Merge pull request #251 from immich-power-tools/feat/smart-auto-albums
Design and UI fixes
This commit is contained in:
18 files changed
+890
-470
No files matched your search
@@ -142,7 +142,7 @@ function PersonChip({ person: p, exImmichUrl, isHidden: hidden, onUpdate }: Pers
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-xs font-medium ${hidden ? 'line-through text-muted-foreground' : ''}`}>{p.personName || 'Unknown'}</span>
|
||||
<span className={`text-xs font-medium ${hidden ? 'line-through text-muted-foreground' : 'text-foreground'}`}>{p.personName || 'Unknown'}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-56 p-3 z-[10000]" side="bottom" align="start">
|
||||
@@ -262,7 +262,7 @@ export default function AssetInfoPanel({ assetId }: AssetInfoPanelProps) {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="w-[360px] min-w-[360px] bg-background border-l overflow-y-auto p-4">
|
||||
<div className="dark w-[360px] min-w-[360px] bg-background text-foreground border-l overflow-y-auto p-4">
|
||||
<h2 className="text-lg font-semibold mb-4">Info</h2>
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-4 bg-muted rounded w-3/4" />
|
||||
@@ -275,7 +275,7 @@ export default function AssetInfoPanel({ assetId }: AssetInfoPanelProps) {
|
||||
|
||||
if (!detail) {
|
||||
return (
|
||||
<div className="w-[360px] min-w-[360px] bg-background border-l overflow-y-auto p-4">
|
||||
<div className="dark w-[360px] min-w-[360px] bg-background text-foreground border-l overflow-y-auto p-4">
|
||||
<h2 className="text-lg font-semibold mb-4">Info</h2>
|
||||
<p className="text-sm text-muted-foreground">Unable to load asset details.</p>
|
||||
</div>
|
||||
@@ -296,7 +296,7 @@ export default function AssetInfoPanel({ assetId }: AssetInfoPanelProps) {
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="w-[360px] min-w-[360px] bg-background border-l overflow-y-auto">
|
||||
<div className="dark w-[360px] min-w-[360px] bg-background text-foreground border-l overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="p-4 pb-2">
|
||||
<h2 className="text-lg font-semibold">Info</h2>
|
||||
|
||||
@@ -8,6 +8,15 @@ import { useRouter } from "next/router";
|
||||
import PeopleFilterContext from "@/contexts/PeopleFilterContext";
|
||||
import PageLayout from "../layouts/PageLayout";
|
||||
import Header from "../shared/Header";
|
||||
import { Users } from "lucide-react";
|
||||
|
||||
const COLS_KEY = "people_grid_cols";
|
||||
|
||||
function getStoredCols(): number {
|
||||
if (typeof window === "undefined") return 6;
|
||||
const v = parseInt(localStorage.getItem(COLS_KEY) || "", 10);
|
||||
return isNaN(v) ? 6 : Math.min(20, Math.max(3, v));
|
||||
}
|
||||
|
||||
export default function PeopleList() {
|
||||
const router = useRouter();
|
||||
@@ -15,12 +24,22 @@ export default function PeopleList() {
|
||||
const [count, setCount] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [cols, setCols] = useState<number>(6);
|
||||
const [filters, setFilters] = useState<IPersonListFilters>({
|
||||
...router.query,
|
||||
page: 1,
|
||||
});
|
||||
|
||||
|
||||
// Hydrate cols from localStorage after mount
|
||||
useEffect(() => {
|
||||
setCols(getStoredCols());
|
||||
}, []);
|
||||
|
||||
const handleColsChange = (val: number) => {
|
||||
setCols(val);
|
||||
localStorage.setItem(COLS_KEY, String(val));
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
setErrorMessage(null);
|
||||
@@ -38,29 +57,36 @@ export default function PeopleList() {
|
||||
};
|
||||
|
||||
const handleRemove = (person: IPerson) => {
|
||||
setPeople((prev) => prev.filter((p) => {
|
||||
return p.id !== person.id
|
||||
}));
|
||||
}
|
||||
setPeople((prev) => prev.filter((p) => p.id !== person.id));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady) return;
|
||||
fetchData();
|
||||
}, [filters]);
|
||||
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading) return <Loader />;
|
||||
if (errorMessage) return <div>{errorMessage}</div>;
|
||||
if (errorMessage) return <div className="p-4 text-sm text-destructive">{errorMessage}</div>;
|
||||
if (!people.length) return (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-3 text-muted-foreground">
|
||||
<Users className="h-10 w-10 opacity-30" />
|
||||
<p className="text-sm">No people found</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-1 p-2">
|
||||
<div
|
||||
className="grid gap-2 p-3"
|
||||
style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{people.map((person) => (
|
||||
<PersonItem person={person} key={person.id} onRemove={handleRemove}/>
|
||||
<PersonItem person={person} key={person.id} onRemove={handleRemove} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PeopleFilterContext.Provider
|
||||
value={{
|
||||
@@ -69,12 +95,28 @@ export default function PeopleList() {
|
||||
setFilters((prev) => ({ ...prev, ...newConfig })),
|
||||
}}
|
||||
>
|
||||
<PageLayout className="!p-0 !mb-0">
|
||||
<PageLayout className="!p-0 !mb-0 relative">
|
||||
<Header
|
||||
leftComponent="Manage People"
|
||||
rightComponent={<PeopleFilters />}
|
||||
/>
|
||||
{renderContent()}
|
||||
|
||||
{/* Grid size slider — fixed bottom-left */}
|
||||
<div className="fixed bottom-4 left-[210px] lg:left-[250px] z-20 flex items-center gap-3 bg-background/90 backdrop-blur-sm border rounded-full px-4 py-2 shadow-md">
|
||||
<Users className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<input
|
||||
type="range"
|
||||
min={3}
|
||||
max={20}
|
||||
step={1}
|
||||
value={cols}
|
||||
onChange={(e) => handleColsChange(Number(e.target.value))}
|
||||
className="w-28 h-1.5 appearance-none rounded-full bg-muted cursor-pointer accent-foreground"
|
||||
title={`${cols} per row`}
|
||||
/>
|
||||
<span className="text-xs font-mono text-muted-foreground w-4 text-center">{cols}</span>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</PeopleFilterContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,102 +1,141 @@
|
||||
import { IPerson } from '@/types/person'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { DatePicker } from '../ui/datepicker'
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { updatePerson } from '@/handlers/api/people.handler';
|
||||
import { Input } from '../ui/input';
|
||||
import { useToast } from '../ui/use-toast';
|
||||
import { format } from 'date-fns';
|
||||
import { CalendarDays, X } from 'lucide-react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
import { Calendar } from '../ui/calendar';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
|
||||
// @ts-ignore
|
||||
import chrono from 'chrono-node'
|
||||
|
||||
|
||||
|
||||
interface IProps {
|
||||
person: IPerson
|
||||
}
|
||||
|
||||
const normalizeDateOnly = (value?: Date | string | null): Date | null => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!value) return null;
|
||||
if (typeof value === 'string') {
|
||||
const dateOnlyMatch = value.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (dateOnlyMatch) {
|
||||
const [, year, month, day] = dateOnlyMatch;
|
||||
return new Date(Number(year), Number(month) - 1, Number(day));
|
||||
}
|
||||
const m = value.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (m) return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||
}
|
||||
|
||||
const parsedDate = new Date(value);
|
||||
if (Number.isNaN(parsedDate.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Date(
|
||||
parsedDate.getUTCFullYear(),
|
||||
parsedDate.getUTCMonth(),
|
||||
parsedDate.getUTCDate()
|
||||
);
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
|
||||
}
|
||||
|
||||
export default function PersonBirthdayCell(
|
||||
{ person }: IProps
|
||||
) {
|
||||
export default function PersonBirthdayCell({ person }: IProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const normalizedBirthDate = useMemo(() => normalizeDateOnly(person.birthDate), [person.birthDate]);
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(normalizedBirthDate);
|
||||
const [textDate, setTextDate] = useState<string>(normalizedBirthDate ? format(normalizedBirthDate, 'PPP') : '');
|
||||
const [textInput, setTextInput] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedDate(normalizedBirthDate);
|
||||
setTextDate(normalizedBirthDate ? format(normalizedBirthDate, 'PPP') : '');
|
||||
}, [normalizedBirthDate]);
|
||||
|
||||
const handleEdit = (date?: Date | null) => {
|
||||
const formatedDate = date ? format(date, 'yyyy-MM-dd') : null;
|
||||
setSelectedDate(date || null);
|
||||
setTextDate(date ? format(date, 'PPP') : '');
|
||||
setSelectedDate(normalizeDateOnly(person.birthDate));
|
||||
}, [person.birthDate]);
|
||||
|
||||
const handleSave = (date: Date | null) => {
|
||||
const formatted = date ? format(date, 'yyyy-MM-dd') : null;
|
||||
setLoading(true);
|
||||
return updatePerson(person.id, {
|
||||
birthDate: formatedDate,
|
||||
})
|
||||
updatePerson(person.id, { birthDate: formatted })
|
||||
.then(() => {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Person updated successfully",
|
||||
})
|
||||
setSelectedDate(date);
|
||||
setOpen(false);
|
||||
setTextInput('');
|
||||
toast({ title: "Birthday saved" });
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
.catch(() => {
|
||||
toast({ title: "Error", description: "Failed to save birthday", variant: "destructive" });
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
const handleCalendarSelect = (date?: Date) => {
|
||||
handleSave(date ?? null);
|
||||
};
|
||||
|
||||
const handleTextCommit = () => {
|
||||
if (!textInput.trim()) {
|
||||
handleSave(null);
|
||||
return;
|
||||
}
|
||||
const parsed = chrono.parseDate(textInput);
|
||||
if (parsed) {
|
||||
handleSave(parsed);
|
||||
} else {
|
||||
toast({ title: "Couldn't parse date", description: 'Try "Jan 1 1990" or "1990-01-01"', variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
handleSave(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex gap-1'>
|
||||
<Input
|
||||
className=''
|
||||
value={textDate}
|
||||
placeholder='Enter a date'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const parsedDate = chrono.parseDate(textDate || "");
|
||||
if (parsedDate) {
|
||||
handleEdit(parsedDate);
|
||||
} else {
|
||||
handleEdit(null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={loading}
|
||||
onChange={(e) => setTextDate(e.target.value)}
|
||||
/>
|
||||
<DatePicker date={selectedDate} onSelect={handleEdit} iconOnly/>
|
||||
</div>
|
||||
)
|
||||
<Popover open={open} onOpenChange={(v) => { setOpen(v); if (v) setTextInput(''); }}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-1.5 w-full text-left text-xs text-muted-foreground hover:text-foreground transition-colors group/bday"
|
||||
title={selectedDate ? `Birthday: ${format(selectedDate, 'PPP')}` : "Add birthday"}
|
||||
>
|
||||
<CalendarDays size={11} className="shrink-0 opacity-60" />
|
||||
{selectedDate ? (
|
||||
<span className="flex-1 truncate">{format(selectedDate, 'PPP')}</span>
|
||||
) : (
|
||||
<span className="italic opacity-50">Add birthday</span>
|
||||
)}
|
||||
{selectedDate && (
|
||||
<span
|
||||
role="button"
|
||||
title="Clear birthday"
|
||||
onClick={handleClear}
|
||||
className="ml-auto opacity-0 group-hover/bday:opacity-60 hover:!opacity-100 transition-opacity p-0.5 rounded"
|
||||
>
|
||||
<X size={10} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start" side="bottom">
|
||||
<div className="p-3 border-b">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">Type a date or pick from calendar</p>
|
||||
<div className="flex gap-1.5">
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
placeholder='e.g. "Jan 1 1990"'
|
||||
value={textInput}
|
||||
onChange={(e) => setTextInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleTextCommit();
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
}}
|
||||
autoFocus
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs px-2"
|
||||
onClick={handleTextCommit}
|
||||
disabled={loading}
|
||||
>
|
||||
Set
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedDate ?? undefined}
|
||||
onSelect={handleCalendarSelect}
|
||||
defaultMonth={selectedDate ?? new Date(1990, 0, 1)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,30 @@
|
||||
import { IPerson } from "@/types/person";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { Avatar } from "../ui/avatar";
|
||||
import { mergePerson, searchPeople, updatePerson } from "@/handlers/api/people.handler";
|
||||
import { PersonMergeDropdown } from "./PersonMergeDropdown";
|
||||
import PersonBirthdayCell from "./PersonBirthdayCell";
|
||||
import clsx from "clsx";
|
||||
import Link from "next/link";
|
||||
import { ArrowUpRight, Info } from "lucide-react";
|
||||
import { ArrowUpRight, Eye, EyeOff, GitMerge, Info, MoreHorizontal, Share2 } from "lucide-react";
|
||||
import { useConfig } from "@/contexts/ConfigContext";
|
||||
import { useToast } from "../ui/use-toast";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ShareAssetsTrigger from "../shared/ShareAssetsTrigger";
|
||||
import { Autocomplete, AutocompleteOption } from "../ui/autocomplete";
|
||||
import { AlertDialog, IAlertDialogActions } from "../ui/alert-dialog";
|
||||
import { Tooltip } from "../ui/tooltip";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
|
||||
interface IProps {
|
||||
person: IPerson;
|
||||
onRemove: (person: IPerson) => void;
|
||||
}
|
||||
|
||||
export default function PersonItem({ person, onRemove }: IProps) {
|
||||
const { exImmichUrl } = useConfig();
|
||||
const { toast } = useToast();
|
||||
@@ -30,26 +37,15 @@ export default function PersonItem({ person, onRemove }: IProps) {
|
||||
const handleEdit = () => {
|
||||
if (formData.name && formData.name !== person.name) {
|
||||
setLoading(true);
|
||||
return updatePerson(person.id, {
|
||||
name: formData.name,
|
||||
})
|
||||
return updatePerson(person.id, { name: formData.name })
|
||||
.then(() => {
|
||||
setEditMode(!editMode);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Person updated successfully",
|
||||
});
|
||||
setEditMode(false);
|
||||
toast({ title: "Success", description: "Person updated successfully" });
|
||||
})
|
||||
.catch(() => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to update person",
|
||||
variant: "destructive",
|
||||
});
|
||||
toast({ title: "Error", description: "Failed to update person", variant: "destructive" });
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
.finally(() => setLoading(false));
|
||||
} else {
|
||||
setEditMode(!editMode);
|
||||
}
|
||||
@@ -57,19 +53,10 @@ export default function PersonItem({ person, onRemove }: IProps) {
|
||||
|
||||
const handleHide = (hidden: boolean) => {
|
||||
setLoading(true);
|
||||
return updatePerson(person.id, {
|
||||
isHidden: hidden,
|
||||
})
|
||||
.then(() => {
|
||||
setFormData((person) => ({
|
||||
...person,
|
||||
isHidden: hidden,
|
||||
}));
|
||||
})
|
||||
.catch(() => { })
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
return updatePerson(person.id, { isHidden: hidden })
|
||||
.then(() => setFormData((p) => ({ ...p, isHidden: hidden })))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
const handleMerge = async (option: AutocompleteOption) => {
|
||||
@@ -79,87 +66,133 @@ export default function PersonItem({ person, onRemove }: IProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex flex-col rounded-lg pb-2 border border-2 border-transparent items-center gap-2",
|
||||
"group relative flex flex-col rounded-xl overflow-hidden border bg-card transition-all duration-200",
|
||||
"hover:shadow-lg hover:border-border/80",
|
||||
{
|
||||
"opacity-50": formData.isHidden,
|
||||
"border border-blue-500": formData.name,
|
||||
"opacity-40": formData.isHidden,
|
||||
"border-border shadow-sm": formData.name,
|
||||
"border-border/40": !formData.name,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="relative w-full h-auto group">
|
||||
<Avatar
|
||||
className="w-full min-h-full h-auto rounded-lg"
|
||||
src={person.thumbnailPath}
|
||||
alt={person.name}
|
||||
/>
|
||||
<div className="absolute bottom-2 w-full flex justify-center items-center">
|
||||
<Badge variant={"secondary"} className="text-xs !font-medium font-mono">{person.assetCount} Assets</Badge>
|
||||
{/* Image area */}
|
||||
<div className="relative aspect-square w-full overflow-hidden bg-muted">
|
||||
{person.thumbnailPath ? (
|
||||
<img
|
||||
src={person.thumbnailPath}
|
||||
alt={formData.name || "Unknown"}
|
||||
className="w-full h-full object-cover object-top transition-transform duration-300 group-hover:scale-[1.03]"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-muted">
|
||||
<span className="text-4xl text-muted-foreground/30 font-light select-none">?</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gradient overlay — always visible at bottom */}
|
||||
<div className="absolute inset-x-0 bottom-0 h-1/3 bg-gradient-to-t from-black/70 to-transparent pointer-events-none" />
|
||||
|
||||
{/* Asset count — bottom-left of image */}
|
||||
<div className="absolute bottom-1.5 left-2 pointer-events-none">
|
||||
<span className="text-[10px] font-medium text-white/80 tabular-nums">
|
||||
{formData.isHidden && <span className="mr-0.5 opacity-70">Hidden ·</span>}
|
||||
{person.assetCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="absolute top-2 left-2 group-hover:flex hidden items-center gap-2">
|
||||
<Link
|
||||
className="bg-green-300 block rounded-lg px-2 py-1 text-sm dark:text-gray-900"
|
||||
href={`${exImmichUrl}/people/${person.id}`}
|
||||
target="_blank"
|
||||
>
|
||||
<ArrowUpRight size={16} />
|
||||
</Link>
|
||||
<Link
|
||||
className="bg-gray-300 block rounded-lg px-2 py-1 text-sm dark:text-gray-900"
|
||||
href={`/people/${person.id}`}
|
||||
>
|
||||
<Info size={16} />
|
||||
</Link>
|
||||
|
||||
{/* Three-dot menu — top-right, visible on hover */}
|
||||
<div className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity duration-150">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="p-1 rounded-full bg-black/50 hover:bg-black/70 text-white backdrop-blur-sm transition-colors">
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={4} className="min-w-[160px]">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/people/${person.id}`} className="gap-2">
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
View details
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`${exImmichUrl}/people/${person.id}`} target="_blank" className="gap-2">
|
||||
<ArrowUpRight className="h-3.5 w-3.5" />
|
||||
Open in Immich
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<ShareAssetsTrigger
|
||||
filters={{ personIds: [person.id] }}
|
||||
asDropdownItem
|
||||
>
|
||||
<Share2 className="h-3.5 w-3.5" />
|
||||
Share assets
|
||||
</ShareAssetsTrigger>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="absolute top-2 right-2 flex flex-col gap-2">
|
||||
<PersonMergeDropdown person={person} onRemove={onRemove} />
|
||||
<Button variant="outline" className="!py-0.5 !px-2 text-xs h-7" onClick={() => {
|
||||
handleHide(!formData.isHidden);
|
||||
}}>
|
||||
{formData.isHidden ? "Show" : "Hide"}
|
||||
</Button>
|
||||
<ShareAssetsTrigger filters={{ personIds: [person.id] }} buttonProps={{ variant: "outline", className: "!py-0.5 !px-2 text-xs h-7" }} />
|
||||
|
||||
{/* Quick actions — bottom-right, visible on hover */}
|
||||
<div className="absolute bottom-1.5 right-1.5 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-150">
|
||||
<Tooltip content={formData.isHidden ? "Show person" : "Hide person"}>
|
||||
<button
|
||||
onClick={() => handleHide(!formData.isHidden)}
|
||||
disabled={loading}
|
||||
className="h-6 w-6 rounded-full bg-black/50 hover:bg-black/70 text-white backdrop-blur-sm flex items-center justify-center transition-colors"
|
||||
>
|
||||
{formData.isHidden ? <Eye className="h-3 w-3" /> : <EyeOff className="h-3 w-3" />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Merge">
|
||||
<span>
|
||||
<PersonMergeDropdown
|
||||
person={person}
|
||||
onRemove={onRemove}
|
||||
triggerClassName="!p-0 !h-6 !w-6 rounded-full !bg-black/50 hover:!bg-black/70 !text-white !border-transparent backdrop-blur-sm flex items-center justify-center"
|
||||
triggerIcon={<GitMerge className="h-3 w-3" />}
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{!editMode ? (
|
||||
<h2
|
||||
className="text-lg text-center font-semibold hover:bg-gray-300 dark:hover:bg-gray-800 w-full px-2 py-1 rounded-lg"
|
||||
onClick={() => {
|
||||
setEditMode((prev) => !prev);
|
||||
}}
|
||||
>
|
||||
{formData.name ? (
|
||||
formData.name
|
||||
) : (
|
||||
<span className="text-gray-400">Unknown</span>
|
||||
)}
|
||||
</h2>
|
||||
) : (
|
||||
<Autocomplete
|
||||
loadOptions={(query: string) => searchPeople(query).then((people) => people.map((person: any) => ({
|
||||
label: person.name, value: person.id,
|
||||
imageUrl: person.thumbnailPath
|
||||
})))}
|
||||
type="text"
|
||||
className="text-lg font-semibold text-center w-full px-2 py-1 rounded-lg"
|
||||
defaultValue={formData.name}
|
||||
placeholder="Enter name"
|
||||
autoFocus
|
||||
onChange={(e) => {
|
||||
setFormData({ ...formData, name: e.target.value });
|
||||
}}
|
||||
value={formData.name}
|
||||
onOptionSelect={(value) => {
|
||||
mergeDialogRef.current?.open();
|
||||
selectedPerson.current = value;
|
||||
}}
|
||||
createNewLabel="Create"
|
||||
disabled={loading}
|
||||
onCreateNew={(value) => {
|
||||
handleEdit();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
{/* Name + birthday section */}
|
||||
<div className="px-1.5 py-1.5 flex flex-col gap-1 min-w-0">
|
||||
{!editMode ? (
|
||||
<button
|
||||
className="text-left w-full text-xs font-semibold leading-snug hover:text-muted-foreground transition-colors truncate"
|
||||
onClick={() => setEditMode(true)}
|
||||
title={formData.name || "Click to set name"}
|
||||
>
|
||||
{formData.name || (
|
||||
<span className="text-muted-foreground/50 font-normal italic text-[11px]">Unknown</span>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<Autocomplete
|
||||
loadOptions={(query: string) =>
|
||||
searchPeople(query).then((people) =>
|
||||
people.map((p: any) => ({ label: p.name, value: p.id, imageUrl: p.thumbnailPath }))
|
||||
)
|
||||
}
|
||||
type="text"
|
||||
className="text-xs font-semibold w-full px-1 py-0.5 rounded-md"
|
||||
defaultValue={formData.name}
|
||||
placeholder="Enter name"
|
||||
autoFocus
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
value={formData.name}
|
||||
onOptionSelect={(value) => {
|
||||
mergeDialogRef.current?.open();
|
||||
selectedPerson.current = value;
|
||||
}}
|
||||
createNewLabel="Save"
|
||||
disabled={loading}
|
||||
onCreateNew={() => handleEdit()}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PersonBirthdayCell person={person} />
|
||||
</div>
|
||||
|
||||
@@ -168,9 +201,7 @@ export default function PersonItem({ person, onRemove }: IProps) {
|
||||
title="Merge Person"
|
||||
description="Are you sure you want to merge this person with the selected person?"
|
||||
onConfirm={() => {
|
||||
if (selectedPerson.current) {
|
||||
handleMerge(selectedPerson.current);
|
||||
}
|
||||
if (selectedPerson.current) handleMerge(selectedPerson.current);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -41,6 +41,7 @@ interface PersonMergeDropdownProps {
|
||||
onRemove?: (person: IPerson) => void;
|
||||
onComplete?: (mergedPerson: IPerson) => void;
|
||||
triggerClassName?: string;
|
||||
triggerIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function PersonMergeDropdown({
|
||||
@@ -48,6 +49,7 @@ export function PersonMergeDropdown({
|
||||
onRemove,
|
||||
onComplete,
|
||||
triggerClassName,
|
||||
triggerIcon,
|
||||
}: PersonMergeDropdownProps) {
|
||||
const [searchedPeople, setSearchedPeople] = useState<IPerson[] | null>(null);
|
||||
const [similarPeople, setSimilarPeople] = useState<IPerson[]>([]);
|
||||
@@ -243,7 +245,7 @@ export function PersonMergeDropdown({
|
||||
variant="outline"
|
||||
className={cn("!py-0.5 !px-2 text-xs h-7", triggerClassName)}
|
||||
>
|
||||
Merge
|
||||
{triggerIcon ?? "Merge"}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, { useState } from "react";
|
||||
import { KeyRound, Wand2, Settings } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import Link from "next/link";
|
||||
|
||||
interface Permission {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface ApiKeyGateProps {
|
||||
title: string;
|
||||
description: string;
|
||||
permissions: Permission[];
|
||||
generateEndpoint: string;
|
||||
onGenerated: () => void;
|
||||
}
|
||||
|
||||
export default function ApiKeyGate({ title, description, permissions, generateEndpoint, onGenerated }: ApiKeyGateProps) {
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setGenerating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(generateEndpoint, { method: "POST" });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.message ?? "Failed to generate API key");
|
||||
}
|
||||
onGenerated();
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? "Failed to generate API key");
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-8">
|
||||
<div className="max-w-md w-full text-center space-y-6">
|
||||
<div className="mx-auto h-14 w-14 rounded-2xl bg-muted flex items-center justify-center">
|
||||
<KeyRound className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{description}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-muted/40 p-4 text-left space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Required permissions</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{permissions.map((p) => (
|
||||
<Badge key={p.name} variant="outline" className="text-[11px] font-mono font-normal" title={p.description}>
|
||||
{p.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button onClick={handleGenerate} disabled={generating} className="w-full">
|
||||
<Wand2 className="h-4 w-4 mr-2" />
|
||||
{generating ? "Generating…" : "Generate API key automatically"}
|
||||
</Button>
|
||||
<Link href="/settings">
|
||||
<Button variant="outline" className="w-full">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Configure manually in Settings
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,8 +13,19 @@ import { usePhotoSelectionContext } from '@/contexts/PhotoSelectionContext';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Heart, Info, Trash2, ExternalLink } from 'lucide-react';
|
||||
import { updateAssets } from '@/handlers/api/asset.handler';
|
||||
import { updateAssets, deleteAssets } from '@/handlers/api/asset.handler';
|
||||
import { toast } from '@/components/ui/use-toast';
|
||||
import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||
|
||||
const AssetInfoPanel = dynamic(() => import('@/components/asset-info/AssetInfoPanel'), { ssr: false });
|
||||
|
||||
@@ -50,6 +61,8 @@ const AssetGrid = forwardRef<AssetGridRef, AssetGridProps>(({ assets, isInternal
|
||||
return false
|
||||
});
|
||||
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(() => new Set(assets.filter(a => a.isFavorite).map(a => a.id)));
|
||||
const [deletedIds, setDeletedIds] = useState<Set<string>>(new Set());
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const { exImmichUrl } = useConfig();
|
||||
// Use context for selection state
|
||||
const { selectedIds, updateContext } = usePhotoSelectionContext();
|
||||
@@ -145,7 +158,7 @@ const AssetGrid = forwardRef<AssetGridRef, AssetGridProps>(({ assets, isInternal
|
||||
};
|
||||
|
||||
const slides = useMemo(() => {
|
||||
return assets.map((asset) => ({
|
||||
return assets.filter((asset) => !deletedIds.has(asset.id)).map((asset) => ({
|
||||
...asset,
|
||||
orientation: 1,
|
||||
src: asset.previewUrl as string,
|
||||
@@ -166,18 +179,20 @@ const AssetGrid = forwardRef<AssetGridRef, AssetGridProps>(({ assets, isInternal
|
||||
}, [assets]);
|
||||
|
||||
const images: AssetPhoto[] = useMemo(() => {
|
||||
return assets.map((p) => ({
|
||||
...p,
|
||||
src: p.url as string,
|
||||
original: p.previewUrl as string,
|
||||
width: p.exifImageWidth as number,
|
||||
height: p.exifImageHeight as number,
|
||||
orientation: 1,
|
||||
isSelected: selectedIds.includes(p.id),
|
||||
isVideo: p.type === "VIDEO",
|
||||
duration: p.duration != null ? String(p.duration) : undefined,
|
||||
}));
|
||||
}, [assets, selectedIds, exImmichUrl]);
|
||||
return assets
|
||||
.filter((p) => !deletedIds.has(p.id))
|
||||
.map((p) => ({
|
||||
...p,
|
||||
src: p.url as string,
|
||||
original: p.previewUrl as string,
|
||||
width: p.exifImageWidth as number,
|
||||
height: p.exifImageHeight as number,
|
||||
orientation: 1,
|
||||
isSelected: selectedIds.includes(p.id),
|
||||
isVideo: p.type === "VIDEO",
|
||||
duration: p.duration != null ? String(p.duration) : undefined,
|
||||
}));
|
||||
}, [assets, selectedIds, deletedIds]);
|
||||
|
||||
const handleEsc = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
@@ -242,20 +257,15 @@ const AssetGrid = forwardRef<AssetGridRef, AssetGridProps>(({ assets, isInternal
|
||||
>
|
||||
<ExternalLink className="h-6 w-6 text-white" />
|
||||
</button>,
|
||||
...(onDeleteAsset ? [
|
||||
<button
|
||||
key="delete"
|
||||
type="button"
|
||||
className="yarl__button"
|
||||
title="Delete"
|
||||
onClick={() => {
|
||||
onDeleteAsset(currentAsset.id);
|
||||
setIndex(-1);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-6 w-6 text-white" />
|
||||
</button>,
|
||||
] : []),
|
||||
<button
|
||||
key="delete"
|
||||
type="button"
|
||||
className="yarl__button"
|
||||
title="Delete"
|
||||
onClick={() => setDeleteConfirmOpen(true)}
|
||||
>
|
||||
<Trash2 className="h-6 w-6 text-white" />
|
||||
</button>,
|
||||
];
|
||||
}, [currentAsset, showInfoPanel, onDeleteAsset, onFavoriteAsset, handleOpenInImmich, favoriteIds, handleToggleFavorite]);
|
||||
|
||||
@@ -280,7 +290,7 @@ const AssetGrid = forwardRef<AssetGridRef, AssetGridProps>(({ assets, isInternal
|
||||
render={{
|
||||
slideContainer: ({ children }) => (
|
||||
<div className="flex h-full w-full">
|
||||
<div className="flex-1 flex items-center justify-center overflow-hidden">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
{showInfoPanel && currentAsset && (
|
||||
@@ -295,8 +305,47 @@ const AssetGrid = forwardRef<AssetGridRef, AssetGridProps>(({ assets, isInternal
|
||||
container: {
|
||||
backgroundColor: "rgba(0, 0, 0, 0.95)",
|
||||
},
|
||||
slide: {
|
||||
padding: 0,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<AlertDialogPrimitive.Root open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay className="!z-[10000]" />
|
||||
<AlertDialogPrimitive.Content className="fixed left-[50%] top-[50%] z-[10001] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background 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%] sm:rounded-lg">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete asset?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete{currentAsset ? ` "${currentAsset.originalFileName}"` : " this asset"}. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setDeleteConfirmOpen(false)}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
if (currentAsset) {
|
||||
try {
|
||||
await deleteAssets([currentAsset.id]);
|
||||
setDeletedIds((prev) => new Set(prev).add(currentAsset.id));
|
||||
onDeleteAsset?.(currentAsset.id);
|
||||
toast({ title: "Deleted", description: `"${currentAsset.originalFileName}" deleted.` });
|
||||
setIndex(-1);
|
||||
} catch {
|
||||
toast({ title: "Error", description: "Failed to delete asset.", variant: "destructive" });
|
||||
}
|
||||
}
|
||||
setDeleteConfirmOpen(false);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogPrimitive.Content>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialogPrimitive.Root>
|
||||
|
||||
<RowsPhotoAlbum
|
||||
photos={images}
|
||||
targetRowHeight={150}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Dialog, DialogTitle, DialogContent, DialogHeader, DialogTrigger, DialogDescription } from '../ui/dialog';
|
||||
import { Button, ButtonProps } from '../ui/button';
|
||||
import { DropdownMenuItem } from '../ui/dropdown-menu';
|
||||
import { ShareLinkFilters } from '@/types/shareLink';
|
||||
import { generateShareLink } from '@/handlers/api/shareLink.handler';
|
||||
import { createShareKey, getShareKey } from '@/handlers/api/share-key.handler';
|
||||
@@ -13,9 +14,11 @@ import { Select, SelectValue, SelectContent, SelectItem, SelectTrigger } from '.
|
||||
interface ShareAssetsTriggerProps {
|
||||
filters: ShareLinkFilters
|
||||
buttonProps?: ButtonProps
|
||||
asDropdownItem?: boolean
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export default function ShareAssetsTrigger({ filters, buttonProps }: ShareAssetsTriggerProps) {
|
||||
export default function ShareAssetsTrigger({ filters, buttonProps, asDropdownItem, children }: ShareAssetsTriggerProps) {
|
||||
const [generatedLink, setGeneratedLink] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
@@ -91,7 +94,13 @@ export default function ShareAssetsTrigger({ filters, buttonProps }: ShareAssets
|
||||
return (
|
||||
<Dialog onOpenChange={(open) => { if (open) checkShareKey(); }}>
|
||||
<DialogTrigger asChild>
|
||||
<Button {...buttonProps}>Share</Button>
|
||||
{asDropdownItem ? (
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()} className="gap-2">
|
||||
{children}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<Button {...buttonProps}>{children ?? "Share"}</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
|
||||
@@ -48,6 +48,11 @@ export default function Sidebar() {
|
||||
>
|
||||
{nav.icon}
|
||||
{nav.title}
|
||||
{nav.badge && (
|
||||
<span className="ml-auto text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-amber-500/15 text-amber-600 dark:text-amber-400 leading-none">
|
||||
{nav.badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from "react"
|
||||
import ReactDOM from "react-dom"
|
||||
import { Input } from "./input"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Avatar, AvatarImage } from "./avatar"
|
||||
@@ -43,10 +44,18 @@ const Autocomplete = React.forwardRef<HTMLInputElement, AutocompleteProps>(
|
||||
const [options, setOptions] = React.useState<AutocompleteOption[]>(staticOptions || [])
|
||||
const [isLoading, setIsLoading] = React.useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(-1)
|
||||
const [dropdownRect, setDropdownRect] = React.useState<{ top: number; left: number; width: number } | null>(null)
|
||||
const containerRef = React.useRef<HTMLDivElement>(null)
|
||||
const inputRef = React.useRef<HTMLInputElement>(null)
|
||||
const debounceTimeout = React.useRef<NodeJS.Timeout>(undefined)
|
||||
|
||||
const updateDropdownRect = React.useCallback(() => {
|
||||
if (inputRef.current) {
|
||||
const rect = inputRef.current.getBoundingClientRect()
|
||||
setDropdownRect({ top: rect.bottom + window.scrollY + 4, left: rect.left + window.scrollX, width: rect.width })
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Find the label for the current value
|
||||
const currentLabel = React.useMemo(() => {
|
||||
if (!value) return ""
|
||||
@@ -181,6 +190,7 @@ const Autocomplete = React.forwardRef<HTMLInputElement, AutocompleteProps>(
|
||||
}}
|
||||
value={inputValue}
|
||||
onFocus={() => {
|
||||
updateDropdownRect()
|
||||
setOpen(true)
|
||||
setSelectedIndex(showCreateNew ? 0 : -1)
|
||||
}}
|
||||
@@ -194,8 +204,11 @@ const Autocomplete = React.forwardRef<HTMLInputElement, AutocompleteProps>(
|
||||
props.onChange?.(e)
|
||||
}}
|
||||
/>
|
||||
{open && (filteredOptions.length > 0 || isLoading || showCreateNew) && (
|
||||
<div className={cn("absolute left-0 right-0 z-50 mt-1 max-h-[300px] overflow-auto rounded-md border bg-popover shadow-md", position === "top" ? "top-full" : "bottom-full")}>
|
||||
{open && (filteredOptions.length > 0 || isLoading || showCreateNew) && dropdownRect && typeof document !== "undefined" && ReactDOM.createPortal(
|
||||
<div
|
||||
style={{ position: "absolute", top: dropdownRect.top, left: dropdownRect.left, width: dropdownRect.width }}
|
||||
className="z-[9999] max-h-[300px] overflow-auto rounded-md border bg-popover shadow-md"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
Loading...
|
||||
@@ -241,7 +254,8 @@ const Autocomplete = React.forwardRef<HTMLInputElement, AutocompleteProps>(
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ interface SidebarNav {
|
||||
title: string;
|
||||
link: string;
|
||||
icon: React.ReactNode;
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
interface SidebarGroup {
|
||||
@@ -34,7 +35,7 @@ export const sidebarGroups: SidebarGroup[] = [
|
||||
{
|
||||
label: "Automation",
|
||||
items: [
|
||||
{ title: "Workflows", link: "/workflows", icon: <Workflow className="h-4 w-4" /> },
|
||||
{ title: "Workflows", link: "/workflows", icon: <Workflow className="h-4 w-4" />, badge: "Beta" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { desc, eq, and } from "drizzle-orm";
|
||||
import { appDb } from "@/db";
|
||||
import { importJobs, importJobItems } from "@/db/schema";
|
||||
import { settings } from "@/db/schema/settings.schema";
|
||||
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
|
||||
import { getUserHeaders } from "@/helpers/user.helper";
|
||||
import { runImportJob } from "@/workers/import/runner";
|
||||
import { getProcessor } from "@/workers/import/registry";
|
||||
import { ENV } from "@/config/environment";
|
||||
|
||||
const IMPORT_API_KEY_SETTING = "import_api_key";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "GET") {
|
||||
@@ -66,7 +70,19 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
}))
|
||||
);
|
||||
|
||||
const headers = getUserHeaders(currentUser) as Record<string, string>;
|
||||
// Prefer import API key over user session for uploads
|
||||
const [importKeyRow] = await appDb
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(and(eq(settings.key, IMPORT_API_KEY_SETTING), eq(settings.ownerId, currentUser.id)));
|
||||
|
||||
let headers: Record<string, string>;
|
||||
if (importKeyRow?.value) {
|
||||
headers = { "x-api-key": importKeyRow.value, "Content-Type": "application/json" };
|
||||
} else {
|
||||
headers = getUserHeaders(currentUser) as Record<string, string>;
|
||||
}
|
||||
|
||||
runImportJob(job.id, headers).catch((err) =>
|
||||
console.error("Unhandled error in runImportJob", err)
|
||||
);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ENV } from "@/config/environment";
|
||||
import { appDb } from "@/db";
|
||||
import { settings } from "@/db/schema/settings.schema";
|
||||
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
|
||||
import { getUserHeaders } from "@/helpers/user.helper";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export const IMPORT_API_KEY_SETTING = "import_api_key";
|
||||
const IMPORT_KEY_NAME = "Power Tools Import Key";
|
||||
const IMPORT_PERMISSIONS = [
|
||||
"asset.read",
|
||||
"asset.upload",
|
||||
"album.read",
|
||||
"album.create",
|
||||
"album.update",
|
||||
"tag.create",
|
||||
];
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") return res.status(405).json({ message: "Method not allowed" });
|
||||
|
||||
const currentUser = await getCurrentUser(req);
|
||||
if (!currentUser) return res.status(401).json({ message: "Unauthorized" });
|
||||
|
||||
const immichRes = await fetch(`${ENV.IMMICH_URL}/api/api-keys`, {
|
||||
method: "POST",
|
||||
headers: getUserHeaders(currentUser, { "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ name: IMPORT_KEY_NAME, permissions: IMPORT_PERMISSIONS }),
|
||||
});
|
||||
|
||||
if (!immichRes.ok) {
|
||||
const err = await immichRes.json().catch(() => ({}));
|
||||
return res.status(immichRes.status).json({ message: err.message ?? "Failed to create API key in Immich" });
|
||||
}
|
||||
|
||||
const { secret } = await immichRes.json();
|
||||
|
||||
const [existing] = await appDb
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(and(eq(settings.key, IMPORT_API_KEY_SETTING), eq(settings.ownerId, currentUser.id)));
|
||||
|
||||
if (existing) {
|
||||
await appDb.update(settings).set({ value: secret }).where(eq(settings.id, existing.id));
|
||||
} else {
|
||||
await appDb.insert(settings).values({ key: IMPORT_API_KEY_SETTING, value: secret, ownerId: currentUser.id });
|
||||
}
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ENV } from "@/config/environment";
|
||||
import { appDb } from "@/db";
|
||||
import { settings } from "@/db/schema/settings.schema";
|
||||
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
|
||||
import { getUserHeaders } from "@/helpers/user.helper";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
const WORKFLOW_API_KEY_SETTING = "workflow_api_key";
|
||||
const WORKFLOW_KEY_NAME = "Power Tools Workflow Key";
|
||||
const WORKFLOW_PERMISSIONS = [
|
||||
"asset.read",
|
||||
"asset.update",
|
||||
"album.read",
|
||||
"album.create",
|
||||
"album.update",
|
||||
"tag.create",
|
||||
];
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== "POST") return res.status(405).json({ message: "Method not allowed" });
|
||||
|
||||
const currentUser = await getCurrentUser(req);
|
||||
if (!currentUser) return res.status(401).json({ message: "Unauthorized" });
|
||||
|
||||
// Create a new API key in Immich with workflow permissions
|
||||
const immichRes = await fetch(`${ENV.IMMICH_URL}/api/api-keys`, {
|
||||
method: "POST",
|
||||
headers: getUserHeaders(currentUser, { "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ name: WORKFLOW_KEY_NAME, permissions: WORKFLOW_PERMISSIONS }),
|
||||
});
|
||||
|
||||
if (!immichRes.ok) {
|
||||
const err = await immichRes.json().catch(() => ({}));
|
||||
return res.status(immichRes.status).json({ message: err.message ?? "Failed to create API key in Immich" });
|
||||
}
|
||||
|
||||
const { secret } = await immichRes.json();
|
||||
|
||||
// Save the secret to the KV settings store as workflow_api_key
|
||||
const [existing] = await appDb
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(and(eq(settings.key, WORKFLOW_API_KEY_SETTING), eq(settings.ownerId, currentUser.id)));
|
||||
|
||||
if (existing) {
|
||||
await appDb.update(settings).set({ value: secret }).where(eq(settings.id, existing.id));
|
||||
} else {
|
||||
await appDb.insert(settings).values({ key: WORKFLOW_API_KEY_SETTING, value: secret, ownerId: currentUser.id });
|
||||
}
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
}
|
||||
@@ -20,6 +20,16 @@ import {
|
||||
} from "lucide-react";
|
||||
import PageLayout from "@/components/layouts/PageLayout";
|
||||
import Header from "@/components/shared/Header";
|
||||
import ApiKeyGate from "@/components/shared/ApiKeyGate";
|
||||
|
||||
const IMPORT_PERMISSIONS = [
|
||||
{ name: "asset.read", description: "Check for existing assets" },
|
||||
{ name: "asset.upload", description: "Upload new assets" },
|
||||
{ name: "album.read", description: "Read album data" },
|
||||
{ name: "album.create", description: "Create new albums" },
|
||||
{ name: "album.update", description: "Add assets to albums" },
|
||||
{ name: "tag.create", description: "Tag imported assets" },
|
||||
];
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -153,6 +163,15 @@ export default function ImportSharedPage() {
|
||||
const [tagAssets, setTagAssets] = useState(true);
|
||||
const [detailJobId, setDetailJobId] = useState<string | null>(null);
|
||||
|
||||
const [hasImportApiKey, setHasImportApiKey] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/settings/kv/import_api_key")
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((data) => setHasImportApiKey(!!data?.value))
|
||||
.catch(() => setHasImportApiKey(false));
|
||||
}, []);
|
||||
|
||||
const { data: permissions } = useQuery({
|
||||
queryKey: ["validate-permissions"],
|
||||
queryFn: validatePermissions,
|
||||
@@ -457,8 +476,19 @@ export default function ImportSharedPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* ── API key gate ── */}
|
||||
{hasImportApiKey === false && (
|
||||
<ApiKeyGate
|
||||
title="Import API Key Required"
|
||||
description="Importing shared albums requires a dedicated Immich API key with upload permissions. Generate one automatically or configure it in Settings."
|
||||
permissions={IMPORT_PERMISSIONS}
|
||||
generateEndpoint="/api/settings/generate-import-api-key"
|
||||
onGenerated={() => setHasImportApiKey(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Landing state ── */}
|
||||
{!sharedData && (
|
||||
{hasImportApiKey !== false && !sharedData && (
|
||||
<div className="flex flex-1 justify-center px-4 py-8">
|
||||
<div className="w-full max-w-2xl space-y-6">
|
||||
{permissions?.canUpload === false && (
|
||||
@@ -586,7 +616,7 @@ export default function ImportSharedPage() {
|
||||
)}
|
||||
|
||||
{/* ── Album detail + gallery view ── */}
|
||||
{sharedData && albumDetails && sharedLinkDetails && (
|
||||
{hasImportApiKey !== false && sharedData && albumDetails && sharedLinkDetails && (
|
||||
<>
|
||||
<div className="flex flex-col gap-4 p-4 pb-36">
|
||||
{/* Upload banner */}
|
||||
|
||||
+105
-117
@@ -1,14 +1,11 @@
|
||||
import PageLayout from "@/components/layouts/PageLayout";
|
||||
import Header from "@/components/shared/Header";
|
||||
import { deleteApiKey, getApiKeys, regenerateApiKey } from "@/handlers/api/settings.handler";
|
||||
import { AlertDialog } from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
RefreshCw, Trash2, Save, Eye, EyeOff, ExternalLink,
|
||||
Key, Workflow, Shield, CheckCircle, XCircle, Server, Globe,
|
||||
Save, Eye, EyeOff, ExternalLink,
|
||||
Workflow, Shield, CheckCircle, XCircle, Server, Globe, Download, Wand2,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -16,13 +13,6 @@ import API from "@/lib/api";
|
||||
import { useConfig } from "@/contexts/ConfigContext";
|
||||
import Link from "next/link";
|
||||
|
||||
interface ApiKey {
|
||||
id: string;
|
||||
purpose: string;
|
||||
keyName: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function ConnectionStatus({ label, url, icon: Icon }: { label: string; url: string; icon: any }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2">
|
||||
@@ -47,11 +37,6 @@ function ConnectionStatus({ label, url, icon: Icon }: { label: string; url: stri
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { exImmichUrl, immichURL, version, aiEnabled } = useConfig();
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const [regenerating, setRegenerating] = useState<string | null>(null);
|
||||
|
||||
const [workflowApiKey, setWorkflowApiKey] = useState("");
|
||||
const [workflowApiKeyLoading, setWorkflowApiKeyLoading] = useState(false);
|
||||
const [showWorkflowApiKey, setShowWorkflowApiKey] = useState(false);
|
||||
@@ -60,6 +45,12 @@ export default function SettingsPage() {
|
||||
valid: boolean; error: string | null; permissions: string[]; missing: string[];
|
||||
} | null>(null);
|
||||
|
||||
const [importApiKey, setImportApiKey] = useState("");
|
||||
const [importApiKeyLoading, setImportApiKeyLoading] = useState(false);
|
||||
const [showImportApiKey, setShowImportApiKey] = useState(false);
|
||||
const [importApiKeySaved, setImportApiKeySaved] = useState(false);
|
||||
const [generatingImportKey, setGeneratingImportKey] = useState(false);
|
||||
|
||||
const fetchWorkflowApiKey = async () => {
|
||||
try {
|
||||
const data = await API.get("/api/settings/kv/workflow_api_key");
|
||||
@@ -70,6 +61,40 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchImportApiKey = async () => {
|
||||
try {
|
||||
const data = await API.get("/api/settings/kv/import_api_key");
|
||||
setImportApiKey(data.value || "");
|
||||
setImportApiKeySaved(!!data.value);
|
||||
} catch {
|
||||
setImportApiKeySaved(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveImportApiKey = async () => {
|
||||
setImportApiKeyLoading(true);
|
||||
try {
|
||||
await API.put("/api/settings/kv/import_api_key", { value: importApiKey.trim() });
|
||||
setImportApiKeySaved(!!importApiKey.trim());
|
||||
toast.success(importApiKey.trim() ? "Import API key saved" : "Import API key cleared");
|
||||
} catch {
|
||||
toast.error("Failed to save import API key");
|
||||
}
|
||||
setImportApiKeyLoading(false);
|
||||
};
|
||||
|
||||
const handleGenerateImportKey = async () => {
|
||||
setGeneratingImportKey(true);
|
||||
try {
|
||||
await API.post("/api/settings/generate-import-api-key", {});
|
||||
await fetchImportApiKey();
|
||||
toast.success("Import API key generated and saved");
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message ?? "Failed to generate import API key");
|
||||
}
|
||||
setGeneratingImportKey(false);
|
||||
};
|
||||
|
||||
const saveWorkflowApiKey = async () => {
|
||||
if (!workflowApiKey.trim()) {
|
||||
// Clear the key
|
||||
@@ -111,44 +136,11 @@ export default function SettingsPage() {
|
||||
setWorkflowApiKeyLoading(false);
|
||||
};
|
||||
|
||||
const fetchKeys = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getApiKeys();
|
||||
setKeys(data);
|
||||
} catch {}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchKeys();
|
||||
fetchWorkflowApiKey();
|
||||
fetchImportApiKey();
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (purpose: string) => {
|
||||
setDeleting(purpose);
|
||||
try {
|
||||
await deleteApiKey(purpose);
|
||||
setKeys((prev) => prev.filter((k) => k.purpose !== purpose));
|
||||
toast.success("API key deleted");
|
||||
} catch (err: any) {
|
||||
toast.error(err.message ?? "Failed to delete API key");
|
||||
}
|
||||
setDeleting(null);
|
||||
};
|
||||
|
||||
const handleRegenerate = async (purpose: string) => {
|
||||
setRegenerating(purpose);
|
||||
try {
|
||||
await regenerateApiKey(purpose);
|
||||
await fetchKeys();
|
||||
toast.success("API key regenerated");
|
||||
} catch (err: any) {
|
||||
toast.error(err.message ?? "Failed to regenerate API key");
|
||||
}
|
||||
setRegenerating(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<Header leftComponent="Settings" />
|
||||
@@ -265,78 +257,74 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Managed API Keys */}
|
||||
{/* Import API Key */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Key className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold tracking-wide uppercase text-muted-foreground">Managed API Keys</h2>
|
||||
<span className="text-xs text-muted-foreground ml-auto">{keys.length} key{keys.length !== 1 ? "s" : ""}</span>
|
||||
</div>
|
||||
<div className="border rounded-lg">
|
||||
{loading ? (
|
||||
<div className="px-4 py-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
) : keys.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center">
|
||||
<Key className="h-5 w-5 text-muted-foreground/40 mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">No managed API keys yet.</p>
|
||||
<p className="text-xs text-muted-foreground/60 mt-0.5">Keys are created automatically when needed (e.g. share links).</p>
|
||||
</div>
|
||||
<Download className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold tracking-wide uppercase text-muted-foreground">Import API Key</h2>
|
||||
{importApiKeySaved ? (
|
||||
<CheckCircle className="h-3.5 w-3.5 text-green-500 ml-auto" />
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{keys.map((key) => (
|
||||
<li key={key.id} className="flex items-center justify-between px-4 py-3 gap-4">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">{key.keyName}</span>
|
||||
<Badge variant="secondary" className="capitalize text-[10px] h-4">{key.purpose}</Badge>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
Created {new Date(key.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<AlertDialog
|
||||
title="Regenerate API Key"
|
||||
description={`This will delete the current "${key.keyName}" key from Immich and create a new one.`}
|
||||
onConfirm={() => handleRegenerate(key.purpose)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
disabled={regenerating === key.purpose || deleting === key.purpose}
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${regenerating === key.purpose ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</AlertDialog>
|
||||
<AlertDialog
|
||||
title="Delete API Key"
|
||||
description={`This will remove the "${key.keyName}" key. Share links that depend on it will stop working.`}
|
||||
onConfirm={() => handleDelete(key.purpose)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
disabled={deleting === key.purpose || regenerating === key.purpose}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||
</Button>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<XCircle className="h-3.5 w-3.5 text-yellow-500 ml-auto" />
|
||||
)}
|
||||
</div>
|
||||
<div className="border rounded-lg p-4 space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Immich API key used when importing from shared links. Needs asset upload, album, and tag permissions.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showImportApiKey ? "text" : "password"}
|
||||
className="h-9 text-sm font-mono pr-10"
|
||||
placeholder="Paste your Immich API key here"
|
||||
value={importApiKey}
|
||||
onChange={(e) => setImportApiKey(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setShowImportApiKey((v) => !v)}
|
||||
>
|
||||
{showImportApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<Button size="sm" className="h-9" onClick={saveImportApiKey} disabled={importApiKeyLoading}>
|
||||
<Save className="h-3.5 w-3.5 mr-1" />
|
||||
{importApiKeyLoading ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{["asset.read", "asset.upload", "album.read", "album.create", "album.update", "tag.create"].map((p) => (
|
||||
<Badge key={p} variant="outline" className="text-[10px] h-5 font-mono font-normal">{p}</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs"
|
||||
onClick={handleGenerateImportKey}
|
||||
disabled={generatingImportKey}
|
||||
>
|
||||
<Wand2 className="h-3 w-3 mr-1" />
|
||||
{generatingImportKey ? "Generating..." : "Generate automatically"}
|
||||
</Button>
|
||||
{exImmichUrl && (
|
||||
<Link
|
||||
href={`${exImmichUrl}/user-settings?isOpen=api-keys`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
Create in Immich
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
|
||||
+118
-119
@@ -30,11 +30,22 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ASSET_THUMBNAIL_PATH } from "@/config/routes";
|
||||
import { IWorkflowRun } from "@/types/workflow";
|
||||
import { ArrowLeft, Play, Save, Clock, Webhook, History, ChevronUp, ChevronDown, Bug, TriangleAlert } from "lucide-react";
|
||||
import { ArrowLeft, Play, Save, Clock, Webhook, History, ChevronUp, ChevronDown, Bug } from "lucide-react";
|
||||
import Head from "next/head";
|
||||
import { format } from "date-fns";
|
||||
import Link from "next/link";
|
||||
import Loader from "@/components/ui/loader";
|
||||
import API from "@/lib/api";
|
||||
import ApiKeyGate from "@/components/shared/ApiKeyGate";
|
||||
|
||||
const WORKFLOW_PERMISSIONS = [
|
||||
{ name: "asset.read", description: "Query and filter assets" },
|
||||
{ name: "asset.update", description: "Favorite, archive, update metadata" },
|
||||
{ name: "album.read", description: "Read album data" },
|
||||
{ name: "album.create", description: "Create new albums" },
|
||||
{ name: "album.update", description: "Add/remove assets from albums" },
|
||||
{ name: "tag.create", description: "Create and assign tags" },
|
||||
];
|
||||
|
||||
function generateId() {
|
||||
return Math.random().toString(36).substring(2, 15);
|
||||
@@ -121,26 +132,27 @@ function parseCronToForm(cron: string): { every: number; interval: string } {
|
||||
return { every: 0, interval: "hours" };
|
||||
}
|
||||
|
||||
function SchedulePopover({ value, onChange, workflowId }: { value: string; onChange: (cron: string) => void; workflowId?: string }) {
|
||||
const parsed = parseCronToForm(value);
|
||||
function TriggerPopover({
|
||||
cronSchedule, onCronChange, webhookToken, workflowId,
|
||||
}: {
|
||||
cronSchedule: string; onCronChange: (cron: string) => void; webhookToken: string | null; workflowId?: string;
|
||||
}) {
|
||||
const parsed = parseCronToForm(cronSchedule);
|
||||
const [every, setEvery] = useState(parsed.every);
|
||||
const [interval, setInterval] = useState(parsed.interval);
|
||||
|
||||
useEffect(() => {
|
||||
const p = parseCronToForm(value);
|
||||
const p = parseCronToForm(cronSchedule);
|
||||
setEvery(p.every);
|
||||
setInterval(p.interval);
|
||||
}, [value]);
|
||||
}, [cronSchedule]);
|
||||
|
||||
const handleApply = () => {
|
||||
if (every <= 0) {
|
||||
onChange("");
|
||||
return;
|
||||
}
|
||||
if (every <= 0) { handleClear(); return; }
|
||||
const opt = intervalOptions.find((o) => o.value === interval);
|
||||
if (opt) {
|
||||
const cron = opt.cron(every);
|
||||
onChange(cron);
|
||||
onCronChange(cron);
|
||||
if (workflowId) {
|
||||
updateWorkflow(workflowId, { cronSchedule: cron } as any)
|
||||
.then(() => hotToast.success(`Schedule set — every ${every} ${interval}`))
|
||||
@@ -152,7 +164,7 @@ function SchedulePopover({ value, onChange, workflowId }: { value: string; onCha
|
||||
const handleClear = () => {
|
||||
setEvery(0);
|
||||
setInterval("hours");
|
||||
onChange("");
|
||||
onCronChange("");
|
||||
if (workflowId) {
|
||||
updateWorkflow(workflowId, { cronSchedule: null } as any)
|
||||
.then(() => hotToast.success("Schedule cleared"))
|
||||
@@ -160,51 +172,81 @@ function SchedulePopover({ value, onChange, workflowId }: { value: string; onCha
|
||||
}
|
||||
};
|
||||
|
||||
const summaryLabel = value
|
||||
? `Every ${parseCronToForm(value).every} ${parseCronToForm(value).interval}`
|
||||
: "Schedule";
|
||||
const hasSchedule = !!cronSchedule;
|
||||
const hasWebhook = !!webhookToken;
|
||||
const isActive = hasSchedule || hasWebhook;
|
||||
|
||||
const webhookUrl = webhookToken
|
||||
? `${typeof window !== "undefined" ? window.location.origin : ""}/api/webhooks/${webhookToken}`
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant={value ? "secondary" : "ghost"} size="sm" className="h-8">
|
||||
<Button variant={isActive ? "secondary" : "ghost"} size="sm" className="h-8">
|
||||
<Clock className="h-3.5 w-3.5 mr-1" />
|
||||
{summaryLabel}
|
||||
Triggers
|
||||
{isActive && (
|
||||
<span className="ml-1.5 text-[10px] bg-primary/15 text-primary rounded px-1 tabular-nums">
|
||||
{[hasSchedule && "cron", hasWebhook && "webhook"].filter(Boolean).join(" + ")}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64" align="start">
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-medium">Run every</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="h-8 text-sm w-20"
|
||||
type="number"
|
||||
min={0}
|
||||
value={every || ""}
|
||||
onChange={(e) => setEvery(parseInt(e.target.value) || 0)}
|
||||
placeholder="0"
|
||||
/>
|
||||
<Select value={interval} onValueChange={setInterval}>
|
||||
<SelectTrigger className="h-8 text-sm flex-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{intervalOptions.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<PopoverContent className="w-80" align="end">
|
||||
<div className="space-y-4">
|
||||
{/* Schedule section */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-medium">Schedule</span>
|
||||
{hasSchedule && <span className="ml-auto text-[10px] text-muted-foreground font-mono">{cronSchedule}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground shrink-0">Every</span>
|
||||
<Input
|
||||
className="h-7 text-xs w-16"
|
||||
type="number"
|
||||
min={0}
|
||||
value={every || ""}
|
||||
onChange={(e) => setEvery(parseInt(e.target.value) || 0)}
|
||||
placeholder="—"
|
||||
/>
|
||||
<Select value={interval} onValueChange={setInterval}>
|
||||
<SelectTrigger className="h-7 text-xs flex-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{intervalOptions.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" className="flex-1 h-7 text-xs" onClick={handleApply} disabled={every <= 0}>
|
||||
Apply
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={handleClear} disabled={!hasSchedule}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{every > 0 && (
|
||||
<p className="text-[10px] text-muted-foreground font-mono">
|
||||
{intervalOptions.find((o) => o.value === interval)?.cron(every)}
|
||||
|
||||
<div className="border-t" />
|
||||
|
||||
{/* Webhook section */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Webhook className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-medium">Webhook</span>
|
||||
</div>
|
||||
<Input
|
||||
className="h-7 text-[11px] font-mono"
|
||||
readOnly
|
||||
value={webhookUrl || "Save workflow to get a webhook URL"}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Configure this URL in Immich admin webhook settings to trigger on events.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" className="flex-1 h-7 text-xs" onClick={handleApply} disabled={every <= 0}>
|
||||
Apply
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={handleClear}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
@@ -421,11 +463,16 @@ function WorkflowEditorInner() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen">
|
||||
<Head>
|
||||
<title>{workflowName ? `${workflowName} — Workflow` : "Workflow"}</title>
|
||||
</Head>
|
||||
{/* Top bar */}
|
||||
<div className="h-12 border-b flex items-center gap-3 px-4 bg-background shrink-0">
|
||||
<Link href="/workflows" className="text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
<span className="text-sm text-muted-foreground hidden sm:inline">Workflow</span>
|
||||
<span className="text-muted-foreground hidden sm:inline">/</span>
|
||||
<Input
|
||||
className="h-8 w-64 text-sm font-medium"
|
||||
value={workflowName}
|
||||
@@ -443,75 +490,14 @@ function WorkflowEditorInner() {
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<div className="h-5 w-px bg-border mx-1" />
|
||||
|
||||
{/* Schedule config */}
|
||||
<SchedulePopover value={cronSchedule} onChange={setCronSchedule} workflowId={id} />
|
||||
|
||||
{/* Webhook URL */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant={webhookToken ? "secondary" : "ghost"} size="sm" className="h-8">
|
||||
<Webhook className="h-3.5 w-3.5 mr-1" />
|
||||
Webhook
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80" align="start">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Webhook URL</Label>
|
||||
<Input
|
||||
className="h-8 text-xs font-mono"
|
||||
readOnly
|
||||
value={webhookToken ? `${typeof window !== "undefined" ? window.location.origin : ""}/api/webhooks/${webhookToken}` : "Save workflow first"}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Configure this URL in Immich admin webhook settings to trigger this workflow on events.
|
||||
</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* API Key warning */}
|
||||
{hasApiKey === false && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 text-yellow-500 hover:text-yellow-600">
|
||||
<TriangleAlert className="h-4 w-4 mr-1" />
|
||||
<span className="text-xs">API Key Missing</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80" align="end">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<TriangleAlert className="h-4 w-4 text-yellow-500 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Workflow API Key Not Configured</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Workflows need a dedicated Immich API key to execute actions like creating albums, favoriting assets, and managing tags.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded p-2 bg-muted/50">
|
||||
<p className="text-xs font-medium mb-1">Required Permissions:</p>
|
||||
<ul className="text-[10px] text-muted-foreground space-y-0.5">
|
||||
<li>- <strong>asset.read</strong> — Query and filter assets</li>
|
||||
<li>- <strong>asset.update</strong> — Favorite, archive, update metadata</li>
|
||||
<li>- <strong>album.create</strong> — Create new albums</li>
|
||||
<li>- <strong>album.update</strong> — Add/remove assets from albums</li>
|
||||
<li>- <strong>tag.create</strong> — Create and assign tags</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Link href="/settings" className="block">
|
||||
<Button size="sm" className="w-full h-7 text-xs">
|
||||
Configure in Settings
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<TriggerPopover
|
||||
cronSchedule={cronSchedule}
|
||||
onCronChange={setCronSchedule}
|
||||
webhookToken={webhookToken}
|
||||
workflowId={id}
|
||||
/>
|
||||
<div className="h-5 w-px bg-border" />
|
||||
<Button variant="outline" size="sm" onClick={() => handleRun("debug")} disabled={running}>
|
||||
<Bug className="h-3.5 w-3.5 mr-1" />
|
||||
Debug
|
||||
@@ -532,8 +518,19 @@ function WorkflowEditorInner() {
|
||||
{/* Left: Node palette */}
|
||||
<NodePalette />
|
||||
|
||||
{/* API Key gate — shown instead of canvas when key is missing */}
|
||||
{hasApiKey === false && (
|
||||
<ApiKeyGate
|
||||
title="Workflow API Key Required"
|
||||
description="Workflows need a dedicated Immich API key to execute actions like creating albums, favoriting assets, and managing tags. Generate one automatically or configure it in Settings."
|
||||
permissions={WORKFLOW_PERMISSIONS}
|
||||
generateEndpoint="/api/settings/generate-workflow-api-key"
|
||||
onGenerated={() => setHasApiKey(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Center: Canvas */}
|
||||
<div className="flex-1" ref={reactFlowWrapper}>
|
||||
<div className={`flex-1 ${hasApiKey === false ? "hidden" : ""}`} ref={reactFlowWrapper}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
@@ -556,9 +553,11 @@ function WorkflowEditorInner() {
|
||||
</div>
|
||||
|
||||
{/* Right: Config panel */}
|
||||
<div className="w-72 min-w-[288px] border-l bg-background overflow-y-auto h-full">
|
||||
{renderConfigPanel()}
|
||||
</div>
|
||||
{hasApiKey !== false && (
|
||||
<div className="w-72 min-w-[288px] border-l bg-background overflow-y-auto h-full">
|
||||
{renderConfigPanel()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Runs panel */}
|
||||
|
||||
@@ -101,6 +101,14 @@
|
||||
}
|
||||
|
||||
|
||||
.react-photo-album--photo a[href] {
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.react-photo-album--photo:hover a[href] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.leaflet-tile-pane {
|
||||
z-index: 1 !important;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user