Merge pull request #112 from varun-raj/feat-albums-enhancement
Feat: Albums enhancement and ux enhancements
This commit is contained in:
41 files changed
+1074
-272
No files matched your search
@@ -16,6 +16,7 @@
|
||||
"dependencies": {
|
||||
"@google/generative-ai": "^0.21.0",
|
||||
"@radix-ui/react-accordion": "^1.2.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||
"@radix-ui/react-avatar": "^1.1.0",
|
||||
"@radix-ui/react-checkbox": "^1.1.2",
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
@@ -60,6 +61,7 @@
|
||||
"react-day-picker": "9.0.8",
|
||||
"react-dom": "^18",
|
||||
"react-grid-gallery": "^1.0.1",
|
||||
"react-hot-toast": "^2.5.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-mentions": "^4.4.10",
|
||||
"react-query": "^3.39.3",
|
||||
|
||||
@@ -55,7 +55,7 @@ export default function AlbumCreateDialog({ onSubmit, assetIds }: IProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>Create Album</Button>
|
||||
<Button size={"sm"}>Create Album</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function AlbumSelectorDialog({ onSelected }: IProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>Select Album</Button>
|
||||
<Button size={"sm"}>Select Album</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
|
||||
@@ -125,22 +125,21 @@ export default function AlbumImages({ album }: AlbumImagesProps) {
|
||||
index={index}
|
||||
close={() => setIndex(-1)}
|
||||
/>
|
||||
<div className="w-full grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2 p-2">
|
||||
<div className="w-full grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-2 p-2">
|
||||
{images.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="w-full h-[200px] overflow-hidden relative"
|
||||
className="w-full h-[180px] overflow-hidden relative"
|
||||
>
|
||||
<LazyImage
|
||||
loading="lazy"
|
||||
key={image.id}
|
||||
src={image.original}
|
||||
alt={image.originalFileName}
|
||||
className='overflow-hidden'
|
||||
className='overflow-hidden max-h-[180px] max-w-[180px] min-h-[180px] min-w-[180px]'
|
||||
style={{
|
||||
objectPosition: 'center',
|
||||
objectFit: 'cover',
|
||||
height: '100%',
|
||||
objectFit: 'cover'
|
||||
}}
|
||||
onClick={() => handleClick(images.indexOf(image))}
|
||||
/>
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function AlbumPeople({ album, onSelect }: AlbumPeopleProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto min-w-[200px] sticky top-0 py-4 max-h-[calc(100vh-60px)] min-h-[calc(100vh-60px)] dark:bg-zinc-900 bg-gray-200 flex flex-col gap-2 px-2">
|
||||
<div className="overflow-y-auto min-w-[200px] sticky top-0 py-4 max-h-[calc(100vh-60px)] min-h-[calc(100vh-60px)] border-r border-gray-200 dark:border-zinc-800 flex flex-col gap-2 px-2">
|
||||
|
||||
{selectedPerson && (
|
||||
<div className='flex flex-col gap-2 bg-white dark:bg-zinc-900 p-2 rounded-md'>
|
||||
@@ -157,7 +157,7 @@ export default function AlbumPeople({ album, onSelect }: AlbumPeopleProps) {
|
||||
</Button>
|
||||
)}
|
||||
{selectedPeople.length > 0 && (
|
||||
<div className='absolute mx-auto bottom-0 w-full py-2 dark:bg-white bg-black -mx-2 px-2'>
|
||||
<div className='absolute mx-auto bottom-0 w-full py-2 bg-white darl:bg-black -mx-2 px-2'>
|
||||
<Button variant="outline" className="!py-0.5 !px-2 text-xs h-7" onClick={handleHideSelectedPeople}>
|
||||
Hide {selectedPeople.length} people
|
||||
</Button>
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { humanizeBytes, humanizeNumber, pluralize } 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 { Calendar, Camera, Image, User } from 'lucide-react';
|
||||
import { Calendar, Camera } from 'lucide-react';
|
||||
|
||||
interface IAlbumThumbnailProps {
|
||||
album: IAlbum;
|
||||
onSelect: (checked: boolean) => void;
|
||||
selected: boolean;
|
||||
}
|
||||
export default function AlbumThumbnail({ album, onSelect }: IAlbumThumbnailProps) {
|
||||
export default function AlbumThumbnail({ album, onSelect, selected }: IAlbumThumbnailProps) {
|
||||
const [isSelected, setIsSelected] = useState(selected);
|
||||
|
||||
const numberOfDays = useMemo(() => {
|
||||
return differenceInDays(album.lastPhotoDate, album.firstPhotoDate);
|
||||
}, [album.firstPhotoDate, album.lastPhotoDate]);
|
||||
@@ -39,6 +39,7 @@ export default function AlbumThumbnail({ album, onSelect }: IAlbumThumbnailProps
|
||||
{formatDate(album.firstPhotoDate.toString(), 'MMM d, yyyy')} - {formatDate(album.lastPhotoDate.toString(), 'MMM d, yyyy')}
|
||||
</div>
|
||||
<Checkbox
|
||||
defaultChecked={isSelected}
|
||||
onCheckedChange={onSelect}
|
||||
className="absolute top-2 left-2 w-6 h-6 rounded-full border-gray-300 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import "yet-another-react-lightbox/styles.css";
|
||||
import { usePotentialAlbumContext } from "@/contexts/PotentialAlbumContext";
|
||||
import { listPotentialAlbumsAssets } from "@/handlers/api/album.handler";
|
||||
import { IAsset } from "@/types/asset";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import type { IAsset } from "@/types/asset";
|
||||
import React, { type MouseEvent, useEffect, useMemo, useState } from "react";
|
||||
import { Gallery } from "react-grid-gallery";
|
||||
import Lightbox, { SlideImage, SlideTypes } from "yet-another-react-lightbox";
|
||||
import Captions from "yet-another-react-lightbox/plugins/captions";
|
||||
@@ -20,6 +20,7 @@ export default function PotentialAlbumsAssets() {
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const [index, setIndex] = useState(-1);
|
||||
const [lastSelectedIndex, setLastSelectedIndex] = useState(-1);
|
||||
|
||||
const fetchAssets = async () => {
|
||||
setLoading(true);
|
||||
@@ -44,14 +45,15 @@ export default function PotentialAlbumsAssets() {
|
||||
{
|
||||
title: "Immich Link",
|
||||
value: (
|
||||
<a href={exImmichUrl + "/photos/" + p.id} target="_blank">
|
||||
<a href={`${exImmichUrl}/photos/${p.id}`} target="_blank" rel="noreferrer">
|
||||
Open in Immich
|
||||
</a>
|
||||
),
|
||||
},
|
||||
],
|
||||
}));
|
||||
}, [assets, selectedIds]);
|
||||
}, [assets, selectedIds, exImmichUrl]);
|
||||
|
||||
|
||||
const slides = useMemo(
|
||||
() =>
|
||||
@@ -73,17 +75,31 @@ export default function PotentialAlbumsAssets() {
|
||||
[images]
|
||||
);
|
||||
|
||||
const handleClick = (idx: number) => setIndex(idx);
|
||||
|
||||
const handleSelect = (_idx: number, asset: IAsset) => {
|
||||
const handleSelect = (_idx: number, asset: IAsset, event: MouseEvent<HTMLElement>) => {
|
||||
event.stopPropagation();
|
||||
const isPresent = selectedIds.includes(asset.id);
|
||||
if (isPresent) {
|
||||
updateContext({
|
||||
selectedIds: selectedIds.filter((id) => id !== asset.id),
|
||||
});
|
||||
} else {
|
||||
updateContext({ selectedIds: [...selectedIds, asset.id] });
|
||||
const clickedIndex = images.findIndex((image) => {
|
||||
return image.id === asset.id
|
||||
});
|
||||
if (event.shiftKey) {
|
||||
const startIndex = Math.min(clickedIndex, lastSelectedIndex);
|
||||
const endIndex = Math.max(clickedIndex, lastSelectedIndex);
|
||||
const newSelectedIds = images.slice(startIndex, endIndex + 1).map((image) => image.id);
|
||||
const allSelectedIds = [...selectedIds, ...newSelectedIds];
|
||||
const uniqueSelectedIds = [...new Set(allSelectedIds)];
|
||||
updateContext({ selectedIds: uniqueSelectedIds });
|
||||
} else {
|
||||
updateContext({ selectedIds: [...selectedIds, asset.id] });
|
||||
}
|
||||
setLastSelectedIndex(clickedIndex);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -121,7 +137,7 @@ export default function PotentialAlbumsAssets() {
|
||||
<div className="w-full overflow-y-auto max-h-[calc(100vh-60px)]">
|
||||
<Gallery
|
||||
images={images}
|
||||
onClick={handleClick}
|
||||
onClick={setIndex}
|
||||
enableImageSelection={true}
|
||||
onSelect={handleSelect}
|
||||
thumbnailImageComponent={LazyGridImage}
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function PotentialAlbumsDates() {
|
||||
}, [filters]);
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto min-w-[200px] py-4 max-h-[calc(100vh-60px)] min-h-[calc(100vh-60px)] dark:bg-zinc-900 bg-gray-200 flex flex-col gap-2 px-2">
|
||||
<div className="min-w-[200px] py-4 max-h-[calc(100vh-60px)] min-h-[calc(100vh-60px)] border-r border-gray-200 dark:border-zinc-800 flex flex-col gap-2 px-1">
|
||||
<div className="flex justify-between items-center gap-2">
|
||||
<Select
|
||||
defaultValue={filters.sortBy}
|
||||
@@ -69,14 +69,15 @@ export default function PotentialAlbumsDates() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dateRecords.map((record) => (
|
||||
<PotentialDateItem
|
||||
key={record.date}
|
||||
record={record}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
<div className="overflow-y-auto">
|
||||
{dateRecords.map((record) => (
|
||||
<PotentialDateItem
|
||||
key={record.date}
|
||||
record={record}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,11 +20,11 @@ export default function PotentialDateItem({ record, onSelect }: IProps) {
|
||||
onClick={() => onSelect(record.date)}
|
||||
key={record.date}
|
||||
className={
|
||||
cn("flex gap-1 flex-col p-2 py-1 rounded-lg hover:dark:bg-zinc-800 border border-transparent hover:bg-zinc-100 px-4",
|
||||
cn("flex gap-1 flex-col p-2 py-1 rounded-lg hover:dark:bg-zinc-800 border border-transparent hover:bg-zinc-100",
|
||||
startDate === record.date ? "bg-zinc-100 dark:bg-zinc-800 border-gray-300 dark:border-zinc-700" : "")
|
||||
}
|
||||
>
|
||||
<p className="font-mono text-sm">{dateLabel}</p>
|
||||
<p className="text-sm">{dateLabel}</p>
|
||||
<p className="text-xs text-foreground/50">{record.asset_count} Orphan Assets</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Dialog, DialogTitle, DialogHeader, DialogContent } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { shareAlbums } from '@/handlers/api/album.handler';
|
||||
import { IAlbum } from '@/types/album';
|
||||
import React, { ForwardedRef, forwardRef, useImperativeHandle, useState } from 'react'
|
||||
|
||||
export interface IAlbumShareDialogProps {
|
||||
|
||||
}
|
||||
|
||||
export interface IAlbumShareDialogRef {
|
||||
open: (selectedAlbums: IAlbum[]) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
interface IAlbumWithLink extends IAlbum {
|
||||
shareLink?: string;
|
||||
allowDownload?: boolean;
|
||||
allowUpload?: boolean;
|
||||
showMetadata?: boolean;
|
||||
}
|
||||
|
||||
const AlbumShareDialog = forwardRef(({ }: IAlbumShareDialogProps, ref: ForwardedRef<IAlbumShareDialogRef>) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedAlbums, setSelectedAlbums] = useState<IAlbumWithLink[]>([]);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [generated, setGenerated] = useState<boolean>(false);
|
||||
|
||||
const handleGenerateShareLink = async () => {
|
||||
setGenerating(true);
|
||||
const data = selectedAlbums.map((album) => ({
|
||||
albumId: album.id,
|
||||
albumName: album.albumName,
|
||||
assetCount: album.assetCount,
|
||||
allowDownload: !!album.allowDownload,
|
||||
allowUpload: !!album.allowUpload,
|
||||
showMetadata: !!album.showMetadata,
|
||||
}));
|
||||
|
||||
return shareAlbums(data).then((updatedAlbums) => {
|
||||
setSelectedAlbums(updatedAlbums);
|
||||
setGenerated(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
setError(error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setGenerating(false);
|
||||
});
|
||||
}
|
||||
|
||||
const handleAllowPropertyChange = (albumId: string, property: string, checked: boolean) => {
|
||||
setSelectedAlbums((prevAlbums) => prevAlbums.map((album) => album.id === albumId ? { ...album, [property]: checked } : album));
|
||||
}
|
||||
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
open: (selectedAlbums: IAlbum[]) => {
|
||||
setSelectedAlbums(selectedAlbums.map((album) => ({ ...album, allowDownload: true, allowUpload: true, showMetadata: true })));
|
||||
setOpen(true);
|
||||
},
|
||||
close: () => {
|
||||
setOpen(false);
|
||||
}
|
||||
}));
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Share {selectedAlbums.length} albums</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-2">
|
||||
{error && <div className="text-red-500">{error}</div>}
|
||||
<ol className="flex flex-col gap-4 list-decimal px-4 py-4">
|
||||
{selectedAlbums.map((album) => (
|
||||
<li key={album.id}>
|
||||
<div className="flex justify-between gap-1">
|
||||
<h3 className="text-sm font-medium">{album.albumName}</h3>
|
||||
<p className="text-xs border truncate rounded-md px-1 py-0.5 text-muted-foreground">{album.assetCount} Items</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{album.shareLink ?
|
||||
<p className="text-xs text-muted-foreground truncate font-mono overflow-x-auto">{album.shareLink}</p> : (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">No share link generated</p>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Checkbox checked={album.allowDownload} onCheckedChange={(checked) => handleAllowPropertyChange(album.id, 'allowDownload', !!checked)} />
|
||||
<Label className="text-xs">Allow Download</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Checkbox checked={album.allowUpload} onCheckedChange={(checked) => handleAllowPropertyChange(album.id, 'allowUpload', !!checked)} />
|
||||
<Label className="text-xs">Allow Upload</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Checkbox checked={album.showMetadata} onCheckedChange={(checked) => handleAllowPropertyChange(album.id, 'showMetadata', !!checked)} />
|
||||
<Label className="text-xs">Show Metadata</Label>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
))}
|
||||
</ol>
|
||||
{generated ? (
|
||||
<>
|
||||
<p className="text-sm py-2 text-muted-foreground text-center">Share links all generated</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{generating ? <div className="flex justify-center gap-2">
|
||||
<p className="text-sm py-2 text-muted-foreground">Generating share links...</p>
|
||||
</div> : <div className="flex justify-center gap-2">
|
||||
<Button onClick={handleGenerateShareLink} disabled={generating}>
|
||||
Generate For {selectedAlbums.length} albums
|
||||
</Button>
|
||||
|
||||
</div>}
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
})
|
||||
|
||||
AlbumShareDialog.displayName = "AlbumShareDialog";
|
||||
|
||||
export default AlbumShareDialog;
|
||||
@@ -58,7 +58,7 @@ export default function AssetHeatMap() {
|
||||
const threshold2 = minCount + range * 0.4;
|
||||
const threshold3 = minCount + range * 0.6;
|
||||
const threshold4 = minCount + range * 0.8;
|
||||
if (count === 0) return "bg-zinc-800";
|
||||
if (count === 0) return "bg-zinc-200 dark:bg-zinc-800";
|
||||
if (count <= threshold1) return "bg-green-200";
|
||||
if (count <= threshold2) return "bg-green-400";
|
||||
if (count <= threshold3) return "bg-green-500";
|
||||
|
||||
@@ -2,7 +2,7 @@ import "yet-another-react-lightbox/styles.css";
|
||||
import { usePotentialAlbumContext } from "@/contexts/PotentialAlbumContext";
|
||||
import { listPotentialAlbumsAssets } from "@/handlers/api/album.handler";
|
||||
import { IAsset } from "@/types/asset";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState, MouseEvent } from "react";
|
||||
import { Gallery } from "react-grid-gallery";
|
||||
import Lightbox from "yet-another-react-lightbox";
|
||||
import Captions from "yet-another-react-lightbox/plugins/captions";
|
||||
@@ -20,24 +20,29 @@ import { addDays } from "date-fns";
|
||||
import { useConfig } from "@/contexts/ConfigContext";
|
||||
import LazyGridImage from "@/components/ui/lazy-grid-image";
|
||||
|
||||
export default function MissingLocationAssets() {
|
||||
interface IProps {
|
||||
groupBy: "date" | "album";
|
||||
}
|
||||
export default function MissingLocationAssets({ groupBy }: IProps) {
|
||||
const { exImmichUrl } = useConfig();
|
||||
|
||||
const { startDate, selectedIds, assets, updateContext } =
|
||||
const { startDate, albumId, selectedIds, assets, updateContext } =
|
||||
useMissingLocationContext();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const [index, setIndex] = useState(-1);
|
||||
const [lastSelectedIndex, setLastSelectedIndex] = useState(-1);
|
||||
|
||||
const fetchAssets = async () => {
|
||||
if (!startDate) return;
|
||||
if (!startDate && !albumId) return;
|
||||
setLoading(true);
|
||||
updateContext({
|
||||
assets: [],
|
||||
});
|
||||
return listMissingLocationAssets({ startDate })
|
||||
const filters = groupBy === "date" ? { startDate } : { albumId }
|
||||
return listMissingLocationAssets(filters)
|
||||
.then((assets) => updateContext({ assets }))
|
||||
.catch(setErrorMessage)
|
||||
.finally(() => setLoading(false));
|
||||
@@ -76,20 +81,34 @@ export default function MissingLocationAssets() {
|
||||
|
||||
const handleClick = (idx: number) => setIndex(idx);
|
||||
|
||||
const handleSelect = (_idx: number, asset: IAsset) => {
|
||||
const handleSelect = (_idx: number, asset: IAsset, event: MouseEvent<HTMLElement>) => {
|
||||
event.stopPropagation();
|
||||
const isPresent = selectedIds.includes(asset.id);
|
||||
if (isPresent) {
|
||||
updateContext({
|
||||
selectedIds: selectedIds.filter((id) => id !== asset.id),
|
||||
});
|
||||
} else {
|
||||
updateContext({ selectedIds: [...selectedIds, asset.id] });
|
||||
const clickedIndex = images.findIndex((image) => {
|
||||
return image.id === asset.id;
|
||||
});
|
||||
if (event.shiftKey) {
|
||||
const startIndex = Math.min(clickedIndex, lastSelectedIndex);
|
||||
const endIndex = Math.max(clickedIndex, lastSelectedIndex);
|
||||
const newSelectedIds = images.slice(startIndex, endIndex + 1).map((image) => image.id);
|
||||
const allSelectedIds = [...selectedIds, ...newSelectedIds];
|
||||
const uniqueSelectedIds = [...new Set(allSelectedIds)];
|
||||
updateContext({ selectedIds: uniqueSelectedIds });
|
||||
} else {
|
||||
updateContext({ selectedIds: [...selectedIds, asset.id] });
|
||||
}
|
||||
setLastSelectedIndex(clickedIndex);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (startDate) fetchAssets();
|
||||
}, [startDate]);
|
||||
if (startDate || albumId) fetchAssets();
|
||||
}, [startDate, albumId]);
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
@@ -99,7 +118,7 @@ export default function MissingLocationAssets() {
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!startDate)
|
||||
if (!startDate && !albumId)
|
||||
return (
|
||||
<div className="flex flex-col gap-2 h-full justify-center items-center w-full">
|
||||
<CalendarArrowUp />
|
||||
|
||||
@@ -8,24 +8,34 @@ import React, { useMemo } from "react";
|
||||
interface IProps {
|
||||
record: IMissingLocationDatesResponse;
|
||||
onSelect: (date: string) => void;
|
||||
groupBy: "date" | "album";
|
||||
}
|
||||
export default function MissingLocationDateItem({ record, onSelect }: IProps) {
|
||||
export default function MissingLocationDateItem({ record, onSelect, groupBy }: IProps) {
|
||||
const { startDate } = useMissingLocationContext()
|
||||
|
||||
const dateLabel = useMemo(() => {
|
||||
if (!record.date) return "Unknown"
|
||||
return formatDate(parseDate(record.date, "yyyy-MM-dd").toISOString(), "do MMM yyyy")
|
||||
}, [record.date])
|
||||
if (groupBy === "album") {
|
||||
return record.label
|
||||
}
|
||||
if (!record.label) return "Unknown"
|
||||
try {
|
||||
return formatDate(parseDate(record.label, "yyyy-MM-dd").toISOString(), "do MMM yyyy")
|
||||
} catch (e) {
|
||||
return record.label
|
||||
}
|
||||
}, [record.label, groupBy])
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
onClick={() => onSelect(record.date)}
|
||||
key={record.date}
|
||||
onClick={() => onSelect(record.value)}
|
||||
key={record.label}
|
||||
className={
|
||||
cn("flex gap-1 flex-col p-2 py-1 rounded-lg hover:dark:bg-zinc-800 border border-transparent hover:bg-zinc-100 px-4",
|
||||
startDate === record.date ? "bg-zinc-100 dark:bg-zinc-800 border-gray-300 dark:border-zinc-700" : "")
|
||||
cn("flex gap-1 flex-col p-1 rounded-lg hover:dark:bg-zinc-800 border border-transparent hover:bg-zinc-100",
|
||||
startDate === record.label ? "bg-zinc-100 dark:bg-zinc-800 border-gray-300 dark:border-zinc-700" : "")
|
||||
}
|
||||
>
|
||||
<p className="font-mono text-sm">{dateLabel}</p>
|
||||
<p className="text-sm truncate">{dateLabel}</p>
|
||||
<p className="text-xs text-foreground/50">{record.asset_count} Orphan Assets</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { use, useEffect, useState } from "react";
|
||||
import { usePotentialAlbumContext } from "@/contexts/PotentialAlbumContext";
|
||||
import { IMissingLocationDatesResponse, listMissingLocationDates } from "@/handlers/api/asset.handler";
|
||||
import { IMissingLocationDatesResponse, listMissingLocationAlbums, listMissingLocationDates } from "@/handlers/api/asset.handler";
|
||||
import MissingLocationDateItem from "./MissingLocationDateItem";
|
||||
import { useMissingLocationContext } from "@/contexts/MissingLocationContext";
|
||||
import { useRouter } from "next/router";
|
||||
@@ -8,7 +8,11 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrig
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SortAsc, SortDesc } from "lucide-react";
|
||||
|
||||
export default function MissingLocationDates() {
|
||||
interface IMissingLocationDatesProps {
|
||||
groupBy: string;
|
||||
|
||||
}
|
||||
export default function MissingLocationDates({ groupBy }: IMissingLocationDatesProps) {
|
||||
const { updateContext } = useMissingLocationContext();
|
||||
const router = useRouter();
|
||||
const [dateRecords, setDateRecords] = React.useState<
|
||||
@@ -20,7 +24,9 @@ export default function MissingLocationDates() {
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const fetchData = async () => {
|
||||
return listMissingLocationDates({
|
||||
const func = groupBy === "album" ? listMissingLocationAlbums : listMissingLocationDates
|
||||
setDateRecords([])
|
||||
return func({
|
||||
sortBy: filters.sortBy,
|
||||
sortOrder: filters.sortOrder,
|
||||
})
|
||||
@@ -30,23 +36,41 @@ export default function MissingLocationDates() {
|
||||
};
|
||||
|
||||
const handleSelect = (date: string) => {
|
||||
updateContext({ startDate: date });
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: { startDate: date },
|
||||
});
|
||||
if (groupBy === "album") {
|
||||
updateContext({ albumId: date });
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: {
|
||||
...router.query,
|
||||
albumId: date,
|
||||
groupBy: "album",
|
||||
startDate: undefined
|
||||
},
|
||||
});
|
||||
} else {
|
||||
updateContext({ startDate: date });
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: {
|
||||
...router.query,
|
||||
startDate: date,
|
||||
groupBy: "date",
|
||||
albumId: undefined
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [filters]);
|
||||
}, [filters, groupBy]);
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto min-w-[200px] py-4 max-h-[calc(100vh-60px)] min-h-[calc(100vh-60px)] dark:bg-zinc-900 bg-gray-200 flex flex-col gap-2 px-2">
|
||||
<div className="min-w-[200px] max-w-[200px] sticky top-0 py-4 max-h-[calc(100vh-60px)] min-h-[calc(100vh-60px)] border-r border-gray-200 dark:border-zinc-800 flex flex-col gap-2 px-2">
|
||||
<div className="flex justify-between items-center gap-2">
|
||||
<Select
|
||||
{groupBy === "album" ? (
|
||||
<p className="text-sm text-gray-500 dark:text-zinc-400">Asset Count</p>
|
||||
) : (<Select
|
||||
defaultValue={filters.sortBy}
|
||||
onValueChange={(value) => setFilters({ ...filters, sortBy: value })}
|
||||
>
|
||||
@@ -60,21 +84,27 @@ export default function MissingLocationDates() {
|
||||
<SelectItem value="asset_count">Asset Count</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Select>)}
|
||||
<div>
|
||||
<Button variant="default" size="sm" onClick={() => setFilters({ ...filters, sortOrder: filters.sortOrder === "asc" ? "desc" : "asc" })}>
|
||||
<Button variant="ghost" size="sm" onClick={() =>
|
||||
setFilters({
|
||||
...filters,
|
||||
sortOrder: filters.sortOrder === "asc" ? "desc" : "asc"
|
||||
})}>
|
||||
{filters.sortOrder === "asc" ? <SortAsc size={16} /> : <SortDesc size={16} />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dateRecords.map((record) => (
|
||||
<MissingLocationDateItem
|
||||
key={record.date}
|
||||
record={record}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
<div className="overflow-y-auto flex flex-col gap-2">
|
||||
{dateRecords.map((record) => (
|
||||
<MissingLocationDateItem
|
||||
key={record.value}
|
||||
record={record}
|
||||
groupBy={groupBy as "date" | "album"}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+6
-8
@@ -12,7 +12,6 @@ import { IPlace } from "@/types/common";
|
||||
import React, { useState } from "react";
|
||||
import TagMissingLocationSearchAndAdd from "./TagMissingLocationSearchAndAdd";
|
||||
import TagMissingLocationSearchLatLong from "./TagMissingLocationSearchLatLong";
|
||||
import { MapPinCheck } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const LazyMap = dynamic(() => import("./Map"), {
|
||||
@@ -30,14 +29,14 @@ export default function TagMissingLocationDialog({
|
||||
const [mapPosition,setMapPosition] = useState<IPlace>({
|
||||
latitude: 48.0,
|
||||
longitude: 16.0,
|
||||
name: "home1"
|
||||
name: "home"
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>Tag Missing Location</Button>
|
||||
<Button size={"sm"}>Tag Missing Location</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="!w-[600px] !max-w-[600px]">
|
||||
<DialogHeader>
|
||||
@@ -46,12 +45,11 @@ export default function TagMissingLocationDialog({
|
||||
Tagging a location will add the location to the selected assets.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Tabs defaultValue="search" className="border rounded-lg">
|
||||
<TabsList className="flex justify-between">
|
||||
<Tabs defaultValue="search">
|
||||
<TabsList className="flex justify-center">
|
||||
<TabsTrigger value="search">Search and Pick</TabsTrigger>
|
||||
<TabsTrigger value="latlong">
|
||||
Latitude and Longitude
|
||||
|
||||
Lat & Long
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="maps">Map</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -63,7 +61,7 @@ export default function TagMissingLocationDialog({
|
||||
onOpenChange={setOpen} location={mapPosition} onLocationChange={setMapPosition} />
|
||||
</TabsContent>
|
||||
<TabsContent value="maps">
|
||||
<div className="py-10 flex flex-col gap-6 items-center ">
|
||||
<div className="flex flex-col gap-6 items-center ">
|
||||
<LazyMap location={mapPosition} onLocationChange={setMapPosition} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
+2
-2
@@ -110,7 +110,7 @@ export default function TagMissingLocationSearchLatLong(
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4 px-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-2 flex-1">
|
||||
<Label>Latitude</Label>
|
||||
<Input
|
||||
placeholder="Latitude"
|
||||
@@ -121,7 +121,7 @@ export default function TagMissingLocationSearchLatLong(
|
||||
onBlur={(e) => handleBlur()}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-2 flex-1">
|
||||
<Label>
|
||||
Longitude
|
||||
</Label>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { IUser } from "@/types/user";
|
||||
import Sidebar from "../shared/Sidebar";
|
||||
import { Toaster } from "../ui/toaster";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import { getMe } from "@/handlers/api/user.handler";
|
||||
import UserContext from "@/contexts/CurrentUserContext";
|
||||
@@ -12,6 +11,7 @@ import { LoginForm } from "../auth/LoginForm";
|
||||
import { useConfig } from "@/contexts/ConfigContext";
|
||||
import { queryClient } from "@/config/rQuery";
|
||||
import { QueryClientProvider } from "react-query";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
type RootLayoutProps = {
|
||||
children: ReactNode;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import React from 'react'
|
||||
|
||||
interface FloatingBarProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function FloatingBar({ children }: FloatingBarProps) {
|
||||
return (
|
||||
<div className='absolute bottom-10 left-0 right-0'>
|
||||
<div className='max-w-lg mx-auto py-2 px-2 rounded-lg flex justify-between items-center bg-white dark:bg-zinc-900 border-t border-zinc-200 dark:border-zinc-800'>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
|
||||
import * as React from 'react'
|
||||
|
||||
import { ButtonProps, buttonVariants } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useImperativeHandle, useState } from 'react'
|
||||
|
||||
const AlertDialogRoot = AlertDialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-gray10 bg-white dark:bg-black p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] dark:border-gray7 sm:rounded-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col space-y-2 text-center sm:text-left',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogHeader.displayName = 'AlertDialogHeader'
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = 'AlertDialogFooter'
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-slate-500 dark:text-slate-400', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action> & {
|
||||
variant?: ButtonProps['variant']
|
||||
}
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'mt-2 sm:mt-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
|
||||
export interface INewChangelogButtonActions {
|
||||
open: () => void
|
||||
}
|
||||
interface AlertDialogProps extends AlertDialogPrimitive.AlertDialogProps {
|
||||
children: React.ReactNode
|
||||
title: string
|
||||
description: string
|
||||
onConfirm: () => void | Promise<void>
|
||||
onCancel: () => void | Promise<void>
|
||||
variant?: ButtonProps['variant']
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const AlertDialog = React.forwardRef<INewChangelogButtonActions, Partial<AlertDialogProps>>(({
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
variant,
|
||||
asChild,
|
||||
...props
|
||||
}, ref) => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
open: () => setOpen(true)
|
||||
}))
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setLoading(true)
|
||||
if (onConfirm) {
|
||||
await onConfirm()
|
||||
}
|
||||
setLoading(false)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialogRoot {...props} open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogTrigger asChild={asChild}>{children}</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={loading}
|
||||
onClick={handleConfirm}
|
||||
variant={variant}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogRoot>
|
||||
)
|
||||
})
|
||||
|
||||
AlertDialog.displayName = 'AlertDialog'
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
+7
-1
@@ -5,9 +5,15 @@ import { ENV } from "./environment";
|
||||
import * as schema from "@/schema";
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
const pool = new Pool({
|
||||
const pool = ENV.DATABASE_URL ? new Pool({
|
||||
connectionString: ENV.DATABASE_URL,
|
||||
keepAlive: true,
|
||||
}) : new Pool({
|
||||
user: ENV.DB_USERNAME,
|
||||
password: ENV.DB_PASSWORD,
|
||||
host: ENV.DB_HOST,
|
||||
port: parseInt(ENV.DB_PORT),
|
||||
database: ENV.DB_DATABASE_NAME,
|
||||
});
|
||||
|
||||
class DatabaseConnectionError extends Error {
|
||||
|
||||
@@ -2,7 +2,12 @@ export const ENV = {
|
||||
IMMICH_URL: (process.env.IMMICH_URL || 'http://immich_server:2283') as string,
|
||||
EXTERNAL_IMMICH_URL: (process.env.EXTERNAL_IMMICH_URL || process.env.IMMICH_URL) as string,
|
||||
IMMICH_API_KEY: process.env.IMMICH_API_KEY as string,
|
||||
DATABASE_URL: (process.env.DATABASE_URL || `postgresql://${process.env.DB_USERNAME}:${process.env.DB_PASSWORD}@${(process.env.DB_HOST || 'immich_postgres')}:${(process.env.DB_PORT || '5432')}/${process.env.DB_DATABASE_NAME}`),
|
||||
DATABASE_URL: process.env.DATABASE_URL as string,
|
||||
DB_USERNAME: process.env.DB_USERNAME as string,
|
||||
DB_PASSWORD: process.env.DB_PASSWORD as string,
|
||||
DB_HOST: process.env.DB_HOST as string,
|
||||
DB_PORT: process.env.DB_PORT as string,
|
||||
DB_DATABASE_NAME: process.env.DB_DATABASE_NAME as string,
|
||||
JWT_SECRET: process.env.JWT_SECRET as string,
|
||||
SECURE_COOKIE: process.env.SECURE_COOKIE === 'true',
|
||||
VERSION: process.env.VERSION,
|
||||
|
||||
@@ -25,9 +25,11 @@ export const ALBUM_PEOPLE_PATH = (id: string) => BASE_API_ENDPOINT + "/albums/"
|
||||
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";
|
||||
|
||||
export const SHARE_ALBUMS_PATH = BASE_API_ENDPOINT + "/albums/share";
|
||||
export const DELETE_ALBUMS_PATH = BASE_API_ENDPOINT + "/albums/delete";
|
||||
// Assets
|
||||
export const LIST_MISSING_LOCATION_DATES_PATH = BASE_API_ENDPOINT + "/assets/missing-location-dates";
|
||||
export const LIST_MISSING_LOCATION_ALBUMS_PATH = BASE_API_ENDPOINT + "/assets/missing-location-albums";
|
||||
export const LIST_MISSING_LOCATION_ASSETS_PATH = BASE_API_ENDPOINT + "/assets/missing-location-assets";
|
||||
export const UPDATE_ASSETS_PATH = BASE_PROXY_ENDPOINT + "/assets";
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createContext, useContext } from "react";
|
||||
|
||||
export interface IMissingLocationConfig {
|
||||
startDate?: string;
|
||||
albumId?: string;
|
||||
selectedIds: string[];
|
||||
assets: IAsset[];
|
||||
}
|
||||
@@ -12,6 +13,7 @@ export interface MissingLocationContext extends IMissingLocationConfig {
|
||||
}
|
||||
const MissingLocationContext = createContext<MissingLocationContext>({
|
||||
startDate: undefined,
|
||||
albumId: undefined,
|
||||
selectedIds: [],
|
||||
assets: [],
|
||||
updateContext: () => { },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
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 { ADD_ASSETS_ALBUMS_PATH, ALBUM_ASSETS_PATH, ALBUM_INFO_PATH, ALBUM_PEOPLE_PATH, CREATE_ALBUM_PATH, DELETE_ALBUMS_PATH, LIST_ALBUMS_PATH, LIST_POTENTIAL_ALBUMS_ASSETS_PATH, LIST_POTENTIAL_ALBUMS_DATES_PATH, SHARE_ALBUMS_PATH } from "@/config/routes";
|
||||
import { cleanUpAsset } from "@/helpers/asset.helper";
|
||||
import API from "@/lib/api";
|
||||
import { IAlbumCreate } from "@/types/album";
|
||||
@@ -45,3 +45,11 @@ export const addAssetToAlbum = async (albumId: string, assetIds: string[]) => {
|
||||
export const createAlbum = async (albumData: IAlbumCreate) => {
|
||||
return API.post(CREATE_ALBUM_PATH, albumData);
|
||||
}
|
||||
|
||||
export const shareAlbums = async (albums: { albumId: string, allowDownload: boolean, allowUpload: boolean, showMetadata: boolean }[]) => {
|
||||
return API.post(SHARE_ALBUMS_PATH, { albums });
|
||||
}
|
||||
|
||||
export const deleteAlbums = async (albumIds: string[]) => {
|
||||
return API.delete(DELETE_ALBUMS_PATH, { albumIds });
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ASSET_GEO_HEATMAP_PATH,
|
||||
FIND_ASSETS,
|
||||
LIST_ALBUMS_PATH,
|
||||
LIST_MISSING_LOCATION_ALBUMS_PATH,
|
||||
LIST_MISSING_LOCATION_ASSETS_PATH,
|
||||
LIST_MISSING_LOCATION_DATES_PATH,
|
||||
UPDATE_ASSETS_PATH,
|
||||
@@ -18,16 +19,25 @@ interface IMissingAssetAlbumsFilters {
|
||||
sortOrder?: string;
|
||||
}
|
||||
export interface IMissingLocationDatesResponse {
|
||||
date: string;
|
||||
label: string;
|
||||
asset_count: number;
|
||||
value: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
|
||||
export const listMissingLocationDates = async (
|
||||
filters: IMissingAssetAlbumsFilters
|
||||
): Promise<IMissingLocationDatesResponse[]> => {
|
||||
return API.get(LIST_MISSING_LOCATION_DATES_PATH, filters);
|
||||
};
|
||||
|
||||
export const listMissingLocationAlbums = async (
|
||||
filters: IMissingAssetAlbumsFilters
|
||||
): Promise<IMissingLocationDatesResponse[]> => {
|
||||
return API.get(LIST_MISSING_LOCATION_ALBUMS_PATH, filters);
|
||||
};
|
||||
|
||||
export const listMissingLocationAssets = async (
|
||||
filters: IMissingAssetAlbumsFilters
|
||||
): Promise<IAsset[]> => {
|
||||
|
||||
@@ -57,7 +57,8 @@ const responseSchema = {
|
||||
export const parseFindQuery = async (query: string): Promise<FindQuery> => {
|
||||
|
||||
const prompt = `
|
||||
Parse the following query and return the query and tags: ${query}
|
||||
Parse the following query and return the query and tags: ${query}.
|
||||
Dont include any information that are not intentionally provided in the query.
|
||||
Additional Information For Parsing:
|
||||
today's date is ${new Date().toISOString().split('T')[0]}
|
||||
`;
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
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 { getAlbumInfo } 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'
|
||||
import { Camera, ExternalLink, LinkIcon, Users } from 'lucide-react'
|
||||
import { humanizeNumber, pluralize } from '@/helpers/string.helper'
|
||||
import { Camera, ExternalLink, Users } from 'lucide-react'
|
||||
import { humanizeNumber } from '@/helpers/string.helper'
|
||||
|
||||
export default function AlbumListPage() {
|
||||
const { exImmichUrl } = useConfig()
|
||||
|
||||
+71
-19
@@ -1,20 +1,19 @@
|
||||
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 { listAlbums } from '@/handlers/api/album.handler'
|
||||
import { deleteAlbums, listAlbums } from '@/handlers/api/album.handler'
|
||||
import { IAlbum } from '@/types/album'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import AlbumThumbnail from '@/components/albums/list/AlbumThumbnail'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Select, SelectItem, SelectContent, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { useRouter } from 'next/router'
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons'
|
||||
import { SortAsc, SortDesc } from 'lucide-react'
|
||||
import { Share, SortAsc, SortDesc, Trash } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import AlbumShareDialog, { IAlbumShareDialogRef } from '@/components/albums/share/AlbumShareDialog'
|
||||
import { AlertDialog } from '@/components/ui/alert-dialog'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
const SORT_BY_OPTIONS = [
|
||||
{ value: 'lastPhotoDate', label: 'Last Photo Date' },
|
||||
@@ -27,7 +26,6 @@ const SORT_BY_OPTIONS = [
|
||||
{ value: 'faceCount', label: 'Number of People' },
|
||||
]
|
||||
export default function AlbumListPage() {
|
||||
const { exImmichUrl } = useConfig()
|
||||
const router = useRouter()
|
||||
const [search, setSearch] = useState('')
|
||||
const { query, pathname } = router
|
||||
@@ -35,11 +33,14 @@ export default function AlbumListPage() {
|
||||
const [albums, setAlbums] = useState<IAlbum[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [selectedAlbums, setSelectedAlbums] = useState<string[]>([])
|
||||
const [selectedAlbumsIds, setSelectedAlbumsIds] = useState<string[]>([])
|
||||
const albumShareDialogRef = useRef<IAlbumShareDialogRef>(null);
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const selectedSortBy = useMemo(() => SORT_BY_OPTIONS.find((option) => option.value === sortBy), [sortBy])
|
||||
|
||||
const searchedAlbums = useMemo(() => albums.filter((album) => album.albumName.toLowerCase().includes(search.toLowerCase())), [albums, search])
|
||||
const searchedAlbums = useMemo(() => albums.filter((album) => album.albumName.toLowerCase().includes(search.toLowerCase())), [albums, search])
|
||||
|
||||
const fetchAlbums = async () => {
|
||||
setLoading(true)
|
||||
listAlbums({
|
||||
@@ -55,18 +56,33 @@ export default function AlbumListPage() {
|
||||
})
|
||||
}
|
||||
|
||||
const selectedAlbums = useMemo(() => albums.filter((album) => selectedAlbumsIds.includes(album.id)), [albums, selectedAlbumsIds])
|
||||
|
||||
useEffect(() => {
|
||||
fetchAlbums()
|
||||
}, [sortBy, sortOrder])
|
||||
|
||||
|
||||
const handleSelect = (checked: boolean, albumId: string) => {
|
||||
if (checked) {
|
||||
setSelectedAlbums([...selectedAlbums, albumId])
|
||||
setSelectedAlbumsIds([...selectedAlbumsIds, albumId])
|
||||
} else {
|
||||
setSelectedAlbums(selectedAlbums.filter((id) => id !== albumId))
|
||||
setSelectedAlbumsIds(selectedAlbumsIds.filter((id) => id !== albumId))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteAlbums = async () => {
|
||||
setDeleting(true)
|
||||
return deleteAlbums(selectedAlbumsIds).then(() => {
|
||||
setSelectedAlbumsIds([])
|
||||
setAlbums(albums.filter((album) => !selectedAlbumsIds.includes(album.id)))
|
||||
toast.success(`Deleted ${selectedAlbumsIds.length} albums`)
|
||||
}).catch((error) => {
|
||||
toast.error(error.message)
|
||||
}).finally(() => {
|
||||
setDeleting(false)
|
||||
})
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
@@ -78,7 +94,12 @@ export default function AlbumListPage() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 p-4">
|
||||
{searchedAlbums.map((album) => (
|
||||
<AlbumThumbnail album={album} key={album.id} onSelect={(checked) => handleSelect(checked, album.id)} />
|
||||
<AlbumThumbnail
|
||||
album={album}
|
||||
key={album.id}
|
||||
selected={selectedAlbumsIds.includes(album.id)}
|
||||
onSelect={(checked) => handleSelect(checked, album.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -89,17 +110,45 @@ export default function AlbumListPage() {
|
||||
leftComponent="Manage Albums"
|
||||
rightComponent={
|
||||
<div className="flex items-center gap-2">
|
||||
{!!selectedAlbumsIds.length && (
|
||||
<>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"sm"}
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => {
|
||||
albumShareDialogRef.current?.open(selectedAlbums)
|
||||
}}>
|
||||
<Share size={16} /> Share {selectedAlbumsIds.length} albums
|
||||
</Button>
|
||||
|
||||
<AlertDialog
|
||||
title='Delete Albums'
|
||||
description='Are you sure you want to delete these albums?'
|
||||
onConfirm={handleDeleteAlbums}
|
||||
>
|
||||
<Button
|
||||
variant={"destructive"}
|
||||
size={"sm"}
|
||||
className="flex items-center gap-2"
|
||||
disabled={deleting}
|
||||
>
|
||||
<Trash size={16} /> {deleting ? "Deleting..." : `Delete ${selectedAlbumsIds.length} albums`}
|
||||
</Button>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)}
|
||||
<Input type="text" placeholder="Search" className="w-48" onChange={(e) => setSearch(e.target.value)} />
|
||||
<Select
|
||||
defaultValue={selectedSortBy?.value}
|
||||
onValueChange={(value) => router.push({
|
||||
pathname,
|
||||
query: {
|
||||
...query,
|
||||
sortBy: value,
|
||||
}
|
||||
|
||||
})}>
|
||||
query: {
|
||||
...query,
|
||||
sortBy: value,
|
||||
}
|
||||
|
||||
})}>
|
||||
<SelectTrigger className="px-2">
|
||||
<SelectValue placeholder="Sort by">{selectedSortBy?.label || 'Sort by'}</SelectValue>
|
||||
</SelectTrigger>
|
||||
@@ -122,6 +171,9 @@ export default function AlbumListPage() {
|
||||
}
|
||||
/>
|
||||
{renderContent()}
|
||||
<AlbumShareDialog
|
||||
ref={albumShareDialogRef}
|
||||
/>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
@@ -74,13 +74,14 @@ export default function PotentialAlbums() {
|
||||
<Header
|
||||
leftComponent="Potential Albums"
|
||||
rightComponent={
|
||||
<>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Badge variant={"outline"}>
|
||||
{config.selectedIds.length} Selected
|
||||
</Badge>
|
||||
{config.selectedIds.length && config.selectedIds.length === config.assets.length ? (
|
||||
<Button
|
||||
variant={"outline"}
|
||||
size={"sm"}
|
||||
onClick={() =>
|
||||
setConfig({
|
||||
...config,
|
||||
@@ -93,6 +94,7 @@ export default function PotentialAlbums() {
|
||||
) : (
|
||||
<Button
|
||||
variant={"outline"}
|
||||
size={"sm"}
|
||||
onClick={() =>
|
||||
setConfig({
|
||||
...config,
|
||||
@@ -106,7 +108,7 @@ export default function PotentialAlbums() {
|
||||
<AlbumSelectorDialog onSelected={handleSelect} />
|
||||
<AlbumCreateDialog onSubmit={handleCreate} assetIds={config.assets.map((a) => a.id)} />
|
||||
<AssetsOptions assets={selectedAssets} onAdd={() => { }} />
|
||||
</>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<PotentialAlbumContext.Provider
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ENV } from "@/config/environment";
|
||||
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
|
||||
import { NextApiResponse } from "next";
|
||||
|
||||
import { NextApiRequest } from "next";
|
||||
|
||||
interface IAlbumShare {
|
||||
albumId: string;
|
||||
allowDownload: boolean;
|
||||
allowUpload: boolean;
|
||||
showMetadata: boolean;
|
||||
}
|
||||
|
||||
const deleteSingleAlbum = async (albumId: string, token: string) => {
|
||||
const url = ENV.IMMICH_URL + "/api/albums/" + albumId;
|
||||
return fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
}).then(async (response) => {
|
||||
if (response.status >= 400) {
|
||||
return {
|
||||
error: "Failed to fetch assets",
|
||||
errorData: await response.json()
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const currentUser = await getCurrentUser(req);
|
||||
|
||||
if (!currentUser) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const { albumIds } = req.body as { albumIds: string[] };
|
||||
if (!albumIds) {
|
||||
return res.status(400).json({ error: 'albumIds is required' });
|
||||
}
|
||||
|
||||
const deleteResults = await Promise.all(albumIds.map(async (albumId) => {
|
||||
return deleteSingleAlbum(albumId, currentUser.accessToken);
|
||||
}));
|
||||
return res.status(200).json(deleteResults);
|
||||
}
|
||||
@@ -11,31 +11,34 @@ import { users } from "@/schema/users.schema";
|
||||
import { assetFaces, exif, person } from "@/schema";
|
||||
import { IAlbum } from "@/types/album";
|
||||
|
||||
const getTime = (date: Date) => {
|
||||
return date ? date.getTime() : 0;
|
||||
}
|
||||
|
||||
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());
|
||||
return albums.sort((a, b) => sortOrder === 'asc' ? getTime(a?.createdAt) - getTime(b?.createdAt) : getTime(b?.createdAt) - getTime(a?.createdAt));
|
||||
}
|
||||
if (sortBy === 'updatedAt') {
|
||||
return albums.sort((a, b) => sortOrder === 'asc' ? a.updatedAt.getTime() - b.updatedAt.getTime() : b.updatedAt.getTime() - a.updatedAt.getTime());
|
||||
return albums.sort((a, b) => sortOrder === 'asc' ? getTime(a?.updatedAt) - getTime(b?.updatedAt) : getTime(b?.updatedAt) - getTime(a?.updatedAt));
|
||||
}
|
||||
if (sortBy === 'firstPhotoDate') {
|
||||
return albums.sort((a, b) => sortOrder === 'asc' ? a.firstPhotoDate.getTime() - b.firstPhotoDate.getTime() : b.firstPhotoDate.getTime() - a.firstPhotoDate.getTime());
|
||||
return albums.sort((a, b) => sortOrder === 'asc' ? getTime(a?.firstPhotoDate) - getTime(b?.firstPhotoDate) : getTime(b?.firstPhotoDate) - getTime(a?.firstPhotoDate));
|
||||
}
|
||||
if (sortBy === 'lastPhotoDate') {
|
||||
return albums.sort((a, b) => sortOrder === 'asc' ? a.lastPhotoDate.getTime() - b.lastPhotoDate.getTime() : b.lastPhotoDate.getTime() - a.lastPhotoDate.getTime());
|
||||
return albums.sort((a, b) => sortOrder === 'asc' ? getTime(a?.lastPhotoDate) - getTime(b?.lastPhotoDate) : getTime(b?.lastPhotoDate) - getTime(a?.lastPhotoDate));
|
||||
}
|
||||
if (sortBy === 'assetCount') {
|
||||
return albums.sort((a, b) => sortOrder === 'asc' ? a.assetCount - b.assetCount : b.assetCount - a.assetCount);
|
||||
return albums.sort((a, b) => sortOrder === 'asc' ? a?.assetCount - b?.assetCount : b?.assetCount - a?.assetCount);
|
||||
}
|
||||
if (sortBy === 'albumName') {
|
||||
return albums.sort((a, b) => sortOrder === 'asc' ? a.albumName.localeCompare(b.albumName) : b.albumName.localeCompare(a.albumName));
|
||||
return albums.sort((a, b) => sortOrder === 'asc' ? a?.albumName?.localeCompare(b?.albumName) : b?.albumName?.localeCompare(a?.albumName));
|
||||
}
|
||||
if (sortBy === 'albumSize') {
|
||||
return albums.sort((a, b) => sortOrder === 'asc' ? parseInt(a.size) - parseInt(b.size) : parseInt(b.size) - parseInt(a.size));
|
||||
return albums.sort((a, b) => sortOrder === 'asc' ? parseInt(a?.size) - parseInt(b?.size) : parseInt(b?.size) - parseInt(a?.size));
|
||||
}
|
||||
if (sortBy === 'faceCount') {
|
||||
return albums.sort((a, b) => sortOrder === 'asc' ? a.faceCount - b.faceCount : b.faceCount - a.faceCount);
|
||||
return albums.sort((a, b) => sortOrder === 'asc' ? a?.faceCount - b?.faceCount : b?.faceCount - a?.faceCount);
|
||||
}
|
||||
return albums;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ENV } from "@/config/environment";
|
||||
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
|
||||
import { NextApiResponse } from "next";
|
||||
|
||||
import { NextApiRequest } from "next";
|
||||
|
||||
interface IAlbumShare {
|
||||
albumId: string;
|
||||
allowDownload: boolean;
|
||||
allowUpload: boolean;
|
||||
showMetadata: boolean;
|
||||
}
|
||||
|
||||
const generateShareLink = async (album: IAlbumShare, token: string) => {
|
||||
const url = ENV.IMMICH_URL + "/api/shared-links";
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
type: "ALBUM",
|
||||
albumId: album.albumId,
|
||||
allowDownload: album.allowDownload,
|
||||
allowUpload: album.allowUpload,
|
||||
showMetadata: album.showMetadata,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
}).then(async (response) => {
|
||||
if (response.status >= 400) {
|
||||
return {
|
||||
error: "Failed to fetch assets",
|
||||
errorData: await response.json()
|
||||
};
|
||||
}
|
||||
const data = await response.json();
|
||||
return {
|
||||
...album,
|
||||
...data,
|
||||
shareLink: ENV.EXTERNAL_IMMICH_URL + "/share/" + data.key
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const currentUser = await getCurrentUser(req);
|
||||
|
||||
if (!currentUser) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const { albums } = req.body as { albums: IAlbumShare[] };
|
||||
if (!albums) {
|
||||
return res.status(400).json({ error: 'albums is required' });
|
||||
}
|
||||
|
||||
const shareLinks = await Promise.all(albums.map(async (album) => {
|
||||
return generateShareLink(album, currentUser.accessToken);
|
||||
}));
|
||||
return res.status(200).json(shareLinks);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { db } from "@/config/db";
|
||||
import { IMissingLocationDatesResponse } from "@/handlers/api/asset.handler";
|
||||
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
|
||||
import { parseDate } from "@/helpers/date.helper";
|
||||
import { assets, exif } from "@/schema";
|
||||
import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema";
|
||||
import { albums } from "@/schema/albums.schema";
|
||||
import { and, count, desc, eq, isNotNull, isNull, ne, sql } from "drizzle-orm";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
try {
|
||||
const { sortBy = "date", sortOrder = "desc" } = req.query;
|
||||
const currentUser = await getCurrentUser(req);
|
||||
if (!currentUser) {
|
||||
return res.status(401).json({
|
||||
error: "Unauthorized",
|
||||
});
|
||||
}
|
||||
const rows = await db
|
||||
.select({
|
||||
asset_count: desc(count(assets.id)),
|
||||
label: albums.albumName,
|
||||
value: albums.id,
|
||||
})
|
||||
.from(assets)
|
||||
.leftJoin(exif, eq(exif.assetId, assets.id))
|
||||
.leftJoin(albumsAssetsAssets, eq(albumsAssetsAssets.assetsId, assets.id))
|
||||
.leftJoin(albums, eq(albums.id, albumsAssetsAssets.albumsId))
|
||||
.where(
|
||||
and(
|
||||
isNull(exif.latitude),
|
||||
isNotNull(assets.createdAt),
|
||||
isNotNull(exif.dateTimeOriginal),
|
||||
eq(assets.ownerId, currentUser.id),
|
||||
eq(assets.isVisible, true),
|
||||
isNotNull(albums.id)
|
||||
))
|
||||
.groupBy(albums.id)
|
||||
.orderBy(desc(count(assets.id))) as IMissingLocationDatesResponse[];
|
||||
|
||||
|
||||
rows.sort((a, b) => {
|
||||
return sortOrder === "asc" ? a.asset_count - b.asset_count : b.asset_count - a.asset_count;
|
||||
});
|
||||
return res.status(200).json(rows);
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
error: error?.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,62 +3,116 @@ import { db } from "@/config/db";
|
||||
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
|
||||
import { parseDate } from "@/helpers/date.helper";
|
||||
import { assets, exif } from "@/schema";
|
||||
import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema";
|
||||
import { albums } from "@/schema/albums.schema";
|
||||
import { IUser } from "@/types/user";
|
||||
import { addDays } from "date-fns";
|
||||
import { and, eq, gte, isNotNull, isNull, lte, ne, sql } from "drizzle-orm";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
const getRowsByDates = async (startDateDate: Date, endDateDate: Date, currentUser: IUser) => {
|
||||
return 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(assets)
|
||||
.leftJoin(exif, eq(exif.assetId, assets.id))
|
||||
.where(and(
|
||||
isNull(exif.latitude),
|
||||
isNotNull(assets.createdAt),
|
||||
gte(exif.dateTimeOriginal, startDateDate),
|
||||
lte(exif.dateTimeOriginal, endDateDate),
|
||||
eq(assets.ownerId, currentUser.id),
|
||||
));
|
||||
}
|
||||
|
||||
const getRowsByAlbums = async (currentUser: IUser, albumId: string) => {
|
||||
return 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(assets)
|
||||
.leftJoin(exif, eq(exif.assetId, assets.id))
|
||||
.leftJoin(albumsAssetsAssets, eq(albumsAssetsAssets.assetsId, assets.id))
|
||||
.leftJoin(albums, eq(albums.id, albumsAssetsAssets.albumsId))
|
||||
.where(and(
|
||||
isNull(exif.latitude),
|
||||
isNotNull(assets.createdAt),
|
||||
eq(assets.ownerId, currentUser.id),
|
||||
eq(albums.id, albumId)
|
||||
));
|
||||
}
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
const { startDate } = req.query as {
|
||||
const { startDate, groupBy, albumId } = req.query as {
|
||||
startDate: string;
|
||||
groupBy: "date" | "album";
|
||||
albumId?: string;
|
||||
};
|
||||
|
||||
const currentUser = await getCurrentUser(req);
|
||||
|
||||
if (!startDate) {
|
||||
if (!currentUser) {
|
||||
return res.status(401).json({
|
||||
error: "Unauthorized",
|
||||
});
|
||||
}
|
||||
|
||||
if (!startDate && !albumId) {
|
||||
return res.status(400).json({
|
||||
error: "startDate and endDate are required",
|
||||
error: "startDate or albumId is required",
|
||||
});
|
||||
}
|
||||
|
||||
const startDateDate = parseDate(startDate, "yyyy-MM-dd");
|
||||
const endDateDate = addDays(parseDate(startDate, "yyyy-MM-dd"), 1);
|
||||
|
||||
try {
|
||||
const rows = 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(assets)
|
||||
.leftJoin(exif, eq(exif.assetId, assets.id))
|
||||
.where(and(
|
||||
isNull(exif.latitude),
|
||||
isNotNull(assets.createdAt),
|
||||
gte(exif.dateTimeOriginal, startDateDate),
|
||||
lte(exif.dateTimeOriginal, endDateDate),
|
||||
eq(assets.ownerId, currentUser.id),
|
||||
));
|
||||
if (startDate) {
|
||||
const startDateDate = parseDate(startDate, "yyyy-MM-dd");
|
||||
const endDateDate = addDays(parseDate(startDate, "yyyy-MM-dd"), 1);
|
||||
|
||||
try {
|
||||
const rows = await getRowsByDates(startDateDate, endDateDate, currentUser);
|
||||
return res.status(200).json(rows);
|
||||
} catch (error: any) {
|
||||
return res.status(500).json({
|
||||
error: error?.message,
|
||||
});
|
||||
}
|
||||
} else if (albumId) {
|
||||
if (!albumId) {
|
||||
return res.status(400).json({
|
||||
error: "albumId is required",
|
||||
});
|
||||
}
|
||||
const rows = await getRowsByAlbums(currentUser, albumId);
|
||||
return res.status(200).json(rows);
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
error: error?.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,8 @@ export default async function handler(
|
||||
const rows = await db
|
||||
.select({
|
||||
asset_count: desc(count(assets.id)),
|
||||
date: sql`DATE(${exif.dateTimeOriginal})`,
|
||||
label: sql`DATE(${exif.dateTimeOriginal})`,
|
||||
value: sql`DATE(${exif.dateTimeOriginal})`,
|
||||
})
|
||||
.from(assets)
|
||||
.leftJoin(exif, eq(exif.assetId, assets.id))
|
||||
@@ -39,8 +40,8 @@ export default async function handler(
|
||||
|
||||
if (sortBy === "date") {
|
||||
rows.sort((a, b) => {
|
||||
const aDate = parseDate(a.date, "yyyy-MM-dd");
|
||||
const bDate = parseDate(b.date, "yyyy-MM-dd");
|
||||
const aDate = parseDate(a.label, "yyyy-MM-dd");
|
||||
const bDate = parseDate(b.label, "yyyy-MM-dd");
|
||||
return sortOrder === "asc" ? aDate.getTime() - bDate.getTime() : bDate.getTime() - aDate.getTime();
|
||||
});
|
||||
} else if (sortBy === "asset_count") {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ENV } from '@/config/environment';
|
||||
import { getCurrentUser } from '@/handlers/serverUtils/user.utils';
|
||||
import { parseFindQuery } from '@/helpers/gemini.helper';
|
||||
import { assetFaces, assets, person } from '@/schema';
|
||||
import { Person } from '@/schema/person.schema';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
@@ -10,8 +11,16 @@ export default async function search(req: NextApiRequest, res: NextApiResponse)
|
||||
const { query } = req.body;
|
||||
const currentUser = await getCurrentUser(req);
|
||||
const parsedQuery = await parseFindQuery(query as string);
|
||||
const { personIds } = parsedQuery;
|
||||
const url = ENV.IMMICH_URL + "/api/search/smart";
|
||||
|
||||
let dbPeople: Person[] = [];
|
||||
if (personIds) {
|
||||
dbPeople = await db.select().from(person).where(
|
||||
inArray(person.id, personIds)
|
||||
);
|
||||
}
|
||||
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(parsedQuery),
|
||||
@@ -33,7 +42,10 @@ export default async function search(req: NextApiRequest, res: NextApiResponse)
|
||||
.then(data => {
|
||||
res.status(200).json({
|
||||
assets: data.assets.items,
|
||||
filters: parsedQuery
|
||||
filters: {
|
||||
...parsedQuery,
|
||||
personIds: dbPeople.map((person) => person.name)
|
||||
},
|
||||
});
|
||||
}).catch(err => {
|
||||
res.status(500).json({ error: err.message });
|
||||
|
||||
@@ -15,19 +15,19 @@ import { updateAssets } from "@/handlers/api/asset.handler";
|
||||
import { IPlace } from "@/types/common";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useMemo } from "react";
|
||||
import FloatingBar from "@/components/shared/FloatingBar";
|
||||
import { Select, SelectValue, SelectTrigger, SelectContent, SelectItem } from "@/components/ui/select";
|
||||
|
||||
export default function MissingLocations() {
|
||||
const { toast } = useToast();
|
||||
|
||||
const { query } = useRouter();
|
||||
const { startDate } = query as { startDate: string };
|
||||
const { query, push } = useRouter();
|
||||
const { startDate, groupBy = "date" } = query as { startDate: string, groupBy: string };
|
||||
const [config, setConfig] = React.useState<IMissingLocationConfig>({
|
||||
startDate: startDate || undefined,
|
||||
selectedIds: [],
|
||||
assets: [],
|
||||
});
|
||||
|
||||
const selectedAssets = useMemo(() => config.assets.filter((a) => config.selectedIds.includes(a.id)), [config.assets, config.selectedIds]) ;
|
||||
const selectedAssets = useMemo(() => config.assets.filter((a) => config.selectedIds.includes(a.id)), [config.assets, config.selectedIds]);
|
||||
|
||||
const handleSubmit = (place: IPlace) => {
|
||||
return updateAssets({
|
||||
@@ -43,42 +43,32 @@ export default function MissingLocations() {
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout className="!p-0 !mb-0">
|
||||
<PageLayout className="!p-0 !mb-0 relative">
|
||||
<Header
|
||||
leftComponent="Missing Location"
|
||||
rightComponent={
|
||||
<>
|
||||
<Badge variant={"outline"}>
|
||||
{config.selectedIds.length} Selected
|
||||
</Badge>
|
||||
{config.selectedIds.length === config.assets.length ? (
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={() =>
|
||||
setConfig({
|
||||
...config,
|
||||
selectedIds: [],
|
||||
})
|
||||
}
|
||||
>
|
||||
Unselect all
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={() =>
|
||||
setConfig({
|
||||
...config,
|
||||
selectedIds: config.assets.map((a) => a.id),
|
||||
})
|
||||
}
|
||||
>
|
||||
Select all
|
||||
</Button>
|
||||
)}
|
||||
<TagMissingLocationDialog onSubmit={handleSubmit} />
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={groupBy} onValueChange={(value) => {
|
||||
push({
|
||||
pathname: "/assets/missing-locations",
|
||||
query: {
|
||||
...query,
|
||||
groupBy: value,
|
||||
startDate: undefined,
|
||||
albumId: undefined,
|
||||
},
|
||||
});
|
||||
}}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Group by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="album">Album</SelectItem>
|
||||
<SelectItem value="date">Date</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<AssetsOptions assets={selectedAssets} onAdd={() => { }} />
|
||||
</>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<MissingLocationContext.Provider
|
||||
@@ -89,9 +79,46 @@ export default function MissingLocations() {
|
||||
}}
|
||||
>
|
||||
<div className="flex divide-y">
|
||||
<MissingLocationDates />
|
||||
<MissingLocationAssets />
|
||||
<MissingLocationDates groupBy={groupBy as "date" | "album"} />
|
||||
<MissingLocationAssets groupBy={groupBy as "date" | "album"} />
|
||||
</div>
|
||||
<FloatingBar>
|
||||
<div className="flex items-center gap-2 justify-between w-full">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{config.selectedIds.length} Selected
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{config.selectedIds.length === config.assets.length ? (
|
||||
<Button
|
||||
variant={"outline"}
|
||||
size={"sm"}
|
||||
onClick={() =>
|
||||
setConfig({
|
||||
...config,
|
||||
selectedIds: [],
|
||||
})
|
||||
}
|
||||
>
|
||||
Unselect all
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant={"outline"}
|
||||
size={"sm"}
|
||||
onClick={() =>
|
||||
setConfig({
|
||||
...config,
|
||||
selectedIds: config.assets.map((a) => a.id),
|
||||
})
|
||||
}
|
||||
>
|
||||
Select all
|
||||
</Button>
|
||||
)}
|
||||
<TagMissingLocationDialog onSubmit={handleSubmit} />
|
||||
</div>
|
||||
</div>
|
||||
</FloatingBar>
|
||||
</MissingLocationContext.Provider>
|
||||
</PageLayout>
|
||||
);
|
||||
|
||||
+76
-26
@@ -7,14 +7,27 @@ import { ASSET_PREVIEW_PATH, ASSET_THUMBNAIL_PATH } from '@/config/routes';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { findAssets } from '@/handlers/api/asset.handler';
|
||||
import { IAsset } from '@/types/asset';
|
||||
import { Captions, Megaphone, Search, Speaker, TriangleAlert, WandSparkles } from 'lucide-react';
|
||||
import { ArrowUpRight, Captions, Megaphone, Search, Speaker, TriangleAlert, WandSparkles } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import Lightbox from 'yet-another-react-lightbox';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
|
||||
interface IFindFilters {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
const FILTER_KEY_MAP = {
|
||||
"city": "City",
|
||||
"state": "State",
|
||||
"country": "Country",
|
||||
"takenAfter": "Taken After",
|
||||
"takenBefore": "Taken Before",
|
||||
"size": "Size",
|
||||
"model": "Model",
|
||||
"personIds": "People",
|
||||
}
|
||||
export default function FindPage() {
|
||||
|
||||
const [index, setIndex] = useState(-1);
|
||||
@@ -22,6 +35,7 @@ export default function FindPage() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [assets, setAssets] = useState<IAsset[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filters, setFilters] = useState<IFindFilters>({});
|
||||
|
||||
const slides = useMemo(
|
||||
() =>
|
||||
@@ -33,19 +47,46 @@ export default function FindPage() {
|
||||
[assets]
|
||||
);
|
||||
|
||||
|
||||
const appliedFilters: {
|
||||
label: string;
|
||||
value: string;
|
||||
}[] = useMemo(() => {
|
||||
return Object.entries(filters)
|
||||
.filter(([_key, value]) => value !== undefined)
|
||||
.map(([key, value]) => ({
|
||||
label: key,
|
||||
value: Array.isArray(value) ? value.join(', ') : value,
|
||||
}))
|
||||
.filter((filter) => filter.label !== "query");
|
||||
}, [filters]);
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
setQuery(query);
|
||||
setLoading(true);
|
||||
findAssets(query)
|
||||
.then(({ assets }) => {
|
||||
.then(({ assets, filters: _filters }) => {
|
||||
setAssets(assets);
|
||||
setFilters(_filters);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
const renderFilters = () => {
|
||||
if (appliedFilters.length === 0) return null;
|
||||
return (
|
||||
<div className="flex gap-2 flex-wrap px-2">
|
||||
{appliedFilters.map((filter) => (
|
||||
<div key={filter.label} className="flex gap-2 items-center divide-x divide-gray-400 dark:divide-zinc-800 bg-zinc-100 dark:bg-zinc-800 border border-gray-400 dark:border-zinc-800 rounded-md px-2">
|
||||
<p className="text-sm text-gray-500 dark:text-zinc-400">{FILTER_KEY_MAP[filter.label as keyof typeof FILTER_KEY_MAP] || filter.label}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-zinc-400 pl-1.5">{filter.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
return <div className="flex justify-center items-center h-full">
|
||||
@@ -53,37 +94,46 @@ export default function FindPage() {
|
||||
</div>
|
||||
}
|
||||
else if (query.length === 0) {
|
||||
return <div className="flex justify-center flex-col gap-2 items-center h-full">
|
||||
<Search className='w-10 h-10 text-muted-foreground' />
|
||||
<p className='text-lg font-bold'>Search for photos in natural language</p>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
Example: Sunset photos from last week. Use <kbd className='bg-zinc-200 text-black dark:text-white px-1 py-0.5 rounded-md dark:bg-zinc-500'>@</kbd> to search for photos of a specific person.
|
||||
</p>
|
||||
<p className='text-xs text-muted-foreground text-center flex gap-1 items-center'>
|
||||
<span>Power tools uses Google Gemini only for parsing your query. None of your data is sent to Gemini.</span>
|
||||
</p>
|
||||
</div>
|
||||
return (
|
||||
<div className="flex justify-center flex-col gap-2 items-center h-full">
|
||||
<Search className='w-10 h-10 text-muted-foreground' />
|
||||
<p className='text-lg font-bold'>Search for photos in natural language</p>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
Example: Sunset photos from last week. Use <kbd className='bg-zinc-200 text-black dark:text-white px-1 py-0.5 rounded-md dark:bg-zinc-500'>@</kbd> to search for photos of a specific person.
|
||||
</p>
|
||||
<p className='text-xs text-muted-foreground text-center flex gap-1 items-center'>
|
||||
<span>Power tools uses Google Gemini only for parsing your query. None of your data is sent to Gemini.</span>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
else if (assets.length === 0) {
|
||||
return <div className="flex justify-center items-center h-full">
|
||||
No results found
|
||||
return <div className="flex justify-center items-center h-full flex-col gap-2">
|
||||
<TriangleAlert className='w-10 h-10 text-muted-foreground' />
|
||||
<p className='text-lg font-bold'>No results found for the below filters</p>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
{renderFilters()}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Lightbox
|
||||
slides={slides}
|
||||
open={index >= 0}
|
||||
index={index}
|
||||
close={() => setIndex(-1)}
|
||||
/>
|
||||
<Lightbox
|
||||
slides={slides}
|
||||
open={index >= 0}
|
||||
index={index}
|
||||
close={() => setIndex(-1)}
|
||||
/>
|
||||
{renderFilters()}
|
||||
<div className="grid grid-cols-5 gap-4 p-2">
|
||||
{assets.map((asset, idx) => (
|
||||
<div key={asset.id} className='w-full h-[200px] rounded-md overflow-hidden relative'>
|
||||
<Link href={`${exImmichUrl}/photos/${asset.id}`}
|
||||
target="_blank"
|
||||
className='absolute top-2 right-2 text-xs'>
|
||||
Open in Immich
|
||||
<div key={asset.id} className='group w-full h-[200px] rounded-md overflow-hidden relative'>
|
||||
<Link href={`${exImmichUrl}/photos/${asset.id}`}
|
||||
target="_blank"
|
||||
className='absolute top-2 right-2 text-xs bg-white dark:bg-zinc-800 px-2 py-1 rounded-md flex gap-1 items-center opacity-0 group-hover:opacity-100 transition-opacity duration-300'>
|
||||
Open
|
||||
<ArrowUpRight className='w-4 h-4' />
|
||||
</Link>
|
||||
<Image
|
||||
src={ASSET_THUMBNAIL_PATH(asset.id)}
|
||||
|
||||
@@ -11,4 +11,6 @@ export const person = pgTable("person", {
|
||||
isHidden: boolean("isHidden").notNull().default(false),
|
||||
birthDate: date("birthDate", { mode: "date" }),
|
||||
faceAssetId: uuid("faceAssetId"),
|
||||
});
|
||||
});
|
||||
|
||||
export type Person = typeof person.$inferSelect;
|
||||
Reference in new issue
Block a user