feat: super charged albums page with people management tools

Signed-off-by: Varun Raj <varun@skcript.com>
This commit is contained in:
Varun Raj
2024-11-30 11:30:21 +05:30
parent b5bce5050c
commit 6b27686f39
28 changed files with 1004 additions and 105 deletions
BIN
View File
Binary file not shown.
+2
View File
@@ -14,7 +14,9 @@
"url": "https://varunraj.in"
},
"dependencies": {
"@radix-ui/react-accordion": "^1.2.1",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-hover-card": "^1.1.1",
+159
View File
@@ -0,0 +1,159 @@
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 { IAsset } from '@/types/asset'
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Hourglass } from 'lucide-react';
import { useRouter } from 'next/router';
import { CalendarArrowUp } from 'lucide-react';
import Lightbox from 'yet-another-react-lightbox';
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";
interface AlbumImagesProps {
album: IAlbum
}
interface IAssetFilter {
faceId?: string
page: number
}
export default function AlbumImages({ album }: AlbumImagesProps) {
const { exImmichUrl } = useConfig();
const router = useRouter();
const { faceId } = router.query as { faceId: string };
const [assets, setAssets] = useState<IAsset[]>([])
const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [filters, setFilters] = useState<IAssetFilter>({
faceId,
page: 1
})
const [hasMore, setHasMore] = useState(true)
const [index, setIndex] = useState(-1);
const fetchAssets = async () => {
setLoading(true);
return listAlbumAssets(album.id, filters)
.then((assets) => {
if (filters.page === 1) {
setAssets(assets)
} else {
setAssets([...assets, ...assets])
}
setHasMore(assets.length >= 100)
})
.catch(setErrorMessage)
.finally(() => setLoading(false));
};
const images = useMemo(() => {
return assets.map((p) => ({
...p,
src: p.url as string,
original: p.previewUrl as string,
width: p.exifImageWidth as number,
height: p.exifImageHeight as number,
// isSelected: selectedIds.includes(p.id),
tags: [
{
title: "Immich Link",
value: (
<a href={exImmichUrl + "/photos/" + p.id} target="_blank">
Open in Immich
</a>
),
},
],
}));
}, [assets]);
const slides = useMemo(
() =>
images.map(({ original, width, height }) => ({
src: original,
width,
height,
})),
[images]
);
const handleClick = (idx: number) => setIndex(idx);
// const handleSelect = (_idx: number, asset: IAsset) => {
// const isPresent = selectedIds.includes(asset.id);
// if (isPresent) {
// updateContext({
// selectedIds: selectedIds.filter((id) => id !== asset.id),
// });
// } else {
// updateContext({ selectedIds: [...selectedIds, asset.id] });
// }
// };
useEffect(() => {
fetchAssets();
}, [album.id, filters]);
useEffect(() => {
setFilters({
faceId,
page: 1
})
}, [faceId])
if (loading && filters.page === 1)
return (
<div className="flex flex-col gap-2 h-full justify-center items-center w-full">
<Hourglass />
<p className="text-lg">Loading...</p>
</div>
);
return (
<>
<Lightbox
slides={slides}
plugins={[Captions]}
open={index >= 0}
index={index}
close={() => setIndex(-1)}
/>
<div className="w-full grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2">
{images.map((image) => (
<div key={image.id} className="w-full h-[200px] overflow-hidden">
<Image
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',
}}
onClick={() => handleClick(images.indexOf(image))}
/>
</div>
))}
</div>
{hasMore && <Button variant="outline" className='w-full' onClick={() => {
setFilters({
...filters,
page: filters.page + 1
})
}}
disabled={loading}
>
Load More
</Button>}
</>
);
}
+160
View File
@@ -0,0 +1,160 @@
import React, { useEffect, useMemo, useState } from 'react'
import { IAlbum } from '@/types/album'
import { IPerson } from '@/types/person'
import { getAlbumPeople } from '@/handlers/api/album.handler'
import Loader from '@/components/ui/loader'
import LazyImage from '@/components/ui/lazy-image'
import { PERSON_THUBNAIL_PATH } from '@/config/routes'
import Link from 'next/link'
import { useConfig } from '@/contexts/ConfigContext'
import { Tooltip } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
import { Users, X } from 'lucide-react'
import { useRouter } from 'next/router'
import Image from 'next/image'
import { PersonMergeDropdown } from '@/components/people/PersonMergeDropdown'
import { Button } from '@/components/ui/button'
import { updatePerson } from '@/handlers/api/people.handler'
interface AlbumPeopleProps {
album: IAlbum
onSelect: (personId: string) => void
}
interface IAlbumPerson {
id: string
name: string
numberOfPhotos: number
}
export default function AlbumPeople({ album, onSelect }: AlbumPeopleProps) {
const { exImmichUrl } = useConfig()
const router = useRouter()
const { query, pathname } = router
const [people, setPeople] = useState<IAlbumPerson[]>([])
const [loading, setLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [hidingPerson, setHidingPerson] = useState<boolean>(false)
const selectedPerson = useMemo(() => {
return people.find((person) => query.faceId === person.id)
}, [people, query.faceId])
const fetchPeople = async () => {
return getAlbumPeople(album.id).then((people) => {
setPeople(people)
}).catch((error) => {
setErrorMessage(error.message)
}).finally(() => {
setLoading(false)
})
}
const handleHidePerson = (personId: string) => {
setHidingPerson(true)
updatePerson(personId, { isHidden: true }).then(() => {
setPeople(people.filter((person) => person.id !== personId))
router.push({
pathname,
query: {
...query,
faceId: null
}
})
}).finally(() => {
setHidingPerson(false)
})
}
useEffect(() => {
fetchPeople()
}, [])
if (loading) {
return <Loader />
}
if (errorMessage) {
return <div>{errorMessage}</div>
}
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) => {
console.log(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>
</>
)}
<AccordionTrigger className="flex items-center justify-between gap-2 w-full" />
</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={() => 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" : ""
)
}
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>
)
}
@@ -0,0 +1,64 @@
import React, { useMemo } from 'react'
import Link from 'next/link';
import { useConfig } from '@/contexts/ConfigContext';
import { humanizeNumber } from '@/helpers/string.helper';
import LazyImage from '@/components/ui/lazy-image';
import { ASSET_THUMBNAIL_PATH } from '@/config/routes';
import { IAlbum } from '@/types/album';
import { Badge } from '@/components/ui/badge';
import { formatDate } from '@/helpers/date.helper';
import { Checkbox } from '@/components/ui/checkbox';
import { differenceInDays } from 'date-fns';
import { FaceIcon } from '@radix-ui/react-icons';
import { Camera, Image, User } from 'lucide-react';
interface IAlbumThumbnailProps {
album: IAlbum;
onSelect: (checked: boolean) => void;
}
export default function AlbumThumbnail({ album, onSelect }: IAlbumThumbnailProps) {
const numberOfDays = useMemo(() => {
return differenceInDays(album.lastPhotoDate, album.firstPhotoDate);
}, [album.firstPhotoDate, album.lastPhotoDate]);
return (
<div className="border rounded-lg overflow-hidden shadow-lg relative">
<label className="block relative">
<LazyImage
src={ASSET_THUMBNAIL_PATH(album.albumThumbnailAssetId)}
alt={album.albumName}
title={album.albumName}
style={{
width: '100%',
height: '200px',
objectFit: 'cover',
}}
/>
<div className="absolute bottom-0 w-full bg-gray-800/70 text-white text-center text-xs font-bold py-1">
{formatDate(album.firstPhotoDate.toString(), 'MMM d, yyyy')} - {formatDate(album.lastPhotoDate.toString(), 'MMM d, yyyy')}
</div>
<Checkbox
onCheckedChange={onSelect}
className="absolute hidden top-2 left-2 w-6 h-6 rounded-full border-gray-300 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
/>
</label>
<div className="flex items-center gap-2 absolute top-2 right-2 ">
<div className="bg-gray-800 flex items-center gap-1 text-white text-xs font-bold py-1 px-2 rounded">
{humanizeNumber(album.assetCount)} <Camera size={12} />
</div>
<p className="bg-gray-800 flex items-center gap-1 text-white text-xs font-bold py-1 px-2 rounded">
{album.faceCount} <User size={12} />
</p>
</div>
<div className="p-4">
<Link href={`/albums/${album.id}`}>
<h2 className="text-md font-semibold truncate">{album.albumName}</h2>
</Link>
<p className="text-sm text-gray-700 dark:text-gray-500">{
numberOfDays === 0 ? '1 day' : `${numberOfDays.toLocaleString() } days`
}</p>
</div>
</div>
)
}
@@ -9,7 +9,7 @@ import Captions from "yet-another-react-lightbox/plugins/captions";
import { CalendarArrowDown, CalendarArrowUp, Hourglass } from "lucide-react";
import Video from "yet-another-react-lightbox/plugins/video";
import { useConfig } from "@/contexts/ConfigContext";
import LazyImage from "@/components/ui/lazy-image";
import LazyGridImage from "@/components/ui/lazy-grid-image";
export default function PotentialAlbumsAssets() {
const { exImmichUrl } = useConfig();
@@ -124,7 +124,7 @@ export default function PotentialAlbumsAssets() {
onClick={handleClick}
enableImageSelection={true}
onSelect={handleSelect}
thumbnailImageComponent={LazyImage}
thumbnailImageComponent={LazyGridImage}
tagStyle={{
color: "white",
fontSize: "12px",
@@ -5,7 +5,6 @@ import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { formatDate, offsetDate } from '@/helpers/date.helper';
import LazyImage from '@/components/ui/lazy-image';
import Image from 'next/image';
import { updateAssets } from '@/handlers/api/asset.handler';
import { useToast } from '@/components/ui/use-toast';
@@ -18,7 +18,7 @@ import { listMissingLocationAssets } from "@/handlers/api/asset.handler";
import { formatDate, parseDate } from "@/helpers/date.helper";
import { addDays } from "date-fns";
import { useConfig } from "@/contexts/ConfigContext";
import LazyImage from "@/components/ui/lazy-image";
import LazyGridImage from "@/components/ui/lazy-grid-image";
export default function MissingLocationAssets() {
const { exImmichUrl } = useConfig();
@@ -61,7 +61,7 @@ export default function MissingLocationAssets() {
),
},
],
}));
})).filter((p) => !p.original);
}, [assets, selectedIds]);
const slides = useMemo(
@@ -126,7 +126,7 @@ export default function MissingLocationAssets() {
onClick={handleClick}
enableImageSelection={true}
onSelect={handleSelect}
thumbnailImageComponent={LazyImage}
thumbnailImageComponent={LazyGridImage}
tagStyle={{
color: "white",
fontSize: "12px",
+3 -3
View File
@@ -20,11 +20,11 @@ export default function PageLayout({ children, className }: IProps) {
: children;
return (
<>
<div>
{header}
<main
className={cn(
"flex flex-1 max-h-[calc(100vh-60px)] flex-col gap-4 p-4 lg:gap-6 lg:p-6 overflow-y-auto",
"flex flex-1 max-h-[calc(100vh-60px)] flex-col gap-4 p-4 lg:gap-6 lg:p-6 overflow-y-auto mx-auto",
{
"mb-14": !!header,
},
@@ -33,6 +33,6 @@ export default function PageLayout({ children, className }: IProps) {
>
{childrenWithoutHeader}
</main>
</>
</div>
);
}
@@ -36,11 +36,13 @@ import ErrorBlock from "../shared/ErrorBlock";
interface PersonMergeDropdownProps {
person: IPerson;
onRemove?: (person: IPerson) => void;
onComplete?: (mergedPerson: IPerson) => void;
}
export function PersonMergeDropdown({
person,
onRemove,
onComplete,
}: PersonMergeDropdownProps) {
const [searchedPeople, setSearchedPeople] = useState<IPerson[] | null>(null);
const [similarPeople, setSimilarPeople] = useState<IPerson[]>([]);
@@ -127,6 +129,7 @@ export function PersonMergeDropdown({
description: "People merged successfully",
});
setMerging(false);
onComplete?.(primaryPerson);
return;
}
@@ -159,6 +162,11 @@ export function PersonMergeDropdown({
if (open && !similarPeople.length) fetchSuggestions();
}, [open, person.id, similarPeople]);
useEffect(() => {
setPrimaryPerson(person);
setSelectedPeople([]);
}, [person]);
const renderPeopleList = (people: IPerson[], title: string) => {
return (
<div className="max-h-[400px] min-h-[400px] overflow-y-auto">
+56
View File
@@ -0,0 +1,56 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { cn } from "@/lib/utils"
import { ChevronDownIcon } from "@radix-ui/react-icons"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> & {
noIcon?: boolean
}
>(({ className, children, noIcon, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
{!noIcon && <ChevronDownIcon className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />}
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
+27
View File
@@ -0,0 +1,27 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { cn } from "@/lib/utils"
import { CheckIcon } from "@radix-ui/react-icons"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<CheckIcon className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }
+44
View File
@@ -0,0 +1,44 @@
/* eslint-disable @next/next/no-img-element */
import React, { useEffect } from 'react'
import { Image, ImageExtended, ThumbnailImageProps } from 'react-grid-gallery'
interface LazyImageProps extends ThumbnailImageProps<ImageExtended<Image>> {}
export default function LazyGridImage(
props: LazyImageProps
) {
const [isVisible, setIsVisible] = React.useState(false)
const imageRef = React.useRef<HTMLImageElement>(null)
const setupObserver = () => {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setIsVisible(true)
observer.disconnect()
}
})
})
if (imageRef.current) {
observer.observe(imageRef.current)
}
return observer
}
useEffect(() => {
const observer = setupObserver()
return () => {
observer?.disconnect()
}
}, [])
if (!isVisible) return (
<div style={{ height: props.height }} ref={imageRef} />
)
return (
<img {...props.imageProps} alt={props.imageProps.alt || ""} title="" />
)
}
+3 -5
View File
@@ -1,8 +1,8 @@
/* eslint-disable @next/next/no-img-element */
import { ImageProps } from 'next/image'
import React, { useEffect } from 'react'
import { Image, ImageExtended, ThumbnailImageProps } from 'react-grid-gallery'
interface LazyImageProps extends ThumbnailImageProps<ImageExtended<Image>> {}
interface LazyImageProps extends ImageProps {}
export default function LazyImage(
props: LazyImageProps
@@ -38,7 +38,5 @@ export default function LazyImage(
<div style={{ height: props.height }} ref={imageRef} />
)
return (
<img {...props.imageProps} alt={props.imageProps.alt || ""} title="" />
)
return <img {...props} src={props.src as string} alt={props.alt as string} />
}
+4 -1
View File
@@ -19,7 +19,10 @@ export const EXIF_DISTRIBUTION_PATH = (column: string) => BASE_API_ENDPOINT + "/
// Albums
export const LIST_POTENTIAL_ALBUMS_DATES_PATH = BASE_API_ENDPOINT + "/albums/potential-albums-dates";
export const LIST_POTENTIAL_ALBUMS_ASSETS_PATH = BASE_API_ENDPOINT + "/albums/potential-albums-assets";
export const LIST_ALBUMS_PATH = BASE_PROXY_ENDPOINT + "/albums";
export const LIST_ALBUMS_PATH = BASE_API_ENDPOINT + "/albums/list";
export const ALBUM_INFO_PATH = (id: string) => BASE_API_ENDPOINT + "/albums/" + id + "/info";
export const ALBUM_PEOPLE_PATH = (id: string) => BASE_API_ENDPOINT + "/albums/" + id + "/people";
export const ALBUM_ASSETS_PATH = (id: string) => BASE_API_ENDPOINT + "/albums/" + id + "/assets";
export const CREATE_ALBUM_PATH = BASE_PROXY_ENDPOINT + "/albums";
export const ADD_ASSETS_ALBUMS_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/albums/" + id + "/assets";
+13 -2
View File
@@ -1,4 +1,4 @@
import { ADD_ASSETS_ALBUMS_PATH, CREATE_ALBUM_PATH, LIST_ALBUMS_PATH, LIST_POTENTIAL_ALBUMS_ASSETS_PATH, LIST_POTENTIAL_ALBUMS_DATES_PATH } from "@/config/routes";
import { ADD_ASSETS_ALBUMS_PATH, ALBUM_ASSETS_PATH, ALBUM_INFO_PATH, ALBUM_PEOPLE_PATH, CREATE_ALBUM_PATH, LIST_ALBUMS_PATH, LIST_POTENTIAL_ALBUMS_ASSETS_PATH, LIST_POTENTIAL_ALBUMS_DATES_PATH } from "@/config/routes";
import { cleanUpAsset } from "@/helpers/asset.helper";
import API from "@/lib/api";
import { IAlbumCreate } from "@/types/album";
@@ -24,7 +24,18 @@ export const listPotentialAlbumsAssets = async (filters: IPotentialAlbumsDatesFi
}
export const listAlbums = async () => {
return API.get(LIST_ALBUMS_PATH);
return API.get(LIST_ALBUMS_PATH);
}
export const getAlbumInfo = async (id: string) => {
return API.get(ALBUM_INFO_PATH(id));
}
export const getAlbumPeople = async (id: string) => {
return API.get(ALBUM_PEOPLE_PATH(id));
}
export const listAlbumAssets = async (id: string, filters: { faceId?: string }) => {
return API.get(ALBUM_ASSETS_PATH(id), filters).then((assets) => assets.map(cleanUpAsset));
}
export const addAssetToAlbum = async (albumId: string, assetIds: string[]) => {
+3
View File
@@ -0,0 +1,3 @@
export const humanizeNumber = (number: number) => {
return number.toLocaleString();
}
+104
View File
@@ -0,0 +1,104 @@
import { ASSET_THUMBNAIL_PATH } from '@/config/routes'
import PageLayout from '@/components/layouts/PageLayout'
import Header from '@/components/shared/Header'
import Loader from '@/components/ui/loader'
import { useConfig } from '@/contexts/ConfigContext'
import { getAlbumInfo, listAlbums } from '@/handlers/api/album.handler'
import { IAlbum } from '@/types/album'
import Image from 'next/image'
import Link from 'next/link'
import React, { useEffect, useState } from 'react'
import AlbumThumbnail from '@/components/albums/list/AlbumThumbnail'
import { Button } from '@/components/ui/button'
import { useRouter } from 'next/router'
import AlbumPeople from '@/components/albums/info/AlbumPeople'
import AlbumImages from '@/components/albums/info/AlbumImages'
export default function AlbumListPage() {
const router = useRouter()
const { pathname } = router
const { albumId, faceId } = router.query as { albumId: string, faceId: string }
const [albumInfo, setAlbumInfo] = useState<IAlbum>()
const [loading, setLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const [selectedAlbums, setSelectedAlbums] = useState<string[]>([])
const fetchAlbumInfo = async () => {
setLoading(true)
getAlbumInfo(albumId)
.then(setAlbumInfo)
.catch((error) => {
setErrorMessage(error.message)
})
.finally(() => {
setLoading(false)
})
}
useEffect(() => {
fetchAlbumInfo()
}, [])
const handleSelect = (checked: boolean, albumId: string) => {
if (checked) {
setSelectedAlbums([...selectedAlbums, albumId])
} else {
setSelectedAlbums(selectedAlbums.filter((id) => id !== albumId))
}
}
const handleSelectPerson = (personId: string) => {
if (faceId === personId) {
router.push({
pathname,
query: {
albumId
}
})
} else {
router.push({
pathname,
query: {
albumId,
faceId: personId
}
})
}
}
const renderContent = () => {
if (loading) {
return <Loader />
}
else if (errorMessage) {
return <div>{errorMessage}</div>
}
return (
<div className="flex flex-col gap-4 p-4">
{albumInfo && (
<>
<AlbumPeople album={albumInfo} key={albumInfo.id} onSelect={handleSelectPerson} />
<AlbumImages album={albumInfo} key={albumInfo.id} />
</>
)}
</div>
)
}
return (
<PageLayout className="!p-0 !mb-0">
<Header
leftComponent={albumInfo?.albumName || "Loading..."}
rightComponent={
!loading && (
<div className="flex items-center gap-2">
<p className="text-sm text-gray-700 dark:text-gray-500">{albumInfo?.assetCount} photos</p>
<p className="text-sm text-gray-700 dark:text-gray-500">{albumInfo?.faceCount} faces</p>
</div>
)
}
/>
{renderContent()}
</PageLayout>
)
}
+14 -18
View File
@@ -8,12 +8,15 @@ import { IAlbum } from '@/types/album'
import Image from 'next/image'
import Link from 'next/link'
import React, { useEffect, useState } from 'react'
import AlbumThumbnail from '@/components/albums/list/AlbumThumbnail'
import { Button } from '@/components/ui/button'
export default function AlbumListPage() {
const { exImmichUrl } = useConfig()
const { exImmichUrl } = useConfig()
const [albums, setAlbums] = useState<IAlbum[]>([])
const [loading, setLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const [selectedAlbums, setSelectedAlbums] = useState<string[]>([])
const fetchAlbums = async () => {
setLoading(true)
@@ -31,8 +34,16 @@ const { exImmichUrl } = useConfig()
fetchAlbums()
}, [])
const handleSelect = (checked: boolean, albumId: string) => {
if (checked) {
setSelectedAlbums([...selectedAlbums, albumId])
} else {
setSelectedAlbums(selectedAlbums.filter((id) => id !== albumId))
}
}
const renderContent = () => {
if (loading) {
return <Loader />
}
@@ -42,22 +53,7 @@ const { exImmichUrl } = useConfig()
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 p-4">
{albums.map((album) => (
<div key={album.id} className="border rounded-lg overflow-hidden shadow-lg">
<Image
src={ASSET_THUMBNAIL_PATH(album.albumThumbnailAssetId)} // Assuming this is a URL to the thumbnail
alt={album.albumName}
width={300}
height={300}
loading='lazy'
className="w-full h-48 object-cover"
/>
<div className="p-4">
<Link href={`${exImmichUrl}/albums/${album.id}`} target="_blank">
<h2 className="text-md font-semibold truncate">{album.albumName}</h2>
</Link>
<p className="text-sm text-gray-700 dark:text-gray-500">{album.assetCount} assets</p>
</div>
</div>
<AlbumThumbnail album={album} key={album.id} onSelect={(checked) => handleSelect(checked, album.id)} />
))}
</div>
)
+62
View File
@@ -0,0 +1,62 @@
import { NextApiRequest } from "next";
import { db } from "@/config/db";
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import { NextApiResponse } from "next";
import { albums } from "@/schema/albums.schema";
import { count, desc, eq, min, max, sql, and, asc, isNotNull } from "drizzle-orm";
import { assets } from "@/schema/assets.schema";
import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema";
import { users } from "@/schema/users.schema";
import { assetFaces, exif, person } from "@/schema";
import { IAlbum } from "@/types/album";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const currentUser = await getCurrentUser(req);
if (!currentUser) {
return res.status(401).json({ error: "Unauthorized" });
}
const { id, faceId, page = 1 } = req.query as {
id?: string,
faceId?: string,
page?: number
};
if (!id) {
return res.status(400).json({ error: "Album id is required" });
}
const dbAssets = await db.select({
id: assets.id,
deviceId: assets.deviceId,
type: assets.type,
originalPath: assets.originalPath,
isFavorite: assets.isFavorite,
duration: assets.duration,
encodedVideoPath: assets.encodedVideoPath,
originalFileName: assets.originalFileName,
sidecarPath: assets.sidecarPath,
deletedAt: assets.deletedAt,
localDateTime: assets.localDateTime,
exifImageWidth: exif.exifImageWidth,
exifImageHeight: exif.exifImageHeight,
ownerId: assets.ownerId,
dateTimeOriginal: exif.dateTimeOriginal,
})
.from(albumsAssetsAssets)
.leftJoin(assets, eq(albumsAssetsAssets.assetsId, assets.id))
.leftJoin(exif, eq(assets.id, exif.assetId))
.leftJoin(assetFaces, eq(assets.id, assetFaces.assetId))
.leftJoin(person, eq(assetFaces.personId, person.id))
.where(and(
eq(albumsAssetsAssets.albumsId, id),
eq(assets.isVisible, true),
faceId ? eq(assetFaces.personId, faceId) : undefined,
))
.limit(100)
.offset(100 * (page - 1));
res.status(200).json(dbAssets);
}
+49
View File
@@ -0,0 +1,49 @@
import { NextApiRequest } from "next";
import { db } from "@/config/db";
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import { NextApiResponse } from "next";
import { albums } from "@/schema/albums.schema";
import { count, desc, eq, min, max, sql, and } from "drizzle-orm";
import { assets } from "@/schema/assets.schema";
import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema";
import { users } from "@/schema/users.schema";
import { assetFaces, exif, person } from "@/schema";
import { IAlbum } from "@/types/album";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const currentUser = await getCurrentUser(req);
if (!currentUser) {
return res.status(401).json({ error: "Unauthorized" });
}
const { id } = req.query as { id: string };
const dbAlbums = await db.select({
id: albums.id,
albumName: albums.albumName,
createdAt: albums.createdAt,
updatedAt: albums.updatedAt,
albumThumbnailAssetId: albums.albumThumbnailAssetId,
assetCount: count(assets.id),
firstPhotoDate: min(exif.dateTimeOriginal),
lastPhotoDate: max(exif.dateTimeOriginal),
faceCount: count(sql<string>`DISTINCT ${person.id}`), // Ensure unique personId
})
.from(albums)
.leftJoin(albumsAssetsAssets, eq(albums.id, albumsAssetsAssets.albumsId))
.leftJoin(assets, eq(albumsAssetsAssets.assetsId, assets.id))
.leftJoin(exif, eq(assets.id, exif.assetId))
.leftJoin(assetFaces, eq(assets.id, assetFaces.assetId))
.leftJoin(person, and(eq(assetFaces.personId, person.id), eq(person.isHidden, false)))
.where(and(eq(albums.ownerId, currentUser.id), eq(albums.id, id)))
.groupBy(albums.id)
.limit(1);
if (dbAlbums.length === 0) {
return res.status(404).json({ error: "Album not found" });
}
res.status(200).json(dbAlbums[0]);
}
+40
View File
@@ -0,0 +1,40 @@
import { NextApiRequest } from "next";
import { db } from "@/config/db";
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import { NextApiResponse } from "next";
import { albums } from "@/schema/albums.schema";
import { count, desc, eq, min, max, sql, and, asc, isNotNull } from "drizzle-orm";
import { assets } from "@/schema/assets.schema";
import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema";
import { users } from "@/schema/users.schema";
import { assetFaces, exif, person } from "@/schema";
import { IAlbum } from "@/types/album";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const currentUser = await getCurrentUser(req);
if (!currentUser) {
return res.status(401).json({ error: "Unauthorized" });
}
const { id } = req.query as { id: string };
const dbAlbumPeople = await db.select({
id: person.id,
name: person.name,
thumbnailAssetId: person.faceAssetId,
numberOfPhotos: count(assets.id),
})
.from(albums)
.leftJoin(albumsAssetsAssets, eq(albums.id, albumsAssetsAssets.albumsId))
.leftJoin(assets, eq(albumsAssetsAssets.assetsId, assets.id))
.leftJoin(assetFaces, eq(assets.id, assetFaces.assetId))
.leftJoin(person, and(eq(assetFaces.personId, person.id), eq(person.isHidden, false)))
.where(and(eq(albums.ownerId, currentUser.id), eq(albums.id, id), isNotNull(person.id)))
.orderBy(desc(person.name))
.groupBy(person.id);
res.status(200).json(dbAlbumPeople);
}
+66
View File
@@ -0,0 +1,66 @@
import { NextApiRequest } from "next";
import { db } from "@/config/db";
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import { NextApiResponse } from "next";
import { albums } from "@/schema/albums.schema";
import { count, desc, eq, min, max, sql, and } from "drizzle-orm";
import { assets } from "@/schema/assets.schema";
import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema";
import { users } from "@/schema/users.schema";
import { assetFaces, exif, person } from "@/schema";
import { IAlbum } from "@/types/album";
const sortAlbums = (albums: IAlbum[], sortBy: string, sortOrder: string) => {
if (sortBy === 'createdAt') {
return albums.sort((a, b) => sortOrder === 'asc' ? a.createdAt.getTime() - b.createdAt.getTime() : b.createdAt.getTime() - a.createdAt.getTime());
}
if (sortBy === 'updatedAt') {
return albums.sort((a, b) => sortOrder === 'asc' ? a.updatedAt.getTime() - b.updatedAt.getTime() : b.updatedAt.getTime() - a.updatedAt.getTime());
}
if (sortBy === 'firstPhotoDate') {
return albums.sort((a, b) => sortOrder === 'asc' ? a.firstPhotoDate.getTime() - b.firstPhotoDate.getTime() : b.firstPhotoDate.getTime() - a.firstPhotoDate.getTime());
}
if (sortBy === 'lastPhotoDate') {
return albums.sort((a, b) => sortOrder === 'asc' ? a.lastPhotoDate.getTime() - b.lastPhotoDate.getTime() : b.lastPhotoDate.getTime() - a.lastPhotoDate.getTime());
}
if (sortBy === 'assetCount') {
return albums.sort((a, b) => sortOrder === 'asc' ? a.assetCount - b.assetCount : b.assetCount - a.assetCount);
}
return albums;
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const currentUser = await getCurrentUser(req);
if (!currentUser) {
return res.status(401).json({ error: "Unauthorized" });
}
const { sortBy = 'lastPhotoDate', sortOrder = 'desc' } = req.query as { sortBy: string, sortOrder: string };
const dbAlbums = await db.select({
id: albums.id,
albumName: albums.albumName,
createdAt: albums.createdAt,
updatedAt: albums.updatedAt,
albumThumbnailAssetId: albums.albumThumbnailAssetId,
assetCount: count(assets.id),
firstPhotoDate: min(exif.dateTimeOriginal),
lastPhotoDate: max(exif.dateTimeOriginal),
faceCount: count(sql<string>`DISTINCT ${assetFaces.personId}`), // Ensure unique personId
})
.from(albums)
.leftJoin(albumsAssetsAssets, eq(albums.id, albumsAssetsAssets.albumsId))
.leftJoin(assets, eq(albumsAssetsAssets.assetsId, assets.id))
.leftJoin(exif, eq(assets.id, exif.assetId))
.leftJoin(assetFaces, eq(assets.id, assetFaces.assetId))
.leftJoin(person, and(eq(assetFaces.personId, person.id), eq(person.isHidden, false)))
.where(eq(albums.ownerId, currentUser.id))
.groupBy(albums.id)
.orderBy(desc(albums.createdAt));
const sortedAlbums = sortAlbums(dbAlbums as IAlbum[], sortBy, sortOrder);
res.status(200).json(dbAlbums);
}
@@ -1,11 +1,8 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { CHART_COLORS } from "@/config/constants/chart.constant";
import { db } from "@/config/db";
import { IPotentialAlbumsDatesResponse } from "@/handlers/api/album.handler";
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import { parseDate } from "@/helpers/date.helper";
import { exif } from "@/schema";
import { count, desc, isNotNull, ne, sql } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
const SELECT_ORPHAN_PHOTOS = (ownerId: string) => sql`
+7
View File
@@ -0,0 +1,7 @@
import { pgTable, uuid, timestamp } from 'drizzle-orm/pg-core';
export const albumsAssetsAssets = pgTable('albums_assets_assets', {
albumsId: uuid('albumsId').notNull(),
assetsId: uuid('assetsId').notNull(),
createdAt: timestamp('createdAt', { withTimezone: true }).defaultNow().notNull(),
});
+16 -1
View File
@@ -4,7 +4,8 @@ import { assets } from "./assets.schema";
import { person } from "./person.schema";
import { users } from "./users.schema";
import { faceSearch } from "./faceSearch.schema";
import { albumsAssetsAssets } from "./albumAssetsAssets.schema";
import { albums } from "./albums.schema";
// Assuming there are relations to be defined with `assets` and `person`
export const assetFacesRelations = relations(assetFaces, ({ one }) => ({
asset: one(assets, {
@@ -38,3 +39,17 @@ export const faceSearchRelations = relations(faceSearch, ({ one }) => ({
references: [assetFaces.id],
}),
}));
// Assuming there are relations to be defined with `albums` and `assets`
export const albumsAssetsAssetsRelations = relations(albumsAssetsAssets, ({ one }) => ({
album: one(albums, {
fields: [albumsAssetsAssets.albumsId],
references: [albums.id],
}),
asset: one(assets, {
fields: [albumsAssetsAssets.assetsId],
references: [assets.id],
}),
}));
+3
View File
@@ -17,6 +17,9 @@ export interface IAlbum {
isActivityEnabled: boolean;
order: string;
lastModifiedAssetTimestamp: Date;
firstPhotoDate: Date;
lastPhotoDate: Date;
faceCount: number;
}
export interface IAlbumOwner {
+92 -66
View File
@@ -11,72 +11,98 @@ const config = {
],
prefix: "",
theme: {
fontFamily: {
mono: [ ...fontFamily.mono ],
},
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
fontFamily: {
mono: [ ...fontFamily.mono ]
},
container: {
center: 'true',
padding: '2rem',
screens: {
'2xl': '1400px'
}
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
}
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
keyframes: {
'accordion-down': {
from: {
height: '0'
},
to: {
height: 'var(--radix-accordion-content-height)'
}
},
'accordion-up': {
from: {
height: 'var(--radix-accordion-content-height)'
},
to: {
height: '0'
}
},
'accordion-down': {
from: {
height: '0'
},
to: {
height: 'var(--radix-accordion-content-height)'
}
},
'accordion-up': {
from: {
height: 'var(--radix-accordion-content-height)'
},
to: {
height: '0'
}
}
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out'
}
}
},
plugins: [require("tailwindcss-animate")],
} satisfies Config