feat: better ux for tagging people in album
This commit is contained in:
@@ -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]}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user