diff --git a/src/components/asset-info/AssetInfoPanel.tsx b/src/components/asset-info/AssetInfoPanel.tsx index c395082..1b935f2 100644 --- a/src/components/asset-info/AssetInfoPanel.tsx +++ b/src/components/asset-info/AssetInfoPanel.tsx @@ -142,7 +142,7 @@ function PersonChip({ person: p, exImmichUrl, isHidden: hidden, onUpdate }: Pers )} - {p.personName || 'Unknown'} + {p.personName || 'Unknown'} @@ -262,7 +262,7 @@ export default function AssetInfoPanel({ assetId }: AssetInfoPanelProps) { if (loading) { return ( -
+

Info

@@ -275,7 +275,7 @@ export default function AssetInfoPanel({ assetId }: AssetInfoPanelProps) { if (!detail) { return ( -
+

Info

Unable to load asset details.

@@ -296,7 +296,7 @@ export default function AssetInfoPanel({ assetId }: AssetInfoPanelProps) { : null return ( -
+
{/* Header */}

Info

diff --git a/src/components/people/PeopleList.tsx b/src/components/people/PeopleList.tsx index f031000..a7e9e57 100644 --- a/src/components/people/PeopleList.tsx +++ b/src/components/people/PeopleList.tsx @@ -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(null); + const [cols, setCols] = useState(6); const [filters, setFilters] = useState({ ...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 ; - if (errorMessage) return
{errorMessage}
; + if (errorMessage) return
{errorMessage}
; + if (!people.length) return ( +
+ +

No people found

+
+ ); return ( -
+
{people.map((person) => ( - + ))}
); }; + return ( ({ ...prev, ...newConfig })), }} > - +
} /> {renderContent()} + + {/* Grid size slider — fixed bottom-left */} +
+ + 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`} + /> + {cols} +
); diff --git a/src/components/people/PersonBirthdayCell.tsx b/src/components/people/PersonBirthdayCell.tsx index b6c848f..96d6bfc 100644 --- a/src/components/people/PersonBirthdayCell.tsx +++ b/src/components/people/PersonBirthdayCell.tsx @@ -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(null); const normalizedBirthDate = useMemo(() => normalizeDateOnly(person.birthDate), [person.birthDate]); const [selectedDate, setSelectedDate] = useState(normalizedBirthDate); - const [textDate, setTextDate] = useState(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 ( -
- { - if (e.key === 'Enter') { - const parsedDate = chrono.parseDate(textDate || ""); - if (parsedDate) { - handleEdit(parsedDate); - } else { - handleEdit(null); - } - } - }} - disabled={loading} - onChange={(e) => setTextDate(e.target.value)} - /> - -
- ) + { setOpen(v); if (v) setTextInput(''); }}> + + + + +
+

Type a date or pick from calendar

+
+ setTextInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleTextCommit(); + if (e.key === 'Escape') setOpen(false); + }} + autoFocus + disabled={loading} + /> + +
+
+ +
+
+ ); } diff --git a/src/components/people/PersonItem.tsx b/src/components/people/PersonItem.tsx index d444b10..73660a4 100644 --- a/src/components/people/PersonItem.tsx +++ b/src/components/people/PersonItem.tsx @@ -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 (
-
- -
- {person.assetCount} Assets + {/* Image area */} +
+ {person.thumbnailPath ? ( + {formData.name + ) : ( +
+ ? +
+ )} + + {/* Gradient overlay — always visible at bottom */} +
+ + {/* Asset count — bottom-left of image */} +
+ + {formData.isHidden && Hidden ·} + {person.assetCount} +
-
- - - - - - + + {/* Three-dot menu — top-right, visible on hover */} +
+ + + + + + + + + View details + + + + + + Open in Immich + + + + + + Share assets + + +
-
- - - + + {/* Quick actions — bottom-right, visible on hover */} +
+ + + + + + } + /> + +
- {!editMode ? ( -

{ - setEditMode((prev) => !prev); - }} - > - {formData.name ? ( - formData.name - ) : ( - Unknown - )} -

- ) : ( - 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(); - }} - /> - )} -
+ + {/* Name + birthday section */} +
+ {!editMode ? ( + + ) : ( + + 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()} + /> + )} +
@@ -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); }} />
diff --git a/src/components/people/PersonMergeDropdown.tsx b/src/components/people/PersonMergeDropdown.tsx index a0c5eb6..a5904d6 100644 --- a/src/components/people/PersonMergeDropdown.tsx +++ b/src/components/people/PersonMergeDropdown.tsx @@ -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(null); const [similarPeople, setSimilarPeople] = useState([]); @@ -243,7 +245,7 @@ export function PersonMergeDropdown({ variant="outline" className={cn("!py-0.5 !px-2 text-xs h-7", triggerClassName)} > - Merge + {triggerIcon ?? "Merge"} diff --git a/src/components/shared/ApiKeyGate.tsx b/src/components/shared/ApiKeyGate.tsx new file mode 100644 index 0000000..844384e --- /dev/null +++ b/src/components/shared/ApiKeyGate.tsx @@ -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(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 ( +
+
+
+ +
+ +
+

{title}

+

{description}

+
+ +
+

Required permissions

+
+ {permissions.map((p) => ( + + {p.name} + + ))} +
+
+ + {error && ( +

{error}

+ )} + +
+ + + + +
+
+
+ ); +} diff --git a/src/components/shared/AssetGrid.tsx b/src/components/shared/AssetGrid.tsx index ddcc6fd..f8bb084 100644 --- a/src/components/shared/AssetGrid.tsx +++ b/src/components/shared/AssetGrid.tsx @@ -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(({ assets, isInternal return false }); const [favoriteIds, setFavoriteIds] = useState>(() => new Set(assets.filter(a => a.isFavorite).map(a => a.id))); + const [deletedIds, setDeletedIds] = useState>(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(({ 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(({ 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(({ assets, isInternal > , - ...(onDeleteAsset ? [ - , - ] : []), + , ]; }, [currentAsset, showInfoPanel, onDeleteAsset, onFavoriteAsset, handleOpenInImmich, favoriteIds, handleToggleFavorite]); @@ -280,7 +290,7 @@ const AssetGrid = forwardRef(({ assets, isInternal render={{ slideContainer: ({ children }) => (
-
+
{children}
{showInfoPanel && currentAsset && ( @@ -295,8 +305,47 @@ const AssetGrid = forwardRef(({ assets, isInternal container: { backgroundColor: "rgba(0, 0, 0, 0.95)", }, + slide: { + padding: 0, + }, }} /> + + + + + + Delete asset? + + This will permanently delete{currentAsset ? ` "${currentAsset.originalFileName}"` : " this asset"}. This action cannot be undone. + + + + setDeleteConfirmOpen(false)}>Cancel + { + 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 + + + + + + (null); const [loading, setLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(null); @@ -91,7 +94,13 @@ export default function ShareAssetsTrigger({ filters, buttonProps }: ShareAssets return ( { if (open) checkShareKey(); }}> - + {asDropdownItem ? ( + e.preventDefault()} className="gap-2"> + {children} + + ) : ( + + )} diff --git a/src/components/shared/Sidebar.tsx b/src/components/shared/Sidebar.tsx index 6e4c0e2..f7d2493 100644 --- a/src/components/shared/Sidebar.tsx +++ b/src/components/shared/Sidebar.tsx @@ -48,6 +48,11 @@ export default function Sidebar() { > {nav.icon} {nav.title} + {nav.badge && ( + + {nav.badge} + + )} ))}
diff --git a/src/components/ui/autocomplete.tsx b/src/components/ui/autocomplete.tsx index d068bb1..0da4000 100644 --- a/src/components/ui/autocomplete.tsx +++ b/src/components/ui/autocomplete.tsx @@ -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( const [options, setOptions] = React.useState(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(null) const inputRef = React.useRef(null) const debounceTimeout = React.useRef(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( }} value={inputValue} onFocus={() => { + updateDropdownRect() setOpen(true) setSelectedIndex(showCreateNew ? 0 : -1) }} @@ -194,8 +204,11 @@ const Autocomplete = React.forwardRef( props.onChange?.(e) }} /> - {open && (filteredOptions.length > 0 || isLoading || showCreateNew) && ( -
+ {open && (filteredOptions.length > 0 || isLoading || showCreateNew) && dropdownRect && typeof document !== "undefined" && ReactDOM.createPortal( +
{isLoading ? (
Loading... @@ -241,7 +254,8 @@ const Autocomplete = React.forwardRef( ))} )} -
+
, + document.body )}
) diff --git a/src/config/constants/sidebarNavs.tsx b/src/config/constants/sidebarNavs.tsx index 6f66082..b511a7e 100644 --- a/src/config/constants/sidebarNavs.tsx +++ b/src/config/constants/sidebarNavs.tsx @@ -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: }, + { title: "Workflows", link: "/workflows", icon: , badge: "Beta" }, ], }, { diff --git a/src/pages/api/import-jobs/index.ts b/src/pages/api/import-jobs/index.ts index b0c6590..eb78589 100644 --- a/src/pages/api/import-jobs/index.ts +++ b/src/pages/api/import-jobs/index.ts @@ -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; + // 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; + if (importKeyRow?.value) { + headers = { "x-api-key": importKeyRow.value, "Content-Type": "application/json" }; + } else { + headers = getUserHeaders(currentUser) as Record; + } + runImportJob(job.id, headers).catch((err) => console.error("Unhandled error in runImportJob", err) ); diff --git a/src/pages/api/settings/generate-import-api-key.ts b/src/pages/api/settings/generate-import-api-key.ts new file mode 100644 index 0000000..df97ea5 --- /dev/null +++ b/src/pages/api/settings/generate-import-api-key.ts @@ -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 }); +} diff --git a/src/pages/api/settings/generate-workflow-api-key.ts b/src/pages/api/settings/generate-workflow-api-key.ts new file mode 100644 index 0000000..e5cdd8f --- /dev/null +++ b/src/pages/api/settings/generate-workflow-api-key.ts @@ -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 }); +} diff --git a/src/pages/assets/import-shared.tsx b/src/pages/assets/import-shared.tsx index e6ddb98..14c4301 100644 --- a/src/pages/assets/import-shared.tsx +++ b/src/pages/assets/import-shared.tsx @@ -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(null); + const [hasImportApiKey, setHasImportApiKey] = useState(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 && ( + setHasImportApiKey(true)} + /> + )} + {/* ── Landing state ── */} - {!sharedData && ( + {hasImportApiKey !== false && !sharedData && (
{permissions?.canUpload === false && ( @@ -586,7 +616,7 @@ export default function ImportSharedPage() { )} {/* ── Album detail + gallery view ── */} - {sharedData && albumDetails && sharedLinkDetails && ( + {hasImportApiKey !== false && sharedData && albumDetails && sharedLinkDetails && ( <>
{/* Upload banner */} diff --git a/src/pages/settings/index.tsx b/src/pages/settings/index.tsx index 6440e17..164f1bd 100644 --- a/src/pages/settings/index.tsx +++ b/src/pages/settings/index.tsx @@ -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 (
@@ -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([]); - const [loading, setLoading] = useState(true); - const [deleting, setDeleting] = useState(null); - const [regenerating, setRegenerating] = useState(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 (
@@ -265,78 +257,74 @@ export default function SettingsPage() {
- {/* Managed API Keys */} + {/* Import API Key */}
- -

Managed API Keys

- {keys.length} key{keys.length !== 1 ? "s" : ""} -
-
- {loading ? ( -
-

Loading...

-
- ) : keys.length === 0 ? ( -
- -

No managed API keys yet.

-

Keys are created automatically when needed (e.g. share links).

-
+ +

Import API Key

+ {importApiKeySaved ? ( + ) : ( -
    - {keys.map((key) => ( -
  • -
    -
    - -
    -
    -
    - {key.keyName} - {key.purpose} -
    - - Created {new Date(key.createdAt).toLocaleDateString()} - -
    -
    -
    - handleRegenerate(key.purpose)} - > - - - handleDelete(key.purpose)} - > - - -
    -
  • - ))} -
+ )}
+
+

+ Immich API key used when importing from shared links. Needs asset upload, album, and tag permissions. +

+
+
+ setImportApiKey(e.target.value)} + /> + +
+ +
+
+ {["asset.read", "asset.upload", "album.read", "album.create", "album.update", "tag.create"].map((p) => ( + {p} + ))} +
+
+ + {exImmichUrl && ( + + Create in Immich + + + )} +
+
+
); diff --git a/src/pages/workflows/[id].tsx b/src/pages/workflows/[id].tsx index 1a1a956..b3f86d5 100644 --- a/src/pages/workflows/[id].tsx +++ b/src/pages/workflows/[id].tsx @@ -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 ( - - -
- -
- setEvery(parseInt(e.target.value) || 0)} - placeholder="0" - /> - + +
+ {/* Schedule section */} +
+
+ + Schedule + {hasSchedule && {cronSchedule}} +
+
+ Every + setEvery(parseInt(e.target.value) || 0)} + placeholder="—" + /> + +
+
+ + +
- {every > 0 && ( -

- {intervalOptions.find((o) => o.value === interval)?.cron(every)} + +

+ + {/* Webhook section */} +
+
+ + Webhook +
+ +

+ Configure this URL in Immich admin webhook settings to trigger on events.

- )} -
- -
@@ -421,11 +463,16 @@ function WorkflowEditorInner() { return (
+ + {workflowName ? `${workflowName} — Workflow` : "Workflow"} + {/* Top bar */}
+ Workflow + /
-
- - {/* Schedule config */} - - - {/* Webhook URL */} - - - - - -
- - -

- Configure this URL in Immich admin webhook settings to trigger this workflow on events. -

-
-
-
- - {/* API Key warning */} - {hasApiKey === false && ( - - - - - -
-
- -
-

Workflow API Key Not Configured

-

- Workflows need a dedicated Immich API key to execute actions like creating albums, favoriting assets, and managing tags. -

-
-
-
-

Required Permissions:

-
    -
  • - asset.read — Query and filter assets
  • -
  • - asset.update — Favorite, archive, update metadata
  • -
  • - album.create — Create new albums
  • -
  • - album.update — Add/remove assets from albums
  • -
  • - tag.create — Create and assign tags
  • -
-
- - - -
-
-
- )} -
+ +