feat: better ux for tagging people in album

This commit is contained in:
Varun Raj
2025-07-27 17:30:23 +05:30
parent 7c6b75ebd9
commit aef4e6ee86
6 changed files with 88 additions and 55 deletions
+6 -2
View File
@@ -1,7 +1,7 @@
import "yet-another-react-lightbox/styles.css"; import "yet-another-react-lightbox/styles.css";
import { listAlbumAssets } from "@/handlers/api/album.handler"; import { listAlbumAssets } from "@/handlers/api/album.handler";
import { useConfig } from "@/contexts/ConfigContext"; import { useConfig } from "@/contexts/ConfigContext";
import { IAlbum } from "@/types/album"; import { IAlbum, IAlbumPerson } from "@/types/album";
import { IAsset } from "@/types/asset"; import { IAsset } from "@/types/asset";
import React, { useEffect, useMemo, useRef, useState, MouseEvent } from "react"; import React, { useEffect, useMemo, useRef, useState, MouseEvent } from "react";
import { Hourglass } from "lucide-react"; import { Hourglass } from "lucide-react";
@@ -12,9 +12,12 @@ import Lightbox from "yet-another-react-lightbox";
import Captions from "yet-another-react-lightbox/plugins/captions"; import Captions from "yet-another-react-lightbox/plugins/captions";
import LazyGridImage from "@/components/ui/lazy-grid-image"; import LazyGridImage from "@/components/ui/lazy-grid-image";
import { usePhotoSelectionContext } from "@/contexts/PhotoSelectionContext"; import { usePhotoSelectionContext } from "@/contexts/PhotoSelectionContext";
import { PERSON_THUBNAIL_PATH } from "@/config/routes";
import Image from "next/image";
interface AlbumImagesProps { interface AlbumImagesProps {
album: IAlbum; album: IAlbum;
selectedPerson: IAlbumPerson | null;
} }
interface IAssetFilter { interface IAssetFilter {
@@ -22,7 +25,7 @@ interface IAssetFilter {
page: number; page: number;
} }
export default function AlbumImages({ album }: AlbumImagesProps) { export default function AlbumImages({ album, selectedPerson }: AlbumImagesProps) {
const { exImmichUrl } = useConfig(); const { exImmichUrl } = useConfig();
const router = useRouter(); const router = useRouter();
const { faceId } = router.query as { faceId: string }; const { faceId } = router.query as { faceId: string };
@@ -158,6 +161,7 @@ export default function AlbumImages({ album }: AlbumImagesProps) {
return ( return (
<> <>
<Lightbox <Lightbox
slides={slides} slides={slides}
plugins={[Captions]} plugins={[Captions]}
+73 -46
View File
@@ -14,6 +14,9 @@ import { Button } from '@/components/ui/button'
import { updatePerson } from '@/handlers/api/people.handler' import { updatePerson } from '@/handlers/api/people.handler'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
import { CheckIcon } from '@radix-ui/react-icons'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
interface AlbumPeopleProps { interface AlbumPeopleProps {
album: IAlbum album: IAlbum
@@ -38,7 +41,7 @@ const AlbumPeople = React.forwardRef<IAlbumPeopleRef, AlbumPeopleProps>(({ album
const [selectionMode, setSelectionMode] = useState<boolean>(false) const [selectionMode, setSelectionMode] = useState<boolean>(false)
const [selectedPeople, setSelectedPeople] = useState<string[]>([]) const [selectedPeople, setSelectedPeople] = useState<string[]>([])
const [searchQuery, setSearchQuery] = useState<string>("") const [searchQuery, setSearchQuery] = useState<string>("")
const selectedPerson = useMemo(() => { const selectedPerson = useMemo(() => {
return people.find((person) => query.faceId === person.id) return people.find((person) => query.faceId === person.id)
}, [people, query.faceId]) }, [people, query.faceId])
@@ -108,7 +111,7 @@ const AlbumPeople = React.forwardRef<IAlbumPeopleRef, AlbumPeopleProps>(({ album
} }
}) })
} }
useEffect(() => { useEffect(() => {
fetchPeople() fetchPeople()
@@ -157,7 +160,7 @@ const AlbumPeople = React.forwardRef<IAlbumPeopleRef, AlbumPeopleProps>(({ album
<LazyImage <LazyImage
role="button" role="button"
className={ className={
cn("cursor-pointer h-8 w-8 min-w-8 rounded-full", cn("cursor-pointer h-10 w-10 min-w-10 rounded-full",
) )
} }
src={PERSON_THUBNAIL_PATH(person.id)} alt={person.name} /> src={PERSON_THUBNAIL_PATH(person.id)} alt={person.name} />
@@ -172,12 +175,29 @@ const AlbumPeople = React.forwardRef<IAlbumPeopleRef, AlbumPeopleProps>(({ album
return ( return (
<div className="overflow-y-auto min-w-[200px] sticky top-0 py-2 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"> <div className="overflow-y-auto min-w-[200px] sticky top-0 py-2 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 <div className="flex items-center gap-2">
placeholder="Search" <Input
className="w-full" placeholder="Search"
value={searchQuery} className="flex-1"
onChange={(e) => setSearchQuery(e.target.value)} value={searchQuery}
/> onChange={(e) => setSearchQuery(e.target.value)}
/>
{!readOnly && !selectedPerson && (
<Button
variant={selectionMode ? "default" : "outline"}
size="sm"
className="h-8 w-8 p-0"
onClick={() => {
setSelectionMode(!selectionMode)
if (selectionMode) {
setSelectedPeople([])
}
}}
>
<CheckIcon className="h-4 w-4" />
</Button>
)}
</div>
{selectedPerson && ( {selectedPerson && (
<div className='flex flex-col gap-2 bg-white dark:bg-zinc-900 py-2 rounded-md'> <div className='flex flex-col gap-2 bg-white dark:bg-zinc-900 py-2 rounded-md'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
@@ -189,6 +209,7 @@ const AlbumPeople = React.forwardRef<IAlbumPeopleRef, AlbumPeopleProps>(({ album
className="rounded-full" className="rounded-full"
/> />
<Link target="_blank" href={`${exImmichUrl}/people/${selectedPerson.id}`} <Link target="_blank" href={`${exImmichUrl}/people/${selectedPerson.id}`}
className="text-sm font-medium"> className="text-sm font-medium">
{selectedPerson.name || "No Name"} {selectedPerson.name || "No Name"}
@@ -224,45 +245,51 @@ const AlbumPeople = React.forwardRef<IAlbumPeopleRef, AlbumPeopleProps>(({ album
)} )}
<div className='flex flex-col gap-1 flex-1 overflow-y-auto'> <div className='flex flex-col gap-1 flex-1 overflow-y-auto'>
<Accordion type="multiple" className="w-full">
<div className="flex items-center gap-2 justify-between"> <AccordionItem value="known-people" className="border-none">
<p className="text-sm font-medium">Known People</p> <AccordionTrigger className="py-2 hover:no-underline">
{!readOnly && ( <div className="flex items-center gap-2 justify-between w-full">
<> <p className="text-xs font-medium">Known ({knownPeople.length})</p>
{!selectedPerson && (
<Button variant="outline" className="!py-0.5 !px-2 text-xs h-7" onClick={() => {
setSelectionMode(!selectionMode)
setSelectedPeople([])
}}>
{selectionMode ? "Cancel" : "Select"}
</Button>
)}
{selectedPeople.length > 0 && (
<div className='absolute mx-auto bottom-0 w-full py-2 bg-white darl:bg-black -mx-2 px-2'>
<Button variant="default" className="!py-0.5 !px-2 text-xs h-7" onClick={handleHideSelectedPeople}>
Hide {selectedPeople.length} people
</Button>
</div> </div>
)} </AccordionTrigger>
</> <AccordionContent>
)} <div className="flex flex-col gap-1">
</div> {knownPeople.map(renderPerson)}
<div className="flex flex-col gap-1"> </div>
{knownPeople.map(renderPerson)} </AccordionContent>
</div> </AccordionItem>
{unknownPeople.length > 0 && (
<div className='flex flex-col gap-1'> {unknownPeople.length > 0 && (
<div className='flex items-center gap-2'> <AccordionItem value="unknown-people" className="border-none">
<p className='text-sm font-medium'>Unknown People</p> <AccordionTrigger className="py-2 hover:no-underline">
<Checkbox <div className="flex items-center gap-2 justify-between w-full">
id='unknown-people-checkbox' <p className="text-xs font-medium">Unknown ({unknownPeople.length})</p>
checked={selectedPeople.length === unknownPeople.length} <Checkbox
onCheckedChange={handleUnknownPeopleCheck} id='unknown-people-checkbox'
/> checked={selectedPeople.length === unknownPeople.length}
onCheckedChange={(checked: boolean) => {
handleUnknownPeopleCheck(checked)
}}
onClick={(e) => e.stopPropagation()}
/>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="flex flex-col gap-1">
{unknownPeople.map(renderPerson)}
</div>
</AccordionContent>
</AccordionItem>
)}
</Accordion>
{selectedPeople.length > 0 && (
<div className='sticky bottom-0 w-full py-2 bg-white dark:bg-zinc-900 border-t border-gray-200 dark:border-zinc-800'>
<Button variant="default" className="!py-0.5 !px-2 text-xs h-7 w-full" onClick={handleHideSelectedPeople}>
Hide {selectedPeople.length} people
</Button>
</div> </div>
{unknownPeople.map(renderPerson)} )}
</div>
)}
</div> </div>
</div> </div>
) )
@@ -1,13 +1,12 @@
import FloatingBar from '@/components/shared/FloatingBar' import FloatingBar from '@/components/shared/FloatingBar'
import { AlertDialog, IAlertDialogActions } from '@/components/ui/alert-dialog' import { AlertDialog, IAlertDialogActions } from '@/components/ui/alert-dialog'
import { Autocomplete, AutocompleteOption } from '@/components/ui/autocomplete' import { Autocomplete, AutocompleteOption } from '@/components/ui/autocomplete'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { PERSON_THUBNAIL_PATH } from '@/config/routes'
import { mergePerson, searchPeople, updatePerson } from '@/handlers/api/people.handler' import { mergePerson, searchPeople, updatePerson } from '@/handlers/api/people.handler'
import { IAlbumPerson } from '@/types/album' import { IAlbumPerson } from '@/types/album'
import Image from 'next/image'
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import { Toaster } from 'react-hot-toast'
interface AlbumPersonNameBarProps { interface AlbumPersonNameBarProps {
selectedPerson: IAlbumPerson | null selectedPerson: IAlbumPerson | null
+1 -1
View File
@@ -6,7 +6,7 @@ interface FloatingBarProps {
export default function FloatingBar({ children }: FloatingBarProps) { export default function FloatingBar({ children }: FloatingBarProps) {
return ( return (
<div className='absolute bottom-10 left-0 right-0 max-w-2xl mx-auto py-2 px-2 rounded-lg flex justify-between items-center bg-white dark:bg-zinc-900 border-t border-zinc-200 dark:border-zinc-800 border'> <div className='absolute bottom-10 left-0 right-0 max-w-2xl mx-auto py-2 px-2 rounded-lg flex justify-between items-center bg-white dark:bg-zinc-900 border-t border-zinc-200 dark:border-zinc-800 border shadow-lg'>
{children} {children}
</div> </div>
) )
+2 -2
View File
@@ -68,8 +68,8 @@ export default function AlbumListPage() {
useEffect(() => { useEffect(() => {
fetchAlbumInfo() fetchAlbumInfo()
}, [])
}, [])
// Update context albumId when router albumId changes // Update context albumId when router albumId changes
useEffect(() => { useEffect(() => {
contextState.updateContext({ contextState.updateContext({
@@ -137,7 +137,7 @@ export default function AlbumListPage() {
{albumInfo && ( {albumInfo && (
<> <>
<AlbumPeople ref={albumPeopleRef} album={albumInfo} key={albumInfo.id} onSelect={handleSelectPerson} /> <AlbumPeople ref={albumPeopleRef} album={albumInfo} key={albumInfo.id} onSelect={handleSelectPerson} />
<AlbumImages album={albumInfo} key={albumInfo.id} /> <AlbumImages album={albumInfo} key={albumInfo.id} selectedPerson={selectedPerson} />
</> </>
)} )}
</div> </div>
+4 -1
View File
@@ -3,7 +3,7 @@ import { NextApiRequest } from "next";
import { db } from "@/config/db"; import { db } from "@/config/db";
import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import { NextApiResponse } from "next"; import { NextApiResponse } from "next";
import { and, eq, isNotNull } from "drizzle-orm"; import { and, desc, eq, isNotNull } from "drizzle-orm";
import { assets } from "@/schema/assets.schema"; import { assets } from "@/schema/assets.schema";
import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema"; import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema";
import { assetFaces, exif, person } from "@/schema"; import { assetFaces, exif, person } from "@/schema";
@@ -56,6 +56,7 @@ export default async function handler(
eq(assets.status, "active"), eq(assets.status, "active"),
faceId ? eq(assetFaces.personId, faceId) : undefined, faceId ? eq(assetFaces.personId, faceId) : undefined,
)) ))
.orderBy(desc(assets.id), desc(assets.localDateTime))
.limit(100) .limit(100)
.offset(100 * (page - 1)); .offset(100 * (page - 1));
@@ -71,6 +72,8 @@ export default async function handler(
orientation: asset?.orientation, orientation: asset?.orientation,
downloadUrl: asset?.id ? ASSET_VIDEO_PATH(asset.id) : null, downloadUrl: asset?.id ? ASSET_VIDEO_PATH(asset.id) : null,
}; };
}).sort((b, a) => {
return new Date(b.localDateTime || 0).getTime() - new Date(a.localDateTime || 0).getTime();
}); });
res.status(200).json(cleanedAssets); res.status(200).json(cleanedAssets);
} }