feat: import API key, people card redesign, birthday popover, autocomplete portal
- Add import_api_key setting with auto-generate endpoint and settings UI - Import job runner uses import API key when available, falls back to session - Show ApiKeyGate empty state in both workflow and import pages when key missing - Replace workflow API key warning popover with full gate overlay - Redesign person cards: square avatar, hover action toolbar, cleaner layout - Birthday field now uses popover with calendar + natural language input - Fix autocomplete dropdown clipping inside overflow-hidden cards via portal - People grid column slider persisted to localStorage (min 3, max 20)
This commit is contained in:
1 parent
6a5b2b442a
commit
e47e99ccfc
10 files changed
+626
-266
No files matched your search
@@ -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,22 @@
|
||||
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, Info } 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";
|
||||
|
||||
interface IProps {
|
||||
person: IPerson;
|
||||
onRemove: (person: IPerson) => void;
|
||||
}
|
||||
|
||||
export default function PersonItem({ person, onRemove }: IProps) {
|
||||
const { exImmichUrl } = useConfig();
|
||||
const { toast } = useToast();
|
||||
@@ -30,26 +29,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 +45,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 +58,125 @@ 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-blue-500/60 shadow-blue-500/10 shadow-md": 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-16 bg-gradient-to-t from-black/70 to-transparent pointer-events-none" />
|
||||
|
||||
{/* Asset count — bottom of image */}
|
||||
<div className="absolute bottom-2 left-0 right-0 flex justify-center pointer-events-none">
|
||||
<span className="text-[11px] font-medium text-white/90 tabular-nums tracking-wide">
|
||||
{formData.isHidden && <span className="mr-1 opacity-70">Hidden ·</span>}
|
||||
{person.assetCount} assets
|
||||
</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>
|
||||
</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" }} />
|
||||
|
||||
{/* Action row — slides in from top on hover */}
|
||||
<div className="absolute top-0 inset-x-0 flex items-center justify-between p-1.5 translate-y-[-100%] group-hover:translate-y-0 transition-transform duration-200 bg-gradient-to-b from-black/60 to-transparent">
|
||||
{/* Left: immich + info links */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
href={`${exImmichUrl}/people/${person.id}`}
|
||||
target="_blank"
|
||||
title="Open in Immich"
|
||||
className="p-1 rounded-md bg-white/10 hover:bg-white/25 text-white transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ArrowUpRight size={13} />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/people/${person.id}`}
|
||||
title="View details"
|
||||
className="p-1 rounded-md bg-white/10 hover:bg-white/25 text-white transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Info size={13} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Right: hide + share + merge */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
title={formData.isHidden ? "Show person" : "Hide person"}
|
||||
disabled={loading}
|
||||
onClick={() => handleHide(!formData.isHidden)}
|
||||
className="p-1 rounded-md bg-white/10 hover:bg-white/25 text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{formData.isHidden ? <Eye size={13} /> : <EyeOff size={13} />}
|
||||
</button>
|
||||
<ShareAssetsTrigger
|
||||
filters={{ personIds: [person.id] }}
|
||||
buttonProps={{
|
||||
variant: "ghost",
|
||||
className: "!p-1 !h-auto !w-auto rounded-md bg-white/10 hover:bg-white/25 text-white hover:text-white [&>svg]:h-[13px] [&>svg]:w-[13px]",
|
||||
}}
|
||||
/>
|
||||
<PersonMergeDropdown
|
||||
person={person}
|
||||
onRemove={onRemove}
|
||||
triggerClassName="!p-1 !h-auto !w-auto rounded-md !bg-white/10 hover:!bg-white/25 !text-white !border-transparent text-[10px] leading-none"
|
||||
/>
|
||||
</div>
|
||||
</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-2 pt-2 pb-2 flex flex-col gap-1.5">
|
||||
{!editMode ? (
|
||||
<button
|
||||
className="text-left w-full text-sm 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-xs">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-sm font-semibold w-full px-1.5 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 +185,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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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";
|
||||
@@ -152,6 +162,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,
|
||||
@@ -440,8 +459,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 && (
|
||||
@@ -552,7 +582,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 */}
|
||||
|
||||
@@ -8,7 +8,7 @@ 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,
|
||||
Key, Workflow, Shield, CheckCircle, XCircle, Server, Globe, Download, Wand2,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -60,6 +60,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 +76,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
|
||||
@@ -123,6 +163,7 @@ export default function SettingsPage() {
|
||||
useEffect(() => {
|
||||
fetchKeys();
|
||||
fetchWorkflowApiKey();
|
||||
fetchImportApiKey();
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (purpose: string) => {
|
||||
@@ -265,6 +306,74 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Import API Key */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<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" />
|
||||
) : (
|
||||
<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>
|
||||
|
||||
{/* Managed API Keys */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
|
||||
@@ -30,12 +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, Wand2 } 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);
|
||||
@@ -232,7 +242,6 @@ function WorkflowEditorInner() {
|
||||
const [showRuns, setShowRuns] = useState(false);
|
||||
const [expandedRunId, setExpandedRunId] = useState<string | null>(null);
|
||||
const [hasApiKey, setHasApiKey] = useState<boolean | null>(null);
|
||||
const [generatingKey, setGeneratingKey] = useState(false);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState([] as Node[]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([] as Edge[]);
|
||||
@@ -261,19 +270,6 @@ function WorkflowEditorInner() {
|
||||
.catch(() => setHasApiKey(false));
|
||||
}, [id]);
|
||||
|
||||
const handleGenerateApiKey = async () => {
|
||||
setGeneratingKey(true);
|
||||
try {
|
||||
await API.post("/api/settings/generate-workflow-api-key", {});
|
||||
setHasApiKey(true);
|
||||
hotToast.success("Workflow API key created and saved");
|
||||
} catch (err: any) {
|
||||
hotToast.error(err?.message ?? "Failed to create API key");
|
||||
} finally {
|
||||
setGeneratingKey(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onConnect = useCallback((connection: Connection) => {
|
||||
setEdges((eds) => addEdge({ ...connection, animated: true }, eds));
|
||||
}, [setEdges]);
|
||||
@@ -491,54 +487,6 @@ function WorkflowEditorInner() {
|
||||
</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>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full h-7 text-xs"
|
||||
onClick={handleGenerateApiKey}
|
||||
disabled={generatingKey}
|
||||
>
|
||||
<Wand2 className="h-3 w-3 mr-1" />
|
||||
{generatingKey ? "Creating..." : "Generate automatically"}
|
||||
</Button>
|
||||
<Link href="/settings" className="block">
|
||||
<Button size="sm" variant="outline" className="w-full h-7 text-xs">
|
||||
Configure manually in Settings
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => handleRun("debug")} disabled={running}>
|
||||
@@ -561,8 +509,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}
|
||||
@@ -585,9 +544,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 */}
|
||||
|
||||
Reference in new issue
Block a user