Merge pull request #112 from varun-raj/feat-albums-enhancement

Feat: Albums enhancement and ux enhancements
This commit is contained in:
Varun Raj Manoharan authored and GitHub committed 2025-01-11 11:25:56 +05:30
commit eb3aefd845
41 files changed
+1074 -272

No files matched your search

BIN
View File
Binary file not shown.
+2
View File
@@ -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",
+1 -1
View File
@@ -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>
+4 -5
View File
@@ -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))}
/>
+2 -2
View File
@@ -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>
);
}
@@ -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>
@@ -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 -1
View File
@@ -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;
+16
View File
@@ -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>
)
}
+207
View File
@@ -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
}
-33
View File
@@ -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
View File
@@ -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 {
+6 -1
View File
@@ -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,
+3 -1
View File
@@ -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";
+2
View File
@@ -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: () => { },
+9 -1
View File
@@ -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 });
}
+11 -1
View File
@@ -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[]> => {
+2 -1
View File
@@ -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]}
`;
+3 -7
View File
@@ -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
View File
@@ -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>
)
}
+4 -2
View File
@@ -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
+50
View File
@@ -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 -8
View File
@@ -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;
}
+61
View File
@@ -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,
});
}
}
+93 -39
View File
@@ -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") {
+13 -1
View File
@@ -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 });
+66 -39
View File
@@ -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
View File
@@ -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)}
+3 -1
View File
@@ -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;