diff --git a/bun.lockb b/bun.lockb index e85a8f7..f868e0a 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 9b01e46..abe8db2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/albums/info/AlbumImages.tsx b/src/components/albums/info/AlbumImages.tsx new file mode 100644 index 0000000..5bd45b3 --- /dev/null +++ b/src/components/albums/info/AlbumImages.tsx @@ -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([]) + const [loading, setLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [filters, setFilters] = useState({ + 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: ( + + Open in Immich + + ), + }, + ], + })); + }, [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 ( +
+ +

Loading...

+
+ ); + + return ( + <> + = 0} + index={index} + close={() => setIndex(-1)} + /> +
+ {images.map((image) => ( +
+ {image.originalFileName} handleClick(images.indexOf(image))} + /> +
+ ))} +
+ {hasMore && } + + ); +} diff --git a/src/components/albums/info/AlbumPeople.tsx b/src/components/albums/info/AlbumPeople.tsx new file mode 100644 index 0000000..0e78a01 --- /dev/null +++ b/src/components/albums/info/AlbumPeople.tsx @@ -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([]) + const [loading, setLoading] = useState(false) + const [errorMessage, setErrorMessage] = useState(null) + const [hidingPerson, setHidingPerson] = useState(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 + } + if (errorMessage) { + return
{errorMessage}
+ } + + return ( + + +
+ +
+ +

People

+
+
+
+ {selectedPerson && ( + <> + +
+
+ {selectedPerson.name} +
+ {selectedPerson.name} +
+
+ { + console.log(mergedPerson) + router.push({ + pathname, + query: { + ...query, + faceId: mergedPerson.id + } + }) + }} /> +
+ +
+ + )} + +
+
+ + {people.map((person) => ( + +
+ 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} /> +

{person.numberOfPhotos}

+
+
+ ))} +
+
+
+ ) +} diff --git a/src/components/albums/list/AlbumThumbnail.tsx b/src/components/albums/list/AlbumThumbnail.tsx new file mode 100644 index 0000000..a8a5007 --- /dev/null +++ b/src/components/albums/list/AlbumThumbnail.tsx @@ -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 ( +
+ +
+
+ {humanizeNumber(album.assetCount)} +
+

+ {album.faceCount} +

+
+
+ +

{album.albumName}

+ +

{ + numberOfDays === 0 ? '1 day' : `${numberOfDays.toLocaleString() } days` + }

+
+
+ ) +} diff --git a/src/components/albums/potential-albums/PotentialAlbumsAssets.tsx b/src/components/albums/potential-albums/PotentialAlbumsAssets.tsx index 2818863..ebe6870 100644 --- a/src/components/albums/potential-albums/PotentialAlbumsAssets.tsx +++ b/src/components/albums/potential-albums/PotentialAlbumsAssets.tsx @@ -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", diff --git a/src/components/assets/assets-options/AssetOffsetDialog.tsx b/src/components/assets/assets-options/AssetOffsetDialog.tsx index 081f8e1..8c8036a 100644 --- a/src/components/assets/assets-options/AssetOffsetDialog.tsx +++ b/src/components/assets/assets-options/AssetOffsetDialog.tsx @@ -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'; diff --git a/src/components/assets/missing-location/MissingLocationAssets.tsx b/src/components/assets/missing-location/MissingLocationAssets.tsx index 6b58ace..f2a4ee6 100644 --- a/src/components/assets/missing-location/MissingLocationAssets.tsx +++ b/src/components/assets/missing-location/MissingLocationAssets.tsx @@ -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", diff --git a/src/components/layouts/PageLayout.tsx b/src/components/layouts/PageLayout.tsx index 28aa171..cc64db2 100644 --- a/src/components/layouts/PageLayout.tsx +++ b/src/components/layouts/PageLayout.tsx @@ -20,11 +20,11 @@ export default function PageLayout({ children, className }: IProps) { : children; return ( - <> +
{header}
{childrenWithoutHeader}
- +
); } diff --git a/src/components/people/PersonMergeDropdown.tsx b/src/components/people/PersonMergeDropdown.tsx index 28ab97c..0de0fd5 100644 --- a/src/components/people/PersonMergeDropdown.tsx +++ b/src/components/people/PersonMergeDropdown.tsx @@ -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(null); const [similarPeople, setSimilarPeople] = useState([]); @@ -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 (
diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx new file mode 100644 index 0000000..72a9009 --- /dev/null +++ b/src/components/ui/accordion.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + noIcon?: boolean + } +>(({ className, children, noIcon, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + {!noIcon && } + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..8d02b28 --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/src/components/ui/lazy-grid-image.tsx b/src/components/ui/lazy-grid-image.tsx new file mode 100644 index 0000000..6fd03ff --- /dev/null +++ b/src/components/ui/lazy-grid-image.tsx @@ -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> {} + +export default function LazyGridImage( + props: LazyImageProps +) { + const [isVisible, setIsVisible] = React.useState(false) + const imageRef = React.useRef(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 ( +
+ ) + + return ( + {props.imageProps.alt + ) +} diff --git a/src/components/ui/lazy-image.tsx b/src/components/ui/lazy-image.tsx index 9027779..c942d43 100644 --- a/src/components/ui/lazy-image.tsx +++ b/src/components/ui/lazy-image.tsx @@ -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> {} +interface LazyImageProps extends ImageProps {} export default function LazyImage( props: LazyImageProps @@ -38,7 +38,5 @@ export default function LazyImage(
) - return ( - {props.imageProps.alt - ) + return {props.alt } diff --git a/src/config/routes.ts b/src/config/routes.ts index 4bfe2f8..dbd2949 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -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"; diff --git a/src/handlers/api/album.handler.ts b/src/handlers/api/album.handler.ts index 9097c0b..3342c35 100644 --- a/src/handlers/api/album.handler.ts +++ b/src/handlers/api/album.handler.ts @@ -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[]) => { diff --git a/src/helpers/string.helper.ts b/src/helpers/string.helper.ts new file mode 100644 index 0000000..7f5612e --- /dev/null +++ b/src/helpers/string.helper.ts @@ -0,0 +1,3 @@ +export const humanizeNumber = (number: number) => { + return number.toLocaleString(); +} \ No newline at end of file diff --git a/src/pages/albums/[albumId].tsx b/src/pages/albums/[albumId].tsx new file mode 100644 index 0000000..4dfede8 --- /dev/null +++ b/src/pages/albums/[albumId].tsx @@ -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() + const [loading, setLoading] = useState(false) + const [errorMessage, setErrorMessage] = useState('') + const [selectedAlbums, setSelectedAlbums] = useState([]) + + 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 + } + else if (errorMessage) { + return
{errorMessage}
+ } + return ( +
+ {albumInfo && ( + <> + + + + )} +
+ ) + } + return ( + +
+

{albumInfo?.assetCount} photos

+

{albumInfo?.faceCount} faces

+
+ ) + } + /> + {renderContent()} + + ) +} diff --git a/src/pages/albums/index.tsx b/src/pages/albums/index.tsx index ffc0c4b..83041ec 100644 --- a/src/pages/albums/index.tsx +++ b/src/pages/albums/index.tsx @@ -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([]) const [loading, setLoading] = useState(false) const [errorMessage, setErrorMessage] = useState('') + const [selectedAlbums, setSelectedAlbums] = useState([]) 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 } @@ -42,22 +53,7 @@ const { exImmichUrl } = useConfig() return (
{albums.map((album) => ( -
- {album.albumName} -
- -

{album.albumName}

- -

{album.assetCount} assets

-
-
+ handleSelect(checked, album.id)} /> ))}
) diff --git a/src/pages/api/albums/[id]/assets.ts b/src/pages/api/albums/[id]/assets.ts new file mode 100644 index 0000000..56e9456 --- /dev/null +++ b/src/pages/api/albums/[id]/assets.ts @@ -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); +} \ No newline at end of file diff --git a/src/pages/api/albums/[id]/info.ts b/src/pages/api/albums/[id]/info.ts new file mode 100644 index 0000000..10f9625 --- /dev/null +++ b/src/pages/api/albums/[id]/info.ts @@ -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`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]); +} \ No newline at end of file diff --git a/src/pages/api/albums/[id]/people.ts b/src/pages/api/albums/[id]/people.ts new file mode 100644 index 0000000..63b7392 --- /dev/null +++ b/src/pages/api/albums/[id]/people.ts @@ -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); +} \ No newline at end of file diff --git a/src/pages/api/albums/list.ts b/src/pages/api/albums/list.ts new file mode 100644 index 0000000..45fee66 --- /dev/null +++ b/src/pages/api/albums/list.ts @@ -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`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); +} \ No newline at end of file diff --git a/src/pages/api/albums/potential-albums-dates.ts b/src/pages/api/albums/potential-albums-dates.ts index 1998ac7..7b87b9b 100644 --- a/src/pages/api/albums/potential-albums-dates.ts +++ b/src/pages/api/albums/potential-albums-dates.ts @@ -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` diff --git a/src/schema/albumAssetsAssets.schema.ts b/src/schema/albumAssetsAssets.schema.ts new file mode 100644 index 0000000..8d118c0 --- /dev/null +++ b/src/schema/albumAssetsAssets.schema.ts @@ -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(), +}); \ No newline at end of file diff --git a/src/schema/relationships.ts b/src/schema/relationships.ts index 4041dcb..4b5d15b 100644 --- a/src/schema/relationships.ts +++ b/src/schema/relationships.ts @@ -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], + }), +})); + diff --git a/src/types/album.d.ts b/src/types/album.d.ts index 0e3eece..066ebc8 100644 --- a/src/types/album.d.ts +++ b/src/types/album.d.ts @@ -17,6 +17,9 @@ export interface IAlbum { isActivityEnabled: boolean; order: string; lastModifiedAssetTimestamp: Date; + firstPhotoDate: Date; + lastPhotoDate: Date; + faceCount: number; } export interface IAlbumOwner { diff --git a/tailwind.config.ts b/tailwind.config.ts index d4d8bee..5fd6ca1 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -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