feat: albums page redesigned
Signed-off-by: Varun Raj <varun@skcript.com>
This commit is contained in:
@@ -4,7 +4,7 @@ import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { IAlbum } from '@/types/album'
|
||||
import { IAsset } from '@/types/asset'
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Hourglass } from 'lucide-react';
|
||||
import { ExternalLink, Hourglass } from 'lucide-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { CalendarArrowUp } from 'lucide-react';
|
||||
import Lightbox from 'yet-another-react-lightbox';
|
||||
@@ -12,6 +12,7 @@ import Captions from 'yet-another-react-lightbox/plugins/captions';
|
||||
import LazyImage from '@/components/ui/lazy-image';
|
||||
import Image from 'next/image';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
|
||||
interface AlbumImagesProps {
|
||||
album: IAlbum
|
||||
@@ -116,7 +117,7 @@ export default function AlbumImages({ album }: AlbumImagesProps) {
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Lightbox
|
||||
slides={slides}
|
||||
plugins={[Captions]}
|
||||
@@ -124,23 +125,32 @@ export default function AlbumImages({ album }: AlbumImagesProps) {
|
||||
index={index}
|
||||
close={() => setIndex(-1)}
|
||||
/>
|
||||
<div className="w-full grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2">
|
||||
<div className="w-full grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2 p-2">
|
||||
{images.map((image) => (
|
||||
<div key={image.id} className="w-full h-[200px] overflow-hidden">
|
||||
<Image
|
||||
<div
|
||||
key={image.id}
|
||||
className="w-full h-[200px] overflow-hidden relative"
|
||||
>
|
||||
<LazyImage
|
||||
loading="lazy"
|
||||
key={image.id}
|
||||
src={image.original}
|
||||
alt={image.originalFileName}
|
||||
width={image.width}
|
||||
height={image.height}
|
||||
className='overflow-hidden'
|
||||
style={{
|
||||
objectPosition: 'center',
|
||||
objectFit: 'contain',
|
||||
objectFit: 'cover',
|
||||
height: '100%',
|
||||
}}
|
||||
onClick={() => handleClick(images.indexOf(image))}
|
||||
/>
|
||||
<Link
|
||||
className="absolute top-2 right-2 bg-white dark:bg-zinc-900 p-1 py-0.5 rounded-md flex items-center gap-1 text-xs"
|
||||
target="_blank"
|
||||
href={`${exImmichUrl}/photos/${image.id}`}>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
Open In Immich
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -154,6 +164,6 @@ export default function AlbumImages({ album }: AlbumImagesProps) {
|
||||
>
|
||||
Load More
|
||||
</Button>}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import Image from 'next/image'
|
||||
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'
|
||||
|
||||
interface AlbumPeopleProps {
|
||||
album: IAlbum
|
||||
@@ -98,104 +99,112 @@ export default function AlbumPeople({ album, onSelect }: AlbumPeopleProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion type="multiple">
|
||||
<AccordionItem value="people">
|
||||
<div className='flex justify-between gap-2 w-full sticky top-0 z-10 bg-white dark:bg-black'>
|
||||
<AccordionTrigger noIcon>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4" />
|
||||
<p className="text-sm font-medium">People</p>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedPerson ? (
|
||||
<>
|
||||
<Tooltip content={"Open in Immich"} delayDuration={0}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
<Image
|
||||
src={PERSON_THUBNAIL_PATH(selectedPerson.id)}
|
||||
alt={selectedPerson.name}
|
||||
width={20}
|
||||
height={20}
|
||||
className="rounded-full"
|
||||
/>
|
||||
</div>
|
||||
<Link target="_blank" href={`${exImmichUrl}/people/${selectedPerson.id}`} className="text-sm font-medium">{selectedPerson.name}</Link>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<PersonMergeDropdown person={{
|
||||
...selectedPerson,
|
||||
assetCount: selectedPerson.numberOfPhotos,
|
||||
birthDate: null,
|
||||
thumbnailPath: PERSON_THUBNAIL_PATH(selectedPerson.id),
|
||||
isHidden: false,
|
||||
updatedAt: new Date()
|
||||
}} onComplete={(mergedPerson) => {
|
||||
router.push({
|
||||
pathname,
|
||||
query: {
|
||||
...query,
|
||||
faceId: mergedPerson.id
|
||||
}
|
||||
})
|
||||
}} />
|
||||
<div>
|
||||
<Button disabled={hidingPerson} className="!py-0.5 !px-2 text-xs h-7" variant="outline" onClick={() => handleHidePerson(selectedPerson.id)}>
|
||||
Hide
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedPeople.length > 0 && (
|
||||
<>
|
||||
<p className="text-sm font-medium">{selectedPeople.length} people selected</p>
|
||||
<Button variant="outline" size="sm" onClick={handleHideSelectedPeople}>
|
||||
Hide
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => setSelectionMode(!selectionMode)}>
|
||||
{selectionMode ? "Cancel" : "Select People"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<AccordionTrigger className="flex items-center justify-between gap-2 w-full" />
|
||||
<div className="overflow-y-auto min-w-[200px] sticky top-0 py-4 max-h-[calc(100vh-60px)] min-h-[calc(100vh-60px)] dark:bg-zinc-900 bg-gray-200 flex flex-col gap-2 px-2">
|
||||
|
||||
{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'>
|
||||
<Image
|
||||
src={PERSON_THUBNAIL_PATH(selectedPerson.id)}
|
||||
alt={selectedPerson.name}
|
||||
width={20}
|
||||
height={20}
|
||||
className="rounded-full"
|
||||
/>
|
||||
|
||||
<Link target="_blank" href={`${exImmichUrl}/people/${selectedPerson.id}`}
|
||||
className="text-sm font-medium">
|
||||
{selectedPerson.name || "No Name"}
|
||||
</Link>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<PersonMergeDropdown
|
||||
person={{
|
||||
...selectedPerson,
|
||||
assetCount: selectedPerson.numberOfPhotos,
|
||||
birthDate: null,
|
||||
thumbnailPath: PERSON_THUBNAIL_PATH(selectedPerson.id),
|
||||
isHidden: false,
|
||||
updatedAt: new Date()
|
||||
}}
|
||||
triggerClassName='w-full flex-1'
|
||||
onComplete={(mergedPerson) => {
|
||||
router.push({
|
||||
pathname,
|
||||
query: {
|
||||
...query,
|
||||
faceId: mergedPerson.id
|
||||
}
|
||||
})
|
||||
}} />
|
||||
<Button disabled={hidingPerson} className="!py-0.5 !px-2 text-xs h-7 flex-1" variant="outline" onClick={() => handleHidePerson(selectedPerson.id)}>
|
||||
Hide
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<AccordionContent className="flex items-center flex-wrap overflow-x-hidden gap-1">
|
||||
{people.map((person) => (
|
||||
<Tooltip key={person.id} delayDuration={0} content={(person.name || "No Name")}>
|
||||
<div className='relative'>
|
||||
<LazyImage
|
||||
onClick={() => {
|
||||
if (selectionMode) {
|
||||
handleBulkSelect(person.id)
|
||||
} else {
|
||||
onSelect(person.id)
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
className={
|
||||
cn("cursor-pointer h-16 w-16 min-w-16 rounded border-2",
|
||||
!!person.name ? "border-blue-500" : "border-gray-500",
|
||||
selectedPerson?.id === person.id ? "border-green-500" : "",
|
||||
selectionMode ? "border-blue-500" : "",
|
||||
selectedPeople.includes(person.id) ? "border-green-500" : ""
|
||||
)
|
||||
}
|
||||
src={PERSON_THUBNAIL_PATH(person.id)} alt={person.name} />
|
||||
<p className={
|
||||
cn("text-xs text-black absolute top-0 right-0 left-0 text-center bg-white/50",
|
||||
selectedPerson?.id === person.id ? "bg-green-500" : ""
|
||||
)
|
||||
}>{person.numberOfPhotos}</p>
|
||||
</div>
|
||||
</Tooltip>
|
||||
))}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
|
||||
<div className="flex items-center gap-2 justify-between">
|
||||
<p className="text-sm font-medium">People</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 dark:bg-white bg-black -mx-2 px-2'>
|
||||
<Button variant="outline" className="!py-0.5 !px-2 text-xs h-7" onClick={handleHideSelectedPeople}>
|
||||
Hide {selectedPeople.length} people
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col overflow-x-hidden gap-2">
|
||||
{people.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',
|
||||
selectedPerson?.id === person.id ? "bg-gray-300 dark:bg-gray-700" : ""
|
||||
)}
|
||||
key={person.id}
|
||||
onClick={() => {
|
||||
if (selectionMode) {
|
||||
handleBulkSelect(person.id)
|
||||
} else {
|
||||
onSelect(person.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selectionMode && (
|
||||
<Checkbox
|
||||
id={person.id + "_checkbox"}
|
||||
checked={selectedPeople.includes(person.id)}
|
||||
onCheckedChange={() => handleBulkSelect(person.id)}
|
||||
/>
|
||||
)}
|
||||
<LazyImage
|
||||
|
||||
role="button"
|
||||
className={
|
||||
cn("cursor-pointer h-10 w-10 min-w-10 rounded-full border-2",
|
||||
!!person.name ? "border-green-500" : "border-gray-500",
|
||||
)
|
||||
}
|
||||
src={PERSON_THUBNAIL_PATH(person.id)} alt={person.name} />
|
||||
<div className='flex flex-col'>
|
||||
<p>{person.name || "No Name"}</p>
|
||||
<p className={
|
||||
cn("text-xs text-gray-500 dark:text-gray-400")
|
||||
}>{person.numberOfPhotos} photos</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,17 +32,20 @@ import {
|
||||
import { CaretDownIcon } from "@radix-ui/react-icons";
|
||||
import FaceThumbnail from "./merge/FaceThumbnail";
|
||||
import ErrorBlock from "../shared/ErrorBlock";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PersonMergeDropdownProps {
|
||||
person: IPerson;
|
||||
onRemove?: (person: IPerson) => void;
|
||||
onComplete?: (mergedPerson: IPerson) => void;
|
||||
triggerClassName?: string;
|
||||
}
|
||||
|
||||
export function PersonMergeDropdown({
|
||||
person,
|
||||
onRemove,
|
||||
onComplete,
|
||||
triggerClassName,
|
||||
}: PersonMergeDropdownProps) {
|
||||
const [searchedPeople, setSearchedPeople] = useState<IPerson[] | null>(null);
|
||||
const [similarPeople, setSimilarPeople] = useState<IPerson[]>([]);
|
||||
@@ -222,113 +225,111 @@ export function PersonMergeDropdown({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-4">
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="!py-0.5 !px-2 text-xs h-7">
|
||||
Merge
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Merge {person.name ? person.name : "Untagged Person"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<div className="flex items-center gap-2">
|
||||
<p>
|
||||
{selectedPeople.length > 0
|
||||
? `Merging ${selectedPeople.length} people to ${person.name}`
|
||||
: "Search and select people to merge with"}
|
||||
</p>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex gap-1 items-center">
|
||||
<Avatar
|
||||
src={primaryPerson.thumbnailPath}
|
||||
alt={primaryPerson.name}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<p>{primaryPerson?.name || "Untagged Person"}</p>
|
||||
<CaretDownIcon />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{[person, ...selectedPeople].map((person) => (
|
||||
<DropdownMenuItem
|
||||
key={person.id}
|
||||
onSelect={() => setPrimaryPerson(person)}
|
||||
className="flex gap-1"
|
||||
>
|
||||
<Avatar
|
||||
src={person.thumbnailPath}
|
||||
alt={person.name}
|
||||
className="w-6 h-6"
|
||||
/>
|
||||
<span>{person.name || "Untagged Person"}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
placeholder="Search people..."
|
||||
onChange={(e) => {
|
||||
handleSearch(e.target.value);
|
||||
}}
|
||||
/>
|
||||
|
||||
{selectedPeople.length > 0 ? (
|
||||
<div className="flex flex-nowrap overflow-x-auto gap-2 py-2">
|
||||
{selectedPeople.map((person) => (
|
||||
<div
|
||||
key={person.id}
|
||||
className="flex border px-1 items-center gap-1 dark:bg-zinc-900 rounded-lg p-1"
|
||||
>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className={cn("!py-0.5 !px-2 text-xs h-7", triggerClassName)}>
|
||||
Merge
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Merge {person.name ? person.name : "Untagged Person"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<div className="flex items-center gap-2">
|
||||
<p>
|
||||
{selectedPeople.length > 0
|
||||
? `Merging ${selectedPeople.length} people to ${person.name}`
|
||||
: "Search and select people to merge with"}
|
||||
</p>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex gap-1 items-center">
|
||||
<Avatar
|
||||
src={person.thumbnailPath}
|
||||
alt={person.name}
|
||||
className="w-6 h-6"
|
||||
src={primaryPerson.thumbnailPath}
|
||||
alt={primaryPerson.name}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<p className="text-xs text-nowrap">
|
||||
{person.name ? person.name : <span>Untagged person</span>}
|
||||
</p>
|
||||
<button
|
||||
className="rounded-full dark:hover:bg-zinc-800 hover:bg-zinc-200 p-1"
|
||||
onClick={() => handleRemove(person)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<p>{primaryPerson?.name || "Untagged Person"}</p>
|
||||
<CaretDownIcon />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{[person, ...selectedPeople].map((person) => (
|
||||
<DropdownMenuItem
|
||||
key={person.id}
|
||||
onSelect={() => setPrimaryPerson(person)}
|
||||
className="flex gap-1"
|
||||
>
|
||||
<Avatar
|
||||
src={person.thumbnailPath}
|
||||
alt={person.name}
|
||||
className="w-6 h-6"
|
||||
/>
|
||||
<span>{person.name || "Untagged Person"}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="py-4 text-sm text-zinc-500">No Selections Yet</p>
|
||||
</div>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
placeholder="Search people..."
|
||||
onChange={(e) => {
|
||||
handleSearch(e.target.value);
|
||||
}}
|
||||
/>
|
||||
|
||||
{renderContent()}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setSelectedPeople([]);
|
||||
setPrimaryPerson(person);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleMerge}
|
||||
disabled={selectedPeople.length === 0 || merging}
|
||||
>
|
||||
Merge {selectedPeople.length + 1} People
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
{selectedPeople.length > 0 ? (
|
||||
<div className="flex flex-nowrap overflow-x-auto gap-2 py-2">
|
||||
{selectedPeople.map((person) => (
|
||||
<div
|
||||
key={person.id}
|
||||
className="flex border px-1 items-center gap-1 dark:bg-zinc-900 rounded-lg p-1"
|
||||
>
|
||||
<Avatar
|
||||
src={person.thumbnailPath}
|
||||
alt={person.name}
|
||||
className="w-6 h-6"
|
||||
/>
|
||||
<p className="text-xs text-nowrap">
|
||||
{person.name ? person.name : <span>Untagged person</span>}
|
||||
</p>
|
||||
<button
|
||||
className="rounded-full dark:hover:bg-zinc-800 hover:bg-zinc-200 p-1"
|
||||
onClick={() => handleRemove(person)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="py-4 text-sm text-zinc-500">No Selections Yet</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderContent()}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setSelectedPeople([]);
|
||||
setPrimaryPerson(person);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleMerge}
|
||||
disabled={selectedPeople.length === 0 || merging}
|
||||
>
|
||||
Merge {selectedPeople.length + 1} People
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function AlbumListPage() {
|
||||
return <div>{errorMessage}</div>
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<div className="flex divide-y">
|
||||
{albumInfo && (
|
||||
<>
|
||||
<AlbumPeople album={albumInfo} key={albumInfo.id} onSelect={handleSelectPerson} />
|
||||
|
||||
Reference in New Issue
Block a user