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
+
+
-
- {saving ? 'Saving...' : 'Save Changes'}
-
{/* Hero Section */}
🏠 Homepage Hero
-
+ setShowHeroPicker(true)}>
+ Add Hero
-
- {gallery.hero.length === 0 && (
-
- No hero images configured. Add asset UUIDs to show a hero carousel.
-
- )}
- {gallery.hero.map((id, i) => (
-
-

-
removeHero(i)}
- title="Remove"
- >
- ×
-
-
{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() {
<>
-
- {
- moveSubpage(spIndex, -1);
- setExpandedSubpage(spIndex - 1);
- }}
- disabled={spIndex === 0}
- title="Move up"
- >
- ←
-
- {
- moveSubpage(spIndex, 1);
- setExpandedSubpage(spIndex + 1);
- }}
- disabled={spIndex === gallery.subpages.length - 1}
- title="Move down"
- >
- →
-
-
e.stopPropagation()}
/>
+
/{slugify(sp.name)}
+
e.stopPropagation()}
+ >
+ ↗
+
setExpandedSubpage(null)}
@@ -645,7 +903,7 @@ export default function PageBuilder() {
✕
{
removeSubpage(spIndex);
setExpandedSubpage(null);
@@ -696,7 +954,7 @@ export default function PageBuilder() {
- {/* 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() {
)}
-
addSection(spIndex)}
- >
+ addSection(spIndex)}>
+ Add Section
{sp.sections && sp.sections.length > 0 && (
- setPickerTarget({ type: 'subpage', subpageIndex: spIndex })
- }
+ onClick={() => setPickerTarget({ type: 'subpage', subpageIndex: spIndex })}
>
+ Add Album (outside sections)
@@ -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",