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 <ralksta@users.noreply.github.com>
This commit is contained in:
Ralksta
2026-05-31 21:17:52 +02:00
committed by GitHub
parent e24cd33a09
commit 02f5e6a505
6 changed files with 647 additions and 55 deletions

View File

@@ -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<Tab>('pages');
const [saving, setSaving] = useState(false);
// Diagnostics state
const [showStatus, setShowStatus] = useState(false);
const [status, setStatus] = useState<any>(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 (
<div className="admin-dashboard">
<header className="admin-header">
@@ -49,6 +77,70 @@ export default function AdminDashboard({ onLogout }: Props) {
</nav>
</div>
<div className="admin-header-right">
{/* Diagnostics Badge */}
<div className="status-indicator-container">
<button
className={`status-badge-btn ${status?.immich?.status === 'connected' ? 'connected' : 'disconnected'}`}
onClick={() => {
setShowStatus(!showStatus);
if (!showStatus) fetchStatus();
}}
title="Show system status"
>
<span className="status-dot"></span>
<span className="status-text">
{statusLoading ? 'Checking...' : status?.immich?.status === 'connected' ? 'System OK' : 'System Degraded'}
</span>
</button>
{showStatus && (
<>
<div className="status-dropdown-backdrop" onClick={() => setShowStatus(false)} />
<div className="status-dropdown">
<div className="status-dropdown-header">
<h4>System Diagnostics</h4>
<button
className="status-refresh-btn"
onClick={fetchStatus}
disabled={statusLoading}
title="Refresh diagnostics"
>
{statusLoading ? '...' : '↻'}
</button>
</div>
<div className="status-dropdown-body">
<div className="status-item">
<span className="status-label">Immich Connection</span>
<span className={`status-val ${status?.immich?.status === 'connected' ? 'ok' : 'error'}`}>
{status?.immich?.status === 'connected' ? 'Connected' : 'Disconnected'}
</span>
</div>
<div className="status-item">
<span className="status-label">Config Integrity</span>
<span className={`status-val ${status?.config?.status === 'valid' ? 'ok' : 'error'}`}>
{status?.config?.status === 'valid' ? 'Valid' : 'Degraded'}
</span>
</div>
<div className="status-item">
<span className="status-label">Config Backups</span>
<span className="status-val">{status?.backups?.count ?? 0} Backups</span>
</div>
<div className="status-item">
<span className="status-label">Latest Backup</span>
<span className="status-val" title={status?.backups?.lastBackup || 'None'}>
{status?.backups?.lastBackup ? new Date(status.backups.lastBackup).toLocaleDateString() : 'None'}
</span>
</div>
<div className="status-item">
<span className="status-label">In-Memory Cache</span>
<span className="status-val">{status?.cache?.size ?? 0} items</span>
</div>
</div>
</div>
</>
)}
</div>
<a
href="/"
target="_blank"

View File

@@ -266,6 +266,7 @@ export default function PageBuilder() {
sectionIndex?: number;
} | null>(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 (
<div className="page-builder">
{/* Save Bar */}
@@ -752,6 +786,25 @@ export default function PageBuilder() {
</div>
</div>
{/* Search Bar */}
<div className="builder-search-container">
<div className="builder-search-wrapper">
<span className="builder-search-icon">🔍</span>
<input
type="text"
className="builder-search-input"
placeholder="Search albums or subpages..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<button className="builder-search-clear" onClick={() => setSearchQuery('')} title="Clear search">
×
</button>
)}
</div>
</div>
{/* Hero Section */}
<section className="builder-section">
<div className="builder-section-header">
@@ -805,34 +858,40 @@ export default function PageBuilder() {
onDragEnd={handleAlbumDragEnd}
>
<SortableContext
items={gallery.albums.map((a, i) => `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}
>
<div className="album-list">
{gallery.albums.length === 0 && (
{filteredAlbums.length === 0 && (
<p className="empty-hint">
No standalone albums. These show directly on the homepage.
{searchQuery ? 'No matching standalone albums found.' : 'No standalone albums. These show directly on the homepage.'}
</p>
)}
{gallery.albums.map((album, i) => (
<SortableAlbumCard
key={`${album.id}-${i}`}
album={album}
index={i}
name={getAlbumName(album.id)}
count={getAlbumCount(album.id)}
thumbnailId={getAlbumThumbnailId(album.id)}
onRemove={() => 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 (
<SortableAlbumCard
key={`${album.id}-${originalIndex}`}
album={album}
index={originalIndex}
name={getAlbumName(album.id)}
count={getAlbumCount(album.id)}
thumbnailId={getAlbumThumbnailId(album.id)}
onRemove={() => removeStandaloneAlbum(originalIndex)}
onUpdate={(updates) => {
setGallery((g) => {
const albums = [...g.albums];
albums[originalIndex] = { ...albums[originalIndex], ...updates };
return { ...g, albums };
});
markDirty();
}}
/>
);
})}
</div>
</SortableContext>
</DndContext>
@@ -853,6 +912,12 @@ export default function PageBuilder() {
</p>
)}
{gallery.subpages.length > 0 && filteredSubpages.length === 0 && (
<p className="empty-hint">
No matching subpages found.
</p>
)}
{/* Collapsed overview grid with DnD */}
<DndContext
sensors={sensors}
@@ -860,17 +925,17 @@ export default function PageBuilder() {
onDragEnd={handleSubpageDragEnd}
>
<SortableContext
items={gallery.subpages.map((_, i) => `subpage-${i}`)}
items={filteredSubpages.map(({ index }) => `subpage-${index}`)}
strategy={horizontalListSortingStrategy}
>
<div className="subpage-grid">
{gallery.subpages.map((sp, spIndex) => (
{filteredSubpages.map(({ sp, index }) => (
<SortableSubpageTile
key={`subpage-${spIndex}`}
key={`subpage-${index}`}
sp={sp}
spIndex={spIndex}
isActive={expandedSubpage === spIndex}
onClick={() => setExpandedSubpage(expandedSubpage === spIndex ? null : spIndex)}
spIndex={index}
isActive={expandedSubpage === index}
onClick={() => setExpandedSubpage(expandedSubpage === index ? null : index)}
getFirstThumb={getFirstSubpageThumb}
/>
))}

View File

@@ -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<string, { desc: string; label: string; accent: string; bg: string; tile: string }> = {
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<Settings>({});
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() {
<h3>Theme</h3>
<div className="admin-field">
<label>Preset</label>
<div className="preset-grid">
{PRESETS.map((p) => (
<button
key={p}
className={`preset-btn ${(settings.theme?.preset || 'studio') === p ? 'active' : ''}`}
onClick={() => update('theme.preset', p)}
>
{p}
</button>
))}
<div className="preset-card-grid">
{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 (
<button
key={p}
type="button"
className={`preset-card ${isActive ? 'active' : ''}`}
onClick={() => update('theme.preset', p)}
style={{ '--preset-accent': info.accent } as React.CSSProperties}
>
<div className="preset-card-preview" style={{ backgroundColor: info.bg }}>
<div className="mini-header">
<span className="mini-dot" style={{ backgroundColor: info.accent }}></span>
<span className="mini-line" style={{ backgroundColor: isActive ? info.accent : 'var(--admin-border)' }}></span>
</div>
<div className="mini-grid">
<div className="mini-tile" style={{ backgroundColor: info.tile }}></div>
<div className="mini-tile" style={{ backgroundColor: info.tile }}></div>
<div className="mini-tile" style={{ backgroundColor: info.tile }}></div>
</div>
</div>
<div className="preset-card-info">
<span className="preset-card-name">{info.label}</span>
<span className="preset-card-desc">{info.desc}</span>
</div>
</button>
);
})}
</div>
</div>
<div className="admin-field">