From 02f5e6a505c3a381eae98fbbc0e9590ae7d4b417 Mon Sep 17 00:00:00 2001 From: Ralksta <16941050+ralksta@users.noreply.github.com> Date: Sun, 31 May 2026 21:17:52 +0200 Subject: [PATCH] feat(admin): visual theme cards, album search, and status dashboard (#241) * feat(admin): implement visual theme cards, album search, and system status diagnostics * fix(admin): resolve type-checking build errors in SettingsEditor and status route --------- Co-authored-by: ralksta --- app/admin/admin.css | 313 ++++++++++++++++++++++-- app/admin/components/AdminDashboard.tsx | 94 ++++++- app/admin/components/PageBuilder.tsx | 121 ++++++--- app/admin/components/SettingsEditor.tsx | 95 ++++++- app/api/admin/status/route.ts | 75 ++++++ lib/cache.ts | 4 + 6 files changed, 647 insertions(+), 55 deletions(-) create mode 100644 app/api/admin/status/route.ts diff --git a/app/admin/admin.css b/app/admin/admin.css index 4af0bfe..216a0b8 100644 --- a/app/admin/admin.css +++ b/app/admin/admin.css @@ -1098,33 +1098,314 @@ margin: 0 0 1rem; } -/* Preset Grid */ -.preset-grid { - display: flex; - flex-wrap: wrap; - gap: 0.4rem; +/* Preset Card Grid */ +.preset-card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 1rem; + width: 100%; } -.preset-btn { - padding: 0.4rem 0.8rem; +.preset-card { + display: flex; + flex-direction: column; background: var(--admin-bg-raised); border: 1px solid var(--admin-border); - border-radius: 6px; - color: var(--admin-text-secondary); - font-size: 0.8rem; + border-radius: 10px; + overflow: hidden; cursor: pointer; - transition: all 0.1s; + padding: 0; + text-align: left; + transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.2s, box-shadow 0.2s; + box-shadow: 0 1px 3px rgba(0,0,0,0.05); } -.preset-btn:hover { +.preset-card:hover { + transform: translateY(-2px); border-color: var(--admin-border-hover); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); +} + +.preset-card.active { + border-color: var(--admin-accent); + box-shadow: 0 0 0 2px var(--admin-accent); +} + +/* Preset Mini Preview Mockup */ +.preset-card-preview { + width: 100%; + aspect-ratio: 16 / 10; + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.4rem; + border-bottom: 1px solid var(--admin-border); + position: relative; + overflow: hidden; +} + +.preset-card-preview .mini-header { + display: flex; + align-items: center; + gap: 0.3rem; + height: 8px; +} + +.preset-card-preview .mini-dot { + width: 4px; + height: 4px; + border-radius: 50%; +} + +.preset-card-preview .mini-line { + height: 2px; + width: 24px; + border-radius: 1px; +} + +.preset-card-preview .mini-grid { + flex: 1; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.25rem; + align-items: center; +} + +.preset-card-preview .mini-tile { + aspect-ratio: 1; + border-radius: 3px; + border: 1px solid rgba(0, 0, 0, 0.05); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02); +} + +.preset-card-info { + padding: 0.6rem 0.75rem; + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.preset-card-name { + font-size: 0.85rem; + font-weight: 600; + color: var(--admin-text); + text-transform: capitalize; +} + +.preset-card-desc { + font-size: 0.68rem; + color: var(--admin-text-muted); + line-height: 1.3; +} + +/* ── Status Diagnostics & Badge ──────────────────────────────── */ +.status-indicator-container { + position: relative; + display: inline-block; + margin-right: 0.5rem; +} + +.status-badge-btn { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.4rem 0.75rem; + background: var(--admin-bg-raised); + border: 1px solid var(--admin-border); + border-radius: 20px; + cursor: pointer; + transition: all 0.15s; +} + +.status-badge-btn:hover { + background: var(--admin-bg-elevated); + border-color: var(--admin-border-hover); +} + +.status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + position: relative; +} + +.status-badge-btn.connected .status-dot { + background-color: var(--admin-success, #10b981); + box-shadow: 0 0 8px var(--admin-success, #10b981); + animation: pulse-green 2s infinite; +} + +.status-badge-btn.disconnected .status-dot { + background-color: var(--admin-error, #ef4444); + box-shadow: 0 0 8px var(--admin-error, #ef4444); + animation: pulse-red 1.5s infinite; +} + +.status-text { + font-size: 0.75rem; + font-weight: 500; + color: var(--admin-text-secondary); +} + +/* Status Dropdown */ +.status-dropdown-backdrop { + position: fixed; + inset: 0; + z-index: 99; + background: transparent; +} + +.status-dropdown { + position: absolute; + top: 100%; + right: 0; + margin-top: 0.5rem; + width: 260px; + background: var(--admin-bg-raised); + border: 1px solid var(--admin-border); + border-radius: 12px; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15); + z-index: 100; + overflow: hidden; + animation: slideDown 0.15s cubic-bezier(0.16, 1, 0.3, 1); +} + +.status-dropdown-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.65rem 0.85rem; + border-bottom: 1px solid var(--admin-border); + background: var(--admin-bg-elevated); +} + +.status-dropdown-header h4 { + margin: 0; + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--admin-text-muted); +} + +.status-refresh-btn { + background: none; + border: none; + color: var(--admin-text-muted); + cursor: pointer; + font-size: 0.95rem; + padding: 0 0.2rem; + line-height: 1; + transition: color 0.15s; +} + +.status-refresh-btn:hover { color: var(--admin-text); } -.preset-btn.active { - background: var(--admin-btn-primary-bg); - color: var(--admin-btn-primary-text); - border-color: var(--admin-btn-primary-bg); +.status-dropdown-body { + padding: 0.5rem 0.85rem; + display: flex; + flex-direction: column; +} + +.status-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.45rem 0; + border-bottom: 1px dashed var(--admin-border); + font-size: 0.78rem; +} + +.status-item:last-child { + border-bottom: none; +} + +.status-label { + color: var(--admin-text-muted); +} + +.status-val { + font-weight: 500; + color: var(--admin-text); +} + +.status-val.ok { + color: var(--admin-success, #10b981); +} + +.status-val.error { + color: var(--admin-error, #ef4444); +} + +/* ── Search Bar ────────────────────────────────────────────── */ +.builder-search-container { + padding: 0.75rem 1.5rem; + background: var(--admin-bg-raised); + border-bottom: 1px solid var(--admin-border); + margin-bottom: 1rem; +} + +.builder-search-wrapper { + position: relative; + display: flex; + align-items: center; + max-width: 480px; +} + +.builder-search-icon { + position: absolute; + left: 10px; + font-size: 0.85rem; + color: var(--admin-text-muted); + pointer-events: none; +} + +.builder-search-input { + width: 100%; + padding: 0.5rem 2rem 0.5rem 2rem; + background: var(--admin-bg-input); + border: 1px solid var(--admin-border); + border-radius: 8px; + color: var(--admin-text); + font-size: 0.82rem; + transition: all 0.15s; +} + +.builder-search-input:focus { + outline: none; + border-color: var(--admin-accent); + box-shadow: 0 0 0 1px var(--admin-accent); + background: var(--admin-bg); +} + +.builder-search-clear { + position: absolute; + right: 8px; + background: none; + border: none; + color: var(--admin-text-muted); + font-size: 1.1rem; + cursor: pointer; + padding: 0 4px; +} + +.builder-search-clear:hover { + color: var(--admin-text); +} + +/* Keyframes */ +@keyframes pulse-green { + 0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); } + 70% { box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); } + 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); } +} + +@keyframes pulse-red { + 0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); } + 70% { box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); } + 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); } } /* Color Field */ diff --git a/app/admin/components/AdminDashboard.tsx b/app/admin/components/AdminDashboard.tsx index d24422a..a7270cf 100644 --- a/app/admin/components/AdminDashboard.tsx +++ b/app/admin/components/AdminDashboard.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import PageBuilder from './PageBuilder'; import SettingsEditor from './SettingsEditor'; @@ -14,6 +14,11 @@ export default function AdminDashboard({ onLogout }: Props) { const [activeTab, setActiveTab] = useState('pages'); const [saving, setSaving] = useState(false); + // Diagnostics state + const [showStatus, setShowStatus] = useState(false); + const [status, setStatus] = useState(null); + const [statusLoading, setStatusLoading] = useState(false); + async function handleLogout() { await fetch('/api/admin/auth', { method: 'DELETE' }); onLogout(); @@ -23,11 +28,34 @@ export default function AdminDashboard({ onLogout }: Props) { setSaving(true); try { await fetch('/api/admin/reload', { method: 'POST' }); + // Refresh status after reload + await fetchStatus(); } finally { setSaving(false); } } + const fetchStatus = async () => { + setStatusLoading(true); + try { + const res = await fetch('/api/admin/status'); + if (res.ok) { + const data = await res.json(); + setStatus(data); + } else { + setStatus(null); + } + } catch { + setStatus(null); + } finally { + setStatusLoading(false); + } + }; + + useEffect(() => { + fetchStatus(); + }, []); + return (
@@ -49,6 +77,70 @@ export default function AdminDashboard({ onLogout }: Props) {
+ {/* Diagnostics Badge */} +
+ + + {showStatus && ( + <> +
setShowStatus(false)} /> +
+
+

System Diagnostics

+ +
+
+
+ Immich Connection + + {status?.immich?.status === 'connected' ? 'Connected' : 'Disconnected'} + +
+
+ Config Integrity + + {status?.config?.status === 'valid' ? 'Valid' : 'Degraded'} + +
+
+ Config Backups + {status?.backups?.count ?? 0} Backups +
+
+ Latest Backup + + {status?.backups?.lastBackup ? new Date(status.backups.lastBackup).toLocaleDateString() : 'None'} + +
+
+ In-Memory Cache + {status?.cache?.size ?? 0} items +
+
+
+ + )} +
+ (null); const [showHeroPicker, setShowHeroPicker] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); // DnD sensors const sensors = useSensors( @@ -717,6 +718,39 @@ export default function PageBuilder() { ); } + // Filter standalone albums + const filteredAlbums = gallery.albums.filter((album) => { + if (!searchQuery) return true; + const name = getAlbumName(album.id).toLowerCase(); + const description = (album.description || '').toLowerCase(); + const overrideTitle = (album.title || '').toLowerCase(); + const query = searchQuery.toLowerCase(); + return name.includes(query) || description.includes(query) || overrideTitle.includes(query) || album.id.toLowerCase().includes(query); + }); + + // Filter subpages + const filteredSubpages = gallery.subpages + .map((sp, index) => ({ sp, index })) + .filter(({ sp }) => { + if (!searchQuery) return true; + const query = searchQuery.toLowerCase(); + const name = sp.name.toLowerCase(); + const title = (sp.title || '').toLowerCase(); + const subtitle = (sp.subtitle || '').toLowerCase(); + + if (name.includes(query) || title.includes(query) || subtitle.includes(query)) return true; + + const albums = sp.albums || []; + const hasMatchingAlbum = albums.some((a) => { + const aName = getAlbumName(a.id).toLowerCase(); + const aTitle = (a.title || '').toLowerCase(); + const aDesc = (a.description || '').toLowerCase(); + return aName.includes(query) || aTitle.includes(query) || aDesc.includes(query) || a.id.toLowerCase().includes(query); + }); + + return hasMatchingAlbum; + }); + return (
{/* Save Bar */} @@ -752,6 +786,25 @@ export default function PageBuilder() {
+ {/* Search Bar */} +
+
+ 🔍 + setSearchQuery(e.target.value)} + /> + {searchQuery && ( + + )} +
+
+ {/* Hero Section */}
@@ -805,34 +858,40 @@ export default function PageBuilder() { onDragEnd={handleAlbumDragEnd} > `album-${a.id}-${i}`)} + items={filteredAlbums.map((a) => { + const originalIndex = gallery.albums.findIndex((x) => x.id === a.id); + return `album-${a.id}-${originalIndex}`; + })} strategy={verticalListSortingStrategy} >
- {gallery.albums.length === 0 && ( + {filteredAlbums.length === 0 && (

- No standalone albums. These show directly on the homepage. + {searchQuery ? 'No matching standalone albums found.' : '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(); - }} - /> - ))} + {filteredAlbums.map((album) => { + const originalIndex = gallery.albums.findIndex((a) => a.id === album.id); + return ( + removeStandaloneAlbum(originalIndex)} + onUpdate={(updates) => { + setGallery((g) => { + const albums = [...g.albums]; + albums[originalIndex] = { ...albums[originalIndex], ...updates }; + return { ...g, albums }; + }); + markDirty(); + }} + /> + ); + })}
@@ -853,6 +912,12 @@ export default function PageBuilder() {

)} + {gallery.subpages.length > 0 && filteredSubpages.length === 0 && ( +

+ No matching subpages found. +

+ )} + {/* Collapsed overview grid with DnD */} `subpage-${i}`)} + items={filteredSubpages.map(({ index }) => `subpage-${index}`)} strategy={horizontalListSortingStrategy} >
- {gallery.subpages.map((sp, spIndex) => ( + {filteredSubpages.map(({ sp, index }) => ( setExpandedSubpage(expandedSubpage === spIndex ? null : spIndex)} + spIndex={index} + isActive={expandedSubpage === index} + onClick={() => setExpandedSubpage(expandedSubpage === index ? null : index)} getFirstThumb={getFirstSubpageThumb} /> ))} diff --git a/app/admin/components/SettingsEditor.tsx b/app/admin/components/SettingsEditor.tsx index 7b7d304..221c0ce 100644 --- a/app/admin/components/SettingsEditor.tsx +++ b/app/admin/components/SettingsEditor.tsx @@ -55,6 +55,51 @@ const PHOTO_FRAMES = ['none', 'passepartout', 'shadow']; const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic']; const ASPECT_RATIOS = ['1', '3/2', '2/3', '16/9', 'auto']; +const THEME_INFO: Record = { + studio: { + label: 'Studio', + desc: 'Clean, high-contrast grid with sans-serif type.', + bg: '#141414', + tile: '#242424', + accent: '#e60012', + }, + minimal: { + label: 'Minimal', + desc: 'Pure raw layouts with tiny gaps and high whitespace.', + bg: '#ffffff', + tile: '#f3f3f3', + accent: '#111111', + }, + editorial: { + label: 'Editorial', + desc: 'Warm backgrounds, elegant serifs and large headers.', + bg: '#fbf9f4', + tile: '#e5dfd4', + accent: '#b89053', + }, + classic: { + label: 'Classic', + desc: 'Soft traditional photographer portfolio with shadows.', + bg: '#f7f7f7', + tile: '#ffffff', + accent: '#444444', + }, + noir: { + label: 'Noir', + desc: 'High drama absolute pitch black, stark high-fashion look.', + bg: '#000000', + tile: '#151515', + accent: '#ffffff', + }, + monograph: { + label: 'Monograph', + desc: 'Typewriter monospace font, grid borders and document feel.', + bg: '#f4f4f6', + tile: '#ffffff', + accent: '#555555', + }, +}; + export default function SettingsEditor() { const [settings, setSettings] = useState({}); const [loading, setLoading] = useState(true); @@ -67,6 +112,16 @@ export default function SettingsEditor() { loadSettings(); }, []); + // Sync picked accent color to the admin panel UI immediately for live premium feel + useEffect(() => { + if (settings.theme?.accent) { + document.documentElement.style.setProperty('--admin-accent', settings.theme.accent); + } + return () => { + document.documentElement.style.removeProperty('--admin-accent'); + }; + }, [settings.theme?.accent]); + // ── Keyboard shortcut: ⌘+S / Ctrl+S ───────────────────────── const handleSaveRef = useCallback(() => { if (dirty && !saving) { @@ -291,16 +346,36 @@ export default function SettingsEditor() {

Theme

-
- {PRESETS.map((p) => ( - - ))} +
+ {PRESETS.map((p) => { + const info = THEME_INFO[p] || { label: p, desc: '', bg: '#fff', tile: '#eee', accent: '#333' }; + const isActive = (settings.theme?.preset || 'studio') === p; + return ( + + ); + })}
diff --git a/app/api/admin/status/route.ts b/app/api/admin/status/route.ts new file mode 100644 index 0000000..44b2bd8 --- /dev/null +++ b/app/api/admin/status/route.ts @@ -0,0 +1,75 @@ +import { NextResponse } from 'next/server'; +import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth'; +import { immich } from '@/lib/immich'; +import { cache } from '@/lib/cache'; +import { listBackups, readGalleryYaml, readSettingsYaml } from '@/lib/admin/yaml-service'; + +export async function GET() { + if (!isAdminEnabled()) { + return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 }); + } + if (!(await isAdminAuthenticated())) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // 1. Immich Connection Status + let immichOk = false; + try { + immichOk = await immich.ping(); + } catch (err) { + console.error('[Admin API] Immich ping failed:', err); + } + + // 2. Config Health Check + let galleryValid = false; + let settingsValid = false; + try { + const gallery = await readGalleryYaml(); + galleryValid = !!gallery; + } catch (err) { + console.error('[Admin API] Gallery YAML parse failed:', err); + } + + try { + const settings = await readSettingsYaml(); + settingsValid = !!settings; + } catch (err) { + console.error('[Admin API] Settings YAML parse failed:', err); + } + + // 3. Backup Info + const galleryBackups = await listBackups('gallery.yaml'); + const settingsBackups = await listBackups('settings.yaml'); + const backupCount = galleryBackups.length + settingsBackups.length; + + // Find timestamp of the latest backup + let lastBackup: string | null = null; + const allBackups = [...galleryBackups, ...settingsBackups].sort().reverse(); + if (allBackups.length > 0) { + // Filename format: filename.yaml.2026-05-31T17-30-00-000Z.bak + const match = allBackups[0].match(/\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}/); + if (match) { + lastBackup = match[0].replace(/-/g, ':').replace(/(\d{4}):(\d{2}):(\d{2})T/, '$1-$2-$3T'); + } else { + lastBackup = allBackups[0]; // fallback + } + } + + return NextResponse.json({ + immich: { + status: immichOk ? 'connected' : 'disconnected', + }, + config: { + status: (galleryValid && settingsValid) ? 'valid' : 'invalid', + gallery: galleryValid ? 'valid' : 'invalid', + settings: settingsValid ? 'valid' : 'invalid', + }, + cache: { + size: cache.size, + }, + backups: { + count: backupCount, + lastBackup, + }, + }); +} diff --git a/lib/cache.ts b/lib/cache.ts index e5af989..85a5a77 100644 --- a/lib/cache.ts +++ b/lib/cache.ts @@ -12,6 +12,10 @@ class MemoryCache { private store = new Map>(); private maxEntries = 200; + get size(): number { + return this.store.size; + } + get(key: string): T | null { const entry = this.store.get(key); if (!entry) return null;