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 "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]}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user