Merge pull request #251 from immich-power-tools/feat/smart-auto-albums

Design and UI fixes
This commit is contained in:
Varun Raj Manoharan authored and GitHub committed 2026-04-08 20:23:47 +05:30
commit aa563e2ac2
18 files changed
+890 -470

No files matched your search

+4 -4
View File
@@ -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>
+52 -10
View File
@@ -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>
);
+110 -71
View File
@@ -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>
);
}
+140 -109
View File
@@ -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>
+83
View File
@@ -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>
);
}
+78 -29
View File
@@ -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}
+11 -2
View File
@@ -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>
+5
View File
@@ -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>
+17 -3
View File
@@ -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>
)
+2 -1
View File
@@ -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" },
],
},
{
+18 -2
View File
@@ -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 });
}
+32 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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 */}
+8
View File
@@ -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;
}