From e8e65e7172fa6eac2e28a3d4bc088b18c69be081 Mon Sep 17 00:00:00 2001 From: ralksta Date: Fri, 29 May 2026 17:59:25 +0200 Subject: [PATCH] feat(admin): add asset picker with favorites, drag-and-drop reordering, preview links, and keyboard shortcuts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Asset Picker: Visual browser for hero images with Favorites tab (shown first) and All Photos tab. Includes infinite scroll and UUID fallback input for assets not easily found in the grid. - Drag & Drop: All lists (hero images, standalone albums, subpage albums, subpage tiles) are now reorderable via drag handles using @dnd-kit. - Preview Links: 'Preview Site' button in save bar, '↗ Site' in header, and per-subpage preview links with URL slug preview shown inline. - Keyboard Shortcuts: ⌘+S / Ctrl+S to save in both Pages and Settings tabs. - Unsaved Changes Guard: beforeunload warning prevents accidental data loss. - New API route /api/admin/assets for browsing Immich assets with pagination and favorites filter via search/metadata endpoint. --- app/admin/admin.css | 239 +++++++++ app/admin/components/AdminDashboard.tsx | 9 + app/admin/components/AssetPicker.tsx | 192 ++++++++ app/admin/components/PageBuilder.tsx | 630 +++++++++++++++++------- app/admin/components/SettingsEditor.tsx | 33 +- app/api/admin/assets/route.ts | 87 ++++ package-lock.json | 56 +++ package.json | 3 + 8 files changed, 1075 insertions(+), 174 deletions(-) create mode 100644 app/admin/components/AssetPicker.tsx create mode 100644 app/api/admin/assets/route.ts diff --git a/app/admin/admin.css b/app/admin/admin.css index 0e93fbe..5a6948d 100644 --- a/app/admin/admin.css +++ b/app/admin/admin.css @@ -1120,6 +1120,245 @@ cursor: pointer; } +/* ── Asset Picker (Hero) ───────────────────────────────────── */ + +.asset-picker-modal { + width: 100%; + max-width: 720px; + max-height: 85vh; + background: var(--admin-bg-raised); + border: 1px solid var(--admin-border); + border-radius: 12px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.asset-picker-tabs { + display: flex; + border-bottom: 1px solid var(--admin-border); +} + +.asset-picker-tab { + flex: 1; + padding: 0.7rem 1rem; + background: none; + border: none; + color: var(--admin-text-muted); + font-size: 0.85rem; + cursor: pointer; + transition: all 0.15s; + border-bottom: 2px solid transparent; +} + +.asset-picker-tab:hover { + color: var(--admin-text-secondary); + background: var(--admin-bg-elevated); +} + +.asset-picker-tab.active { + color: var(--admin-text); + border-bottom-color: var(--admin-accent); +} + +.asset-picker-grid { + flex: 1; + overflow-y: auto; + padding: 0.75rem; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 0.5rem; + align-content: start; +} + +.asset-picker-loading { + grid-column: 1 / -1; + display: flex; + align-items: center; + justify-content: center; + padding: 3rem; +} + +.asset-picker-loading-more { + grid-column: 1 / -1; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.asset-picker-tile { + position: relative; + aspect-ratio: 1; + border-radius: 8px; + overflow: hidden; + cursor: pointer; + background: var(--admin-bg-elevated); + border: 2px solid transparent; + transition: border-color 0.15s, transform 0.1s; +} + +.asset-picker-tile:hover:not(.used) { + border-color: var(--admin-accent); + transform: scale(1.03); +} + +.asset-picker-tile.used { + opacity: 0.4; + cursor: not-allowed; +} + +.asset-picker-tile img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.asset-picker-used-badge { + position: absolute; + top: 4px; + right: 4px; + width: 22px; + height: 22px; + border-radius: 50%; + background: var(--admin-accent); + color: #000; + font-size: 0.7rem; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; +} + +.asset-picker-fav-badge { + position: absolute; + top: 4px; + left: 4px; + font-size: 0.7rem; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6); +} + +.asset-picker-uuid-section { + padding: 0.75rem 1rem; + border-top: 1px solid var(--admin-border); + background: var(--admin-bg); +} + +.asset-picker-uuid-form { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.asset-picker-uuid-input { + flex: 1; + padding: 0.5rem 0.75rem; + background: var(--admin-bg-input); + border: 1px solid var(--admin-border); + border-radius: 6px; + color: var(--admin-text); + font-size: 0.8rem; + font-family: monospace; +} + +.asset-picker-uuid-input:focus { + outline: none; + border-color: var(--admin-border-focus); +} + +.asset-picker-uuid-error { + font-size: 0.7rem; + color: var(--admin-error); + margin-top: 0.3rem; + display: block; +} + +/* ── Drag Handles ──────────────────────────────────────────── */ + +.hero-tile-drag, +.album-tile-drag, +.subpage-tile-drag { + position: absolute; + z-index: 5; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.8); + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + border-radius: 4px; + cursor: grab; + opacity: 0; + transition: opacity 0.15s; + user-select: none; + touch-action: none; +} + +.hero-tile-drag:active, +.album-tile-drag:active, +.subpage-tile-drag:active { + cursor: grabbing; +} + +.hero-tile-drag { + top: 6px; + left: 6px; +} + +.album-tile-drag { + top: 6px; + left: 6px; +} + +.subpage-tile-drag { + top: 6px; + left: 6px; +} + +.hero-tile:hover .hero-tile-drag, +.album-tile:hover .album-tile-drag, +.subpage-tile:hover .subpage-tile-drag { + opacity: 1; +} + +/* ── Preview & Slug ───────────────────────────────────────────── */ + +.subpage-slug-preview { + font-size: 0.72rem; + color: var(--admin-text-muted); + font-family: monospace; + padding: 0.15rem 0.4rem; + background: var(--admin-bg); + border-radius: 3px; + white-space: nowrap; +} + +.save-bar-right { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.admin-btn-preview { + font-size: 0.8rem; + padding: 0.4rem 0.8rem; + text-decoration: none; +} + +.save-hint { + font-size: 0.7rem; + color: var(--admin-text-muted); +} + +/* ── Danger icon button ───────────────────────────────────────── */ + +.admin-btn-icon-danger:hover { + color: var(--admin-error) !important; +} + /* ── Responsive ──────────────────────────────────────────────── */ @media (max-width: 768px) { diff --git a/app/admin/components/AdminDashboard.tsx b/app/admin/components/AdminDashboard.tsx index 37bdfdd..d24422a 100644 --- a/app/admin/components/AdminDashboard.tsx +++ b/app/admin/components/AdminDashboard.tsx @@ -49,6 +49,15 @@ export default function AdminDashboard({ onLogout }: Props) {
+ + ↗ Site + +
+ + {/* Tabs */} +
+ + +
+ + {/* Asset Grid */} +
+ {loading ? ( +
+
+
+ ) : assets.length === 0 ? ( +

+ {tab === 'favorites' + ? 'No favorite photos found. Star photos in Immich first, or switch to "All Photos".' + : 'No photos found.'} +

+ ) : ( + <> + {assets.map((asset) => { + const isUsed = currentIds.has(asset.id); + return ( +
!isUsed && onSelect(asset.id)} + title={`${asset.originalFileName}\n${formatDate(asset.fileCreatedAt)}`} + > + {asset.originalFileName} + {isUsed &&
✓
} + {asset.isFavorite && !isUsed && ( +
⭐
+ )} +
+ ); + })} + {loadingMore && ( +
+
+
+ )} + + )} +
+ + {/* UUID Fallback */} +
+
+ { + setUuidInput(e.target.value); + setUuidError(''); + }} + placeholder="Or paste asset UUID directly..." + className="asset-picker-uuid-input" + /> + +
+ {uuidError && {uuidError}} +
+
+
+ ); +} diff --git a/app/admin/components/PageBuilder.tsx b/app/admin/components/PageBuilder.tsx index b4869d9..394aa28 100644 --- a/app/admin/components/PageBuilder.tsx +++ b/app/admin/components/PageBuilder.tsx @@ -1,7 +1,26 @@ 'use client'; import { useState, useEffect, useCallback } from 'react'; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, +} from '@dnd-kit/core'; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, + horizontalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; import AlbumPicker from './AlbumPicker'; +import AssetPicker from './AssetPicker'; // ── Types ────────────────────────────────────────────────────── interface AlbumEntry { @@ -79,6 +98,145 @@ function serializeAlbumEntries( }); } +// ── Sortable Hero Tile ───────────────────────────────────────── + +function SortableHeroTile({ + id, + index, + onRemove, +}: { + id: string; + index: number; + onRemove: () => void; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: `hero-${index}`, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + zIndex: isDragging ? 10 : undefined, + }; + + return ( +
+
+ ⠿ +
+ + + {index + 1} +
+ ); +} + +// ── Sortable Album Card ──────────────────────────────────────── + +function SortableAlbumCard({ + album, + index, + name, + count, + thumbnailId, + onRemove, + onUpdate, +}: { + album: AlbumEntry; + index: number; + name: string; + count: number; + thumbnailId: string | null; + onRemove: () => void; + onUpdate: (updates: Partial) => void; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: `album-${album.id}-${index}`, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + zIndex: isDragging ? 10 : undefined, + }; + + return ( +
+ +
+ ); +} + +// ── Sortable Subpage Tile ────────────────────────────────────── + +function SortableSubpageTile({ + sp, + spIndex, + isActive, + onClick, + getFirstThumb, +}: { + sp: Subpage; + spIndex: number; + isActive: boolean; + onClick: () => void; + getFirstThumb: (sp: Subpage) => string | null; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: `subpage-${spIndex}`, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + zIndex: isDragging ? 10 : undefined, + }; + + const totalAlbums = + sp.albums.length + (sp.sections?.reduce((sum, sec) => sum + sec.albums.length, 0) || 0); + const firstThumb = getFirstThumb(sp); + + return ( +
+
+ ⠿ +
+
+ {firstThumb ? ( + + ) : ( +
📂
+ )} +
+
+ {sp.title || sp.name} + + {totalAlbums} album{totalAlbums !== 1 ? 's' : ''} + +
+
+ ); +} + // ── Component ────────────────────────────────────────────────── export default function PageBuilder() { @@ -94,6 +252,13 @@ export default function PageBuilder() { subpageIndex?: number; sectionIndex?: number; } | null>(null); + const [showHeroPicker, setShowHeroPicker] = useState(false); + + // DnD sensors + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); // Load data useEffect(() => { @@ -101,6 +266,33 @@ export default function PageBuilder() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // ── Keyboard shortcut: ⌘+S / Ctrl+S ───────────────────────── + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key === 's') { + e.preventDefault(); + if (dirty && !saving) { + handleSave(); + } + } + } + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dirty, saving, gallery]); + + // ── Unsaved changes guard ──────────────────────────────────── + useEffect(() => { + function handleBeforeUnload(e: BeforeUnloadEvent) { + if (dirty) { + e.preventDefault(); + e.returnValue = ''; + } + } + window.addEventListener('beforeunload', handleBeforeUnload); + return () => window.removeEventListener('beforeunload', handleBeforeUnload); + }, [dirty]); + async function loadData() { setLoading(true); try { @@ -181,7 +373,6 @@ export default function PageBuilder() { setSaving(true); setSaveMessage(''); - // Serialize back to YAML-compatible format const yamlData: Record = {}; if (gallery.hero.length > 0) { @@ -207,7 +398,6 @@ export default function PageBuilder() { if (sec.description) s.description = sec.description; return s; }); - // Also include top-level albums if they exist alongside sections if (sp.albums.length > 0) { entry.albums = serializeAlbumEntries(sp.albums); } @@ -242,6 +432,13 @@ export default function PageBuilder() { } } + // ── Hero Picker ────────────────────────────────────────────── + function handleHeroSelect(assetId: string) { + setGallery((g) => ({ ...g, hero: [...g.hero, assetId] })); + setShowHeroPicker(false); + markDirty(); + } + // ── Album Picker Handlers ──────────────────────────────────── function handlePickAlbum(albumId: string) { if (!pickerTarget) return; @@ -280,6 +477,75 @@ export default function PageBuilder() { markDirty(); } + // ── Drag & Drop Handlers ───────────────────────────────────── + function handleHeroDragEnd(event: DragEndEvent) { + const { active, over } = event; + if (!over || active.id === over.id) return; + + const oldIndex = gallery.hero.findIndex((_, i) => `hero-${i}` === active.id); + const newIndex = gallery.hero.findIndex((_, i) => `hero-${i}` === over.id); + + if (oldIndex !== -1 && newIndex !== -1) { + setGallery((g) => ({ ...g, hero: arrayMove(g.hero, oldIndex, newIndex) })); + markDirty(); + } + } + + function handleAlbumDragEnd(event: DragEndEvent) { + const { active, over } = event; + if (!over || active.id === over.id) return; + + const oldIndex = gallery.albums.findIndex((a, i) => `album-${a.id}-${i}` === active.id); + const newIndex = gallery.albums.findIndex((a, i) => `album-${a.id}-${i}` === over.id); + + if (oldIndex !== -1 && newIndex !== -1) { + setGallery((g) => ({ ...g, albums: arrayMove(g.albums, oldIndex, newIndex) })); + markDirty(); + } + } + + function handleSubpageDragEnd(event: DragEndEvent) { + const { active, over } = event; + if (!over || active.id === over.id) return; + + const oldIndex = gallery.subpages.findIndex((_, i) => `subpage-${i}` === active.id); + const newIndex = gallery.subpages.findIndex((_, i) => `subpage-${i}` === over.id); + + if (oldIndex !== -1 && newIndex !== -1) { + setGallery((g) => ({ ...g, subpages: arrayMove(g.subpages, oldIndex, newIndex) })); + if (expandedSubpage === oldIndex) setExpandedSubpage(newIndex); + else if (expandedSubpage !== null) { + if (oldIndex < expandedSubpage && newIndex >= expandedSubpage) + setExpandedSubpage(expandedSubpage - 1); + else if (oldIndex > expandedSubpage && newIndex <= expandedSubpage) + setExpandedSubpage(expandedSubpage + 1); + } + markDirty(); + } + } + + function handleSubpageAlbumDragEnd(spIndex: number) { + return (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + + const sp = gallery.subpages[spIndex]; + const oldIndex = sp.albums.findIndex((a, i) => `album-${a.id}-${i}` === active.id); + const newIndex = sp.albums.findIndex((a, i) => `album-${a.id}-${i}` === over.id); + + if (oldIndex !== -1 && newIndex !== -1) { + setGallery((g) => { + const subpages = [...g.subpages]; + const sp2 = { ...subpages[spIndex] }; + sp2.albums = arrayMove(sp2.albums, oldIndex, newIndex); + subpages[spIndex] = sp2; + return { ...g, subpages }; + }); + markDirty(); + } + }; + } + // ── Subpage Management ──────────────────────────────────────── function addSubpage() { setGallery((g) => ({ @@ -390,26 +656,6 @@ export default function PageBuilder() { markDirty(); } - function addHero() { - const uuid = prompt('Enter Immich asset UUID for hero image:'); - if (uuid?.trim()) { - setGallery((g) => ({ ...g, hero: [...g.hero, uuid.trim()] })); - markDirty(); - } - } - - // ── Move helpers ────────────────────────────────────────────── - function moveSubpage(index: number, direction: -1 | 1) { - const newIndex = index + direction; - if (newIndex < 0 || newIndex >= gallery.subpages.length) return; - setGallery((g) => { - const subpages = [...g.subpages]; - [subpages[index], subpages[newIndex]] = [subpages[newIndex], subpages[index]]; - return { ...g, subpages }; - }); - markDirty(); - } - // ── Helpers ────────────────────────────────────────────────── function getAlbumName(id: string): string { const found = immichAlbums.find((a) => a.id === id); @@ -427,12 +673,10 @@ export default function PageBuilder() { } function getFirstSubpageThumb(sp: Subpage): string | null { - // Try albums first for (const album of sp.albums) { const thumb = getAlbumThumbnailId(album.id); if (thumb) return thumb; } - // Try sections if (sp.sections) { for (const sec of sp.sections) { for (const album of sec.albums) { @@ -444,6 +688,13 @@ export default function PageBuilder() { return null; } + function slugify(str: string): string { + return str + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + } + // ── Render ─────────────────────────────────────────────────── if (loading) { return ( @@ -466,48 +717,62 @@ export default function PageBuilder() { {saveMessage} )} + {!dirty && !saveMessage && ⌘S to save} + +
+ + ↗ Preview Site + +
- {/* Hero Section */}

