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:
+2
-1
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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";
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user