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 { listAlbumAssets } from "@/handlers/api/album.handler";
import { useConfig } from "@/contexts/ConfigContext";
import { IAlbum } from "@/types/album";
import { IAlbum, IAlbumPerson } from "@/types/album";
import { IAsset } from "@/types/asset";
import React, { useEffect, useMemo, useRef, useState, MouseEvent } from "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 LazyGridImage from "@/components/ui/lazy-grid-image";
import { usePhotoSelectionContext } from "@/contexts/PhotoSelectionContext";
import { PERSON_THUBNAIL_PATH } from "@/config/routes";
import Image from "next/image";
interface AlbumImagesProps {
album: IAlbum;
selectedPerson: IAlbumPerson | null;
}
interface IAssetFilter {
@@ -22,7 +25,7 @@ interface IAssetFilter {
page: number;
}
export default function AlbumImages({ album }: AlbumImagesProps) {
export default function AlbumImages({ album, selectedPerson }: AlbumImagesProps) {
const { exImmichUrl } = useConfig();
const router = useRouter();
const { faceId } = router.query as { faceId: string };
@@ -158,6 +161,7 @@ export default function AlbumImages({ album }: AlbumImagesProps) {
return (
<>
<Lightbox
slides={slides}
plugins={[Captions]}
+73 -46
View File
@@ -14,6 +14,9 @@ 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'
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 {
album: IAlbum
@@ -38,7 +41,7 @@ const AlbumPeople = React.forwardRef<IAlbumPeopleRef, AlbumPeopleProps>(({ album
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])
@@ -108,7 +111,7 @@ const AlbumPeople = React.forwardRef<IAlbumPeopleRef, AlbumPeopleProps>(({ album
}
})
}
useEffect(() => {
fetchPeople()
@@ -157,7 +160,7 @@ const AlbumPeople = React.forwardRef<IAlbumPeopleRef, AlbumPeopleProps>(({ album
<LazyImage
role="button"
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} />
@@ -172,12 +175,29 @@ const AlbumPeople = React.forwardRef<IAlbumPeopleRef, AlbumPeopleProps>(({ album
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">
<Input
placeholder="Search"
className="w-full"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<div className="flex items-center gap-2">
<Input
placeholder="Search"
className="flex-1"
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 && (
<div className='flex flex-col gap-2 bg-white dark:bg-zinc-900 py-2 rounded-md'>
<div className='flex items-center gap-2'>
@@ -189,6 +209,7 @@ const AlbumPeople = React.forwardRef<IAlbumPeopleRef, AlbumPeopleProps>(({ album
className="rounded-full"
/>
<Link target="_blank" href={`${exImmichUrl}/people/${selectedPerson.id}`}
className="text-sm font-medium">
{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 items-center gap-2 justify-between">
<p className="text-sm font-medium">Known People</p>
{!readOnly && (
<>
{!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>
<Accordion type="multiple" className="w-full">
<AccordionItem value="known-people" className="border-none">
<AccordionTrigger className="py-2 hover:no-underline">
<div className="flex items-center gap-2 justify-between w-full">
<p className="text-xs font-medium">Known ({knownPeople.length})</p>
</div>
)}
</>
)}
</div>
<div className="flex flex-col gap-1">
{knownPeople.map(renderPerson)}
</div>
{unknownPeople.length > 0 && (
<div className='flex flex-col gap-1'>
<div className='flex items-center gap-2'>
<p className='text-sm font-medium'>Unknown People</p>
<Checkbox
id='unknown-people-checkbox'
checked={selectedPeople.length === unknownPeople.length}
onCheckedChange={handleUnknownPeopleCheck}
/>
</AccordionTrigger>
<AccordionContent>
<div className="flex flex-col gap-1">
{knownPeople.map(renderPerson)}
</div>
</AccordionContent>
</AccordionItem>
{unknownPeople.length > 0 && (
<AccordionItem value="unknown-people" className="border-none">
<AccordionTrigger className="py-2 hover:no-underline">
<div className="flex items-center gap-2 justify-between w-full">
<p className="text-xs font-medium">Unknown ({unknownPeople.length})</p>
<Checkbox
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>
{unknownPeople.map(renderPerson)}
</div>
)}
)}
</div>
</div>
)
@@ -1,13 +1,12 @@
import FloatingBar from '@/components/shared/FloatingBar'
import { AlertDialog, IAlertDialogActions } from '@/components/ui/alert-dialog'
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 { PERSON_THUBNAIL_PATH } from '@/config/routes'
import { mergePerson, searchPeople, updatePerson } from '@/handlers/api/people.handler'
import { IAlbumPerson } from '@/types/album'
import Image from 'next/image'
import { useRef, useState } from 'react'
import { Toaster } from 'react-hot-toast'
interface AlbumPersonNameBarProps {
selectedPerson: IAlbumPerson | null
+1 -1
View File
@@ -6,7 +6,7 @@ interface FloatingBarProps {
export default function FloatingBar({ children }: FloatingBarProps) {
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}
</div>
)
+2 -2
View File
@@ -68,8 +68,8 @@ export default function AlbumListPage() {
useEffect(() => {
fetchAlbumInfo()
}, [])
}, [])
// Update context albumId when router albumId changes
useEffect(() => {
contextState.updateContext({
@@ -137,7 +137,7 @@ export default function AlbumListPage() {
{albumInfo && (
<>
<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>
+4 -1
View File
@@ -3,7 +3,7 @@ import { NextApiRequest } from "next";
import { db } from "@/config/db";
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
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 { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema";
import { assetFaces, exif, person } from "@/schema";
@@ -56,6 +56,7 @@ export default async function handler(
eq(assets.status, "active"),
faceId ? eq(assetFaces.personId, faceId) : undefined,
))
.orderBy(desc(assets.id), desc(assets.localDateTime))
.limit(100)
.offset(100 * (page - 1));
@@ -71,6 +72,8 @@ export default async function handler(
orientation: asset?.orientation,
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);
}