feat: add search functionality to PeopleList and AlbumPeople components

- Introduced search input to filter displayed people based on name in both AlbumPeople and PeopleList components.
- Updated .env.sample to include JWT_SECRET for authentication.
- Added SHARE_LINK_DOWNLOAD_PATH to routes and corresponding handler for downloading share links.
This commit is contained in:
Varun Raj
2025-03-19 09:13:48 +05:30
parent 2915e13532
commit 5db6e414a1
7 changed files with 109 additions and 8 deletions
+2 -1
View File
@@ -15,4 +15,5 @@ GEMINI_API_KEY="" # Gemini API Key for parsing search query in "Find"
# Immich Share Link
IMMICH_SHARE_LINK_KEY="" # Share link key for Immich
POWER_TOOLS_ENDPOINT_URL="" # URL of the Power Tools endpoint (Used for share links)
POWER_TOOLS_ENDPOINT_URL="" # URL of the Power Tools endpoint (Used for share links)
JWT_SECRET="" # JWT Secret for authentication
BIN
View File
Binary file not shown.
+13 -2
View File
@@ -17,6 +17,7 @@ import { PersonMergeDropdown } from '@/components/people/PersonMergeDropdown'
import { Button } from '@/components/ui/button'
import { updatePerson } from '@/handlers/api/people.handler'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
interface AlbumPeopleProps {
album: IAlbum
@@ -40,11 +41,16 @@ export default function AlbumPeople({ album, onSelect, readOnly }: AlbumPeoplePr
const [hidingPerson, setHidingPerson] = useState<boolean>(false)
const [selectionMode, setSelectionMode] = useState<boolean>(false)
const [selectedPeople, setSelectedPeople] = useState<string[]>([])
const [searchQuery, setSearchQuery] = useState<string>("")
const selectedPerson = useMemo(() => {
return people.find((person) => query.faceId === person.id)
}, [people, query.faceId])
const filteredPeople = useMemo(() => {
return people.filter((person) => person.name.toLowerCase().includes(searchQuery.toLowerCase()))
}, [people, searchQuery])
const fetchPeople = async () => {
return getAlbumPeople(album.id).then((people) => {
setPeople(people)
@@ -102,7 +108,12 @@ export default function AlbumPeople({ album, onSelect, readOnly }: AlbumPeoplePr
return (
<div className="overflow-y-auto min-w-[200px] sticky top-0 py-4 max-h-[calc(100vh-60px)] min-h-[calc(100vh-60px)] border-r border-gray-200 dark:border-zinc-800 flex flex-col gap-2 px-2">
<Input
placeholder="Search"
className="w-full"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{selectedPerson && (
<div className='flex flex-col gap-2 bg-white dark:bg-zinc-900 p-2 rounded-md'>
<div className='flex items-center gap-2'>
@@ -172,7 +183,7 @@ export default function AlbumPeople({ album, onSelect, readOnly }: AlbumPeoplePr
)}
</div>
<div className="flex flex-col overflow-x-hidden gap-2">
{people.map((person) => (
{filteredPeople.map((person) => (
<div
className={cn(
'flex items-center gap-2 cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-700 rounded-md p-1',
+16 -4
View File
@@ -1,8 +1,9 @@
import React, { useCallback } from 'react'
import React, { useCallback, useMemo, useState } from 'react'
import { IPerson } from '@/types/person';
import { cn } from '@/lib/utils';
import LazyImage from '../ui/lazy-image';
import { PERSON_THUBNAIL_PATH } from '@/config/routes';
import { Input } from '../ui/input';
interface PeopleListProps {
people: IPerson[];
@@ -11,14 +12,25 @@ interface PeopleListProps {
}
export default function PeopleList({ people, onSelect, selectedIds }: PeopleListProps) {
const [searchQuery, setSearchQuery] = useState<string>("")
const filteredPeople = useMemo(() => {
return people.filter((person) => person.name.toLowerCase().includes(searchQuery.toLowerCase()))
}, [people, searchQuery])
const isSelected = useCallback((person: IPerson) => {
return selectedIds?.includes(person.id)
}, [selectedIds])
return (
<div className="flex flex-col overflow-x-hidden gap-2">
{people.map((person) => (
<div className="flex flex-col overflow-x-hidden gap-2 px-2 pt-2">
<Input
placeholder="Search"
className="w-full"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{filteredPeople.map((person) => (
<div
className={cn(
'flex items-center gap-2 cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-700 rounded-md p-1',
+3 -1
View File
@@ -68,4 +68,6 @@ export const FIND_ASSETS = BASE_API_ENDPOINT + "/find/search";
export const SHARE_LINK_PATH = (token: string) => BASE_API_ENDPOINT + "/share-link/" + token;
export const SHARE_LINK_GENERATE_PATH = BASE_API_ENDPOINT + "/share-link/generate";
export const SHARE_LINK_ASSETS_PATH = (token: string) => BASE_API_ENDPOINT + "/share-link/" + token + "/assets";
export const SHARE_LINK_PEOPLE_PATH = (token: string) => BASE_API_ENDPOINT + "/share-link/" + token + "/people";
export const SHARE_LINK_PEOPLE_PATH = (token: string) => BASE_API_ENDPOINT + "/share-link/" + token + "/people";
export const SHARE_LINK_DOWNLOAD_PATH = (token: string) => BASE_API_ENDPOINT + "/share-link/" + token + "/download";
export const DOWNLOAD_ASSETS_PATH = BASE_PROXY_ENDPOINT + "/download/archive";
+5
View File
@@ -1,6 +1,7 @@
import {
SHARE_LINK_ASSETS_PATH,
SHARE_LINK_DOWNLOAD_PATH,
SHARE_LINK_GENERATE_PATH,
SHARE_LINK_PATH,
SHARE_LINK_PEOPLE_PATH
@@ -22,4 +23,8 @@ export const generateShareLink = async (filters: ShareLinkFilters) => {
export const getShareLinkPeople = async (token: string) => {
return API.get(SHARE_LINK_PEOPLE_PATH(token));
}
export const downloadShareLink = async (token: string, assetIds: string[]) => {
return API.post(SHARE_LINK_DOWNLOAD_PATH(token), { assetIds });
}
@@ -0,0 +1,70 @@
import { db } from "@/config/db";
import { ENV } from "@/config/environment";
import { count, desc, gte, isNotNull, lte, ne } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
import { assetFaces, assets, exif, person } from "@/schema";
import { and } from "drizzle-orm";
import { JsonWebTokenError, sign, verify } from "jsonwebtoken";
import { NextApiResponse } from "next";
import { NextApiRequest } from "next";
import { albums } from "@/schema/albums.schema";
import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema";
import { ASSET_SHARE_THUMBNAIL_PATH, DOWNLOAD_ASSETS_PATH, SHARE_LINK_DOWNLOAD_PATH } from "@/config/routes";
import { getUserHeaders } from "@/helpers/user.helper";
interface ShareLinkFilters {
personIds: string[];
albumIds: string[];
startDate: string;
endDate: string;
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { token } = req.query;
const { assetIds } = req.body;
try {
if (!token) {
return res.status(404).json({ message: "Token not found" });
}
if (req.method !== "POST") {
return res.status(405).json({ message: "Method not allowed" });
}
const decoded = verify(token as string, ENV.JWT_SECRET);
if (!decoded) {
return res.status(401).json({ message: "Unauthorized" });
}
const targetUrl = `${ENV.IMMICH_URL}/api/download/archive`;
const response = await fetch(targetUrl, {
method: 'POST',
body: JSON.stringify({ assetIds }),
headers: getUserHeaders({ isUsingShareKey: true }, {
'Content-Type': 'application/json',
}),
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
// Get the image data from the response
const imageBuffer = await response.arrayBuffer()
// Set the appropriate headers for the image response
res.setHeader('Content-Type', response.headers.get('Content-Type') || 'image/png')
res.setHeader('Content-Length', imageBuffer.byteLength)
// Send the image data
res.send(Buffer.from(imageBuffer))
} catch (error) {
if (error instanceof JsonWebTokenError) {
return res.status(401).json({ message: "Please check your link and try again. Looks like it's expired." });
}
return res.status(500).json({ message: (error as Error).message });
}
}