From e0dd64a95ebef88af68a9402bd1caa189d23b377 Mon Sep 17 00:00:00 2001 From: Varun Date: Wed, 8 Apr 2026 08:48:28 +0530 Subject: [PATCH 1/6] feat: sidebar beta badge for workflows, settings and profile improvements Add beta badge to Workflows in the sidebar. Include settings page improvements, workflow detail updates, profile info and sidebar nav refinements, and validate-api-key endpoint. --- src/components/shared/Sidebar.tsx | 5 +++++ src/config/constants/sidebarNavs.tsx | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) 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/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" }, ], }, { From 6a5b2b442a5eba550ede224ee2bd41ab9228a1bd Mon Sep 17 00:00:00 2001 From: Varun Date: Wed, 8 Apr 2026 09:22:55 +0530 Subject: [PATCH 2/6] feat: asset delete in preview, workflow api key auto-generate, UI polish Add delete with confirm dialog to asset lightbox (always visible, handles deletion internally with local state update). Force dark theme on asset info panel and fix text color inheritance. Add edge-to-edge image display in lightbox by removing slide padding. Show open-in-immich link on hover only. Add workflow page title breadcrumb. Add one-click workflow API key generation that creates the key in Immich and saves it automatically. --- src/components/asset-info/AssetInfoPanel.tsx | 8 +- src/components/shared/AssetGrid.tsx | 107 +++++++++++++----- .../api/settings/generate-workflow-api-key.ts | 53 +++++++++ src/pages/workflows/[id].tsx | 35 +++++- src/styles/globals.scss | 8 ++ 5 files changed, 175 insertions(+), 36 deletions(-) create mode 100644 src/pages/api/settings/generate-workflow-api-key.ts 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/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 + + + + + + ({})); + 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/workflows/[id].tsx b/src/pages/workflows/[id].tsx index 1a1a956..ba09b1e 100644 --- a/src/pages/workflows/[id].tsx +++ b/src/pages/workflows/[id].tsx @@ -30,7 +30,8 @@ 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, TriangleAlert, Wand2 } from "lucide-react"; +import Head from "next/head"; import { format } from "date-fns"; import Link from "next/link"; import Loader from "@/components/ui/loader"; @@ -231,6 +232,7 @@ function WorkflowEditorInner() { const [showRuns, setShowRuns] = useState(false); const [expandedRunId, setExpandedRunId] = useState(null); const [hasApiKey, setHasApiKey] = useState(null); + const [generatingKey, setGeneratingKey] = useState(false); const [nodes, setNodes, onNodesChange] = useNodesState([] as Node[]); const [edges, setEdges, onEdgesChange] = useEdgesState([] as Edge[]); @@ -259,6 +261,19 @@ 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]); @@ -421,11 +436,16 @@ function WorkflowEditorInner() { return (
+ + {workflowName ? `${workflowName} — Workflow` : "Workflow"} + {/* Top bar */}
+ Workflow + / - tag.create — Create and assign tags
+ -
diff --git a/src/styles/globals.scss b/src/styles/globals.scss index f14c264..6c4161f 100644 --- a/src/styles/globals.scss +++ b/src/styles/globals.scss @@ -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; } From e47e99ccfc5810323a296e8c8fe245bb399ef7cb Mon Sep 17 00:00:00 2001 From: Varun Date: Wed, 8 Apr 2026 09:53:09 +0530 Subject: [PATCH 3/6] 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) --- src/components/people/PeopleList.tsx | 62 ++++- src/components/people/PersonBirthdayCell.tsx | 181 ++++++++------ src/components/people/PersonItem.tsx | 235 ++++++++++-------- src/components/shared/ApiKeyGate.tsx | 83 +++++++ src/components/ui/autocomplete.tsx | 20 +- src/pages/api/import-jobs/index.ts | 20 +- .../api/settings/generate-import-api-key.ts | 51 ++++ src/pages/assets/import-shared.tsx | 34 ++- src/pages/settings/index.tsx | 111 ++++++++- src/pages/workflows/[id].tsx | 95 +++---- 10 files changed, 626 insertions(+), 266 deletions(-) create mode 100644 src/components/shared/ApiKeyGate.tsx create mode 100644 src/pages/api/settings/generate-import-api-key.ts 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..ac59872 100644 --- a/src/components/people/PersonItem.tsx +++ b/src/components/people/PersonItem.tsx @@ -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 (
-
- -
- {person.assetCount} Assets + {/* Image area */} +
+ {person.thumbnailPath ? ( + {formData.name + ) : ( +
+ ? +
+ )} + + {/* Gradient overlay — always visible at bottom */} +
+ + {/* Asset count — bottom of image */} +
+ + {formData.isHidden && Hidden ·} + {person.assetCount} assets +
-
- - - - - - -
-
- - - + + {/* Action row — slides in from top on hover */} +
+ {/* Left: immich + info links */} +
+ e.stopPropagation()} + > + + + e.stopPropagation()} + > + + +
+ + {/* Right: hide + share + merge */} +
+ + svg]:h-[13px] [&>svg]:w-[13px]", + }} + /> + +
- {!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-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()} + /> + )} +
@@ -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); }} />
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/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/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/assets/import-shared.tsx b/src/pages/assets/import-shared.tsx index 3fb6054..3a820b3 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"; @@ -152,6 +162,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, @@ -440,8 +459,19 @@ export default function ImportSharedPage() { } /> + {/* ── API key gate ── */} + {hasImportApiKey === false && ( + setHasImportApiKey(true)} + /> + )} + {/* ── Landing state ── */} - {!sharedData && ( + {hasImportApiKey !== false && !sharedData && (
{permissions?.canUpload === false && ( @@ -552,7 +582,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..32f8541 100644 --- a/src/pages/settings/index.tsx +++ b/src/pages/settings/index.tsx @@ -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() {
+ {/* Import API Key */} +
+
+ +

Import API Key

+ {importApiKeySaved ? ( + + ) : ( + + )} +
+
+

+ 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 + + + )} +
+
+
+ {/* Managed API Keys */}
diff --git a/src/pages/workflows/[id].tsx b/src/pages/workflows/[id].tsx index ba09b1e..bdef677 100644 --- a/src/pages/workflows/[id].tsx +++ b/src/pages/workflows/[id].tsx @@ -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(null); const [hasApiKey, setHasApiKey] = useState(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() { - {/* 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
  • -
-
- - - - -
-
-
- )}
- -
- -
- 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.

- )} -
- -
@@ -459,36 +490,14 @@ function WorkflowEditorInner() { }} />
-
- - {/* Schedule config */} - - - {/* Webhook URL */} - - - - - -
- - -

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

-
-
-
- -
+ +
- {/* Managed API Keys */} -
-
- -

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).

-
- ) : ( -
    - {keys.map((key) => ( -
  • -
    -
    - -
    -
    -
    - {key.keyName} - {key.purpose} -
    - - Created {new Date(key.createdAt).toLocaleDateString()} - -
    -
    -
    - handleRegenerate(key.purpose)} - > - - - handleDelete(key.purpose)} - > - - -
    -
  • - ))} -
- )} -
-
); From 0ede3927f8ebec1b743b191dff1421f92ccd545f Mon Sep 17 00:00:00 2001 From: Varun Date: Wed, 8 Apr 2026 13:34:31 +0530 Subject: [PATCH 6/6] feat: people card redesign - quick hide button, dropdown menu, grey border --- src/components/people/PersonItem.tsx | 122 ++++++++++-------- src/components/people/PersonMergeDropdown.tsx | 4 +- src/components/shared/ShareAssetsTrigger.tsx | 13 +- 3 files changed, 83 insertions(+), 56 deletions(-) diff --git a/src/components/people/PersonItem.tsx b/src/components/people/PersonItem.tsx index ac59872..73660a4 100644 --- a/src/components/people/PersonItem.tsx +++ b/src/components/people/PersonItem.tsx @@ -5,12 +5,20 @@ import { PersonMergeDropdown } from "./PersonMergeDropdown"; import PersonBirthdayCell from "./PersonBirthdayCell"; import clsx from "clsx"; import Link from "next/link"; -import { ArrowUpRight, Eye, EyeOff, 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 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; @@ -62,7 +70,7 @@ export default function PersonItem({ person, onRemove }: IProps) { "hover:shadow-lg hover:border-border/80", { "opacity-40": formData.isHidden, - "border-blue-500/60 shadow-blue-500/10 shadow-md": formData.name, + "border-border shadow-sm": formData.name, "border-border/40": !formData.name, } )} @@ -82,75 +90,83 @@ export default function PersonItem({ person, onRemove }: IProps) { )} {/* Gradient overlay — always visible at bottom */} -
+
- {/* Asset count — bottom of image */} -
- - {formData.isHidden && Hidden ·} - {person.assetCount} assets + {/* Asset count — bottom-left of image */} +
+ + {formData.isHidden && Hidden ·} + {person.assetCount}
- {/* Action row — slides in from top on hover */} -
- {/* Left: immich + info links */} -
- e.stopPropagation()} - > - - - e.stopPropagation()} - > - - -
+ {/* Three-dot menu — top-right, visible on hover */} +
+ + + + + + + + + View details + + + + + + Open in Immich + + + + + + Share assets + + + +
- {/* Right: hide + share + merge */} -
+ {/* Quick actions — bottom-right, visible on hover */} +
+ - svg]:h-[13px] [&>svg]:w-[13px]", - }} - /> - -
+ + + + } + /> + +
{/* Name + birthday section */} -
+
{!editMode ? ( ) : ( @@ -161,7 +177,7 @@ export default function PersonItem({ person, onRemove }: IProps) { ) } type="text" - className="text-sm font-semibold w-full px-1.5 py-0.5 rounded-md" + className="text-xs font-semibold w-full px-1 py-0.5 rounded-md" defaultValue={formData.name} placeholder="Enter name" autoFocus 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/ShareAssetsTrigger.tsx b/src/components/shared/ShareAssetsTrigger.tsx index 366658a..5479230 100644 --- a/src/components/shared/ShareAssetsTrigger.tsx +++ b/src/components/shared/ShareAssetsTrigger.tsx @@ -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(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} + + ) : ( + + )}