feat: albums page redesigned

Signed-off-by: Varun Raj <varun@skcript.com>
This commit is contained in:
Varun Raj
2024-11-30 13:10:45 +05:30
parent 8749d884c6
commit ea38120fcb
4 changed files with 231 additions and 211 deletions
+19 -9
View File
@@ -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>
);
}
+106 -97
View File
@@ -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>
)
}
+105 -104
View File
@@ -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>
);
}
+1 -1
View File
@@ -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} />