feat: super charged albums page with people management tools
Signed-off-by: Varun Raj <varun@skcript.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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="" />
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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[]) => {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const humanizeNumber = (number: number) => {
|
||||
return number.toLocaleString();
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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],
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
Vendored
+3
@@ -17,6 +17,9 @@ export interface IAlbum {
|
||||
isActivityEnabled: boolean;
|
||||
order: string;
|
||||
lastModifiedAssetTimestamp: Date;
|
||||
firstPhotoDate: Date;
|
||||
lastPhotoDate: Date;
|
||||
faceCount: number;
|
||||
}
|
||||
|
||||
export interface IAlbumOwner {
|
||||
|
||||
+92
-66
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user