🏠 Homepage Hero

-
-
- {gallery.hero.length === 0 && ( -

- No hero images configured. Add asset UUIDs to show a hero carousel. -

- )} - {gallery.hero.map((id, i) => ( -
- - - {i + 1} + + `hero-${i}`)} + strategy={horizontalListSortingStrategy} + > +
+ {gallery.hero.length === 0 && ( +

+ No hero images configured. Add photos to show a hero carousel on the homepage. +

+ )} + {gallery.hero.map((id, i) => ( + removeHero(i)} + /> + ))}
- ))} -
+ +
{/* Standalone Albums */} @@ -521,29 +786,43 @@ export default function PageBuilder() { + Add Album -
- {gallery.albums.length === 0 && ( -

No standalone albums. These show directly on the homepage.

- )} - {gallery.albums.map((album, i) => ( - removeStandaloneAlbum(i)} - onUpdate={(updates) => { - setGallery((g) => { - const albums = [...g.albums]; - albums[i] = { ...albums[i], ...updates }; - return { ...g, albums }; - }); - markDirty(); - }} - /> - ))} -
+ + `album-${a.id}-${i}`)} + strategy={verticalListSortingStrategy} + > +
+ {gallery.albums.length === 0 && ( +

+ No standalone albums. These show directly on the homepage. +

+ )} + {gallery.albums.map((album, i) => ( + removeStandaloneAlbum(i)} + onUpdate={(updates) => { + setGallery((g) => { + const albums = [...g.albums]; + albums[i] = { ...albums[i], ...updates }; + return { ...g, albums }; + }); + markDirty(); + }} + /> + ))} +
+
+
{/* Subpages */} @@ -561,38 +840,30 @@ export default function PageBuilder() {

)} - {/* Collapsed overview grid */} -
- {gallery.subpages.map((sp, spIndex) => { - const totalAlbums = - sp.albums.length + - (sp.sections?.reduce((sum, sec) => sum + sec.albums.length, 0) || 0); - const firstThumb = getFirstSubpageThumb(sp); - return ( -
- setExpandedSubpage(expandedSubpage === spIndex ? null : spIndex) - } - > -
- {firstThumb ? ( - - ) : ( -
📂
- )} -
-
- {sp.title || sp.name} - - {totalAlbums} album{totalAlbums !== 1 ? 's' : ''} - -
-
- ); - })} -
+ {/* Collapsed overview grid with DnD */} + + `subpage-${i}`)} + strategy={horizontalListSortingStrategy} + > +
+ {gallery.subpages.map((sp, spIndex) => ( + setExpandedSubpage(expandedSubpage === spIndex ? null : spIndex)} + getFirstThumb={getFirstSubpageThumb} + /> + ))} +
+
+
{/* Expanded subpage detail */} {expandedSubpage !== null && gallery.subpages[expandedSubpage] && ( @@ -604,30 +875,6 @@ export default function PageBuilder() { <>
-
- - -
e.stopPropagation()} /> + /{slugify(sp.name)}
+ e.stopPropagation()} + > + ↗ +
- {/* Albums (if no sections) */} + {/* Albums (if no sections) with DnD */} {(!sp.sections || sp.sections.length === 0) && (
@@ -710,30 +968,42 @@ export default function PageBuilder() { + Add
-
- {sp.albums.map((album, aIndex) => ( - removeSubpageAlbum(spIndex, aIndex)} - onUpdate={(updates) => { - setGallery((g) => { - const subpages = [...g.subpages]; - const sp2 = { ...subpages[spIndex] }; - const albums = [...sp2.albums]; - albums[aIndex] = { ...albums[aIndex], ...updates }; - sp2.albums = albums; - subpages[spIndex] = sp2; - return { ...g, subpages }; - }); - markDirty(); - }} - /> - ))} -
+ + `album-${a.id}-${i}`)} + strategy={verticalListSortingStrategy} + > +
+ {sp.albums.map((album, aIndex) => ( + removeSubpageAlbum(spIndex, aIndex)} + onUpdate={(updates) => { + setGallery((g) => { + const subpages = [...g.subpages]; + const sp2 = { ...subpages[spIndex] }; + const albums = [...sp2.albums]; + albums[aIndex] = { ...albums[aIndex], ...updates }; + sp2.albums = albums; + subpages[spIndex] = sp2; + return { ...g, subpages }; + }); + markDirty(); + }} + /> + ))} +
+
+
)} @@ -817,18 +1087,13 @@ export default function PageBuilder() { )}
- {sp.sections && sp.sections.length > 0 && ( @@ -850,6 +1115,15 @@ export default function PageBuilder() { usedAlbumIds={getAllUsedAlbumIds()} /> )} + + {/* Hero Asset Picker Modal */} + {showHeroPicker && ( + setShowHeroPicker(false)} + currentAssetIds={gallery.hero} + /> + )}
); @@ -873,20 +1147,30 @@ interface AlbumCardProps { thumbnailId: string | null; onRemove: () => void; onUpdate: (updates: Partial) => void; + dragListeners?: Record; } -function AlbumCard({ album, name, count, thumbnailId, onRemove, onUpdate }: AlbumCardProps) { +function AlbumCard({ + album, + name, + count, + thumbnailId, + onRemove, + onUpdate, + dragListeners, +}: AlbumCardProps) { const [expanded, setExpanded] = useState(false); return (
+ {dragListeners && ( +
+ ⠿ +
+ )} {thumbnailId ? ( - + ) : (
📷
)} diff --git a/app/admin/components/SettingsEditor.tsx b/app/admin/components/SettingsEditor.tsx index ad538a6..7b7d304 100644 --- a/app/admin/components/SettingsEditor.tsx +++ b/app/admin/components/SettingsEditor.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; interface Settings { title?: string; @@ -67,6 +67,37 @@ export default function SettingsEditor() { loadSettings(); }, []); + // ── Keyboard shortcut: ⌘+S / Ctrl+S ───────────────────────── + const handleSaveRef = useCallback(() => { + if (dirty && !saving) { + handleSave(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dirty, saving, settings]); + + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key === 's') { + e.preventDefault(); + handleSaveRef(); + } + } + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [handleSaveRef]); + + // ── Unsaved changes guard ──────────────────────────────────── + useEffect(() => { + function handleBeforeUnload(e: BeforeUnloadEvent) { + if (dirty) { + e.preventDefault(); + e.returnValue = ''; + } + } + window.addEventListener('beforeunload', handleBeforeUnload); + return () => window.removeEventListener('beforeunload', handleBeforeUnload); + }, [dirty]); + async function loadSettings() { setLoading(true); try { diff --git a/app/api/admin/assets/route.ts b/app/api/admin/assets/route.ts new file mode 100644 index 0000000..0911a33 --- /dev/null +++ b/app/api/admin/assets/route.ts @@ -0,0 +1,87 @@ +/** + * Admin API: Browse Immich assets for the hero picker. + * Supports pagination and fetching favorites first. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth'; +import { getConfig } from '@/lib/config'; + +interface ImmichSearchResult { + assets: { + items: Array<{ + id: string; + type: string; + originalFileName: string; + thumbhash: string | null; + fileCreatedAt: string; + isFavorite: boolean; + }>; + nextPage: string | null; + }; +} + +/** GET: Search/browse assets from Immich for hero image selection. */ +export async function GET(request: NextRequest) { + if (!isAdminEnabled()) { + return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 }); + } + if (!(await isAdminAuthenticated())) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const config = getConfig(); + if (config.needsSetup) { + return NextResponse.json({ error: 'Immich not configured' }, { status: 503 }); + } + + const { searchParams } = new URL(request.url); + const page = parseInt(searchParams.get('page') || '1', 10); + const favoritesOnly = searchParams.get('favorites') === 'true'; + const pageSize = 50; + + try { + // Use Immich search/metadata endpoint to query assets + const body: Record = { + type: 'IMAGE', + size: pageSize, + page, + order: 'desc', + isNotInAlbum: false, + }; + + if (favoritesOnly) { + body.isFavorite = true; + } + + const res = await fetch(`${config.immich.apiUrl}/search/metadata`, { + method: 'POST', + headers: { + 'x-api-key': config.immich.apiKey, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + return NextResponse.json({ error: `Immich API returned ${res.status}` }, { status: 502 }); + } + + const data = (await res.json()) as ImmichSearchResult; + const assets = data.assets.items.map((asset) => ({ + id: asset.id, + originalFileName: asset.originalFileName, + fileCreatedAt: asset.fileCreatedAt, + isFavorite: asset.isFavorite, + })); + + return NextResponse.json({ + assets, + nextPage: data.assets.nextPage ? page + 1 : null, + }); + } catch (err) { + console.error('[Admin] Failed to fetch assets from Immich:', err); + return NextResponse.json({ error: 'Failed to connect to Immich' }, { status: 502 }); + } +} diff --git a/package-lock.json b/package-lock.json index 698933c..2375250 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,9 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "js-yaml": "^4.1.1", "leaflet": "^1.9.4", "next": "16.2.4", @@ -283,6 +286,59 @@ "node": ">=18" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", diff --git a/package.json b/package.json index a47213b..77541a3 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,9 @@ "test:e2e": "playwright test" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "js-yaml": "^4.1.1", "leaflet": "^1.9.4", "next": "16.2.4",