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:
@@ -1098,33 +1098,314 @@
|
|||||||
margin: 0 0 1rem;
|
margin: 0 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Preset Grid */
|
/* Preset Card Grid */
|
||||||
.preset-grid {
|
.preset-card-grid {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
gap: 0.4rem;
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-btn {
|
.preset-card {
|
||||||
padding: 0.4rem 0.8rem;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
background: var(--admin-bg-raised);
|
background: var(--admin-bg-raised);
|
||||||
border: 1px solid var(--admin-border);
|
border: 1px solid var(--admin-border);
|
||||||
border-radius: 6px;
|
border-radius: 10px;
|
||||||
color: var(--admin-text-secondary);
|
overflow: hidden;
|
||||||
font-size: 0.8rem;
|
|
||||||
cursor: pointer;
|
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);
|
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);
|
color: var(--admin-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-btn.active {
|
.status-dropdown-body {
|
||||||
background: var(--admin-btn-primary-bg);
|
padding: 0.5rem 0.85rem;
|
||||||
color: var(--admin-btn-primary-text);
|
display: flex;
|
||||||
border-color: var(--admin-btn-primary-bg);
|
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 */
|
/* Color Field */
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import PageBuilder from './PageBuilder';
|
import PageBuilder from './PageBuilder';
|
||||||
import SettingsEditor from './SettingsEditor';
|
import SettingsEditor from './SettingsEditor';
|
||||||
|
|
||||||
@@ -14,6 +14,11 @@ export default function AdminDashboard({ onLogout }: Props) {
|
|||||||
const [activeTab, setActiveTab] = useState<Tab>('pages');
|
const [activeTab, setActiveTab] = useState<Tab>('pages');
|
||||||
const [saving, setSaving] = useState(false);
|
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() {
|
async function handleLogout() {
|
||||||
await fetch('/api/admin/auth', { method: 'DELETE' });
|
await fetch('/api/admin/auth', { method: 'DELETE' });
|
||||||
onLogout();
|
onLogout();
|
||||||
@@ -23,11 +28,34 @@ export default function AdminDashboard({ onLogout }: Props) {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await fetch('/api/admin/reload', { method: 'POST' });
|
await fetch('/api/admin/reload', { method: 'POST' });
|
||||||
|
// Refresh status after reload
|
||||||
|
await fetchStatus();
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
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 (
|
return (
|
||||||
<div className="admin-dashboard">
|
<div className="admin-dashboard">
|
||||||
<header className="admin-header">
|
<header className="admin-header">
|
||||||
@@ -49,6 +77,70 @@ export default function AdminDashboard({ onLogout }: Props) {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-header-right">
|
<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
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
@@ -266,6 +266,7 @@ export default function PageBuilder() {
|
|||||||
sectionIndex?: number;
|
sectionIndex?: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [showHeroPicker, setShowHeroPicker] = useState(false);
|
const [showHeroPicker, setShowHeroPicker] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
// DnD sensors
|
// DnD sensors
|
||||||
const sensors = useSensors(
|
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 (
|
return (
|
||||||
<div className="page-builder">
|
<div className="page-builder">
|
||||||
{/* Save Bar */}
|
{/* Save Bar */}
|
||||||
@@ -752,6 +786,25 @@ export default function PageBuilder() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Hero Section */}
|
||||||
<section className="builder-section">
|
<section className="builder-section">
|
||||||
<div className="builder-section-header">
|
<div className="builder-section-header">
|
||||||
@@ -805,34 +858,40 @@ export default function PageBuilder() {
|
|||||||
onDragEnd={handleAlbumDragEnd}
|
onDragEnd={handleAlbumDragEnd}
|
||||||
>
|
>
|
||||||
<SortableContext
|
<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}
|
strategy={verticalListSortingStrategy}
|
||||||
>
|
>
|
||||||
<div className="album-list">
|
<div className="album-list">
|
||||||
{gallery.albums.length === 0 && (
|
{filteredAlbums.length === 0 && (
|
||||||
<p className="empty-hint">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
{gallery.albums.map((album, i) => (
|
{filteredAlbums.map((album) => {
|
||||||
<SortableAlbumCard
|
const originalIndex = gallery.albums.findIndex((a) => a.id === album.id);
|
||||||
key={`${album.id}-${i}`}
|
return (
|
||||||
album={album}
|
<SortableAlbumCard
|
||||||
index={i}
|
key={`${album.id}-${originalIndex}`}
|
||||||
name={getAlbumName(album.id)}
|
album={album}
|
||||||
count={getAlbumCount(album.id)}
|
index={originalIndex}
|
||||||
thumbnailId={getAlbumThumbnailId(album.id)}
|
name={getAlbumName(album.id)}
|
||||||
onRemove={() => removeStandaloneAlbum(i)}
|
count={getAlbumCount(album.id)}
|
||||||
onUpdate={(updates) => {
|
thumbnailId={getAlbumThumbnailId(album.id)}
|
||||||
setGallery((g) => {
|
onRemove={() => removeStandaloneAlbum(originalIndex)}
|
||||||
const albums = [...g.albums];
|
onUpdate={(updates) => {
|
||||||
albums[i] = { ...albums[i], ...updates };
|
setGallery((g) => {
|
||||||
return { ...g, albums };
|
const albums = [...g.albums];
|
||||||
});
|
albums[originalIndex] = { ...albums[originalIndex], ...updates };
|
||||||
markDirty();
|
return { ...g, albums };
|
||||||
}}
|
});
|
||||||
/>
|
markDirty();
|
||||||
))}
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
@@ -853,6 +912,12 @@ export default function PageBuilder() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{gallery.subpages.length > 0 && filteredSubpages.length === 0 && (
|
||||||
|
<p className="empty-hint">
|
||||||
|
No matching subpages found.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Collapsed overview grid with DnD */}
|
{/* Collapsed overview grid with DnD */}
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
@@ -860,17 +925,17 @@ export default function PageBuilder() {
|
|||||||
onDragEnd={handleSubpageDragEnd}
|
onDragEnd={handleSubpageDragEnd}
|
||||||
>
|
>
|
||||||
<SortableContext
|
<SortableContext
|
||||||
items={gallery.subpages.map((_, i) => `subpage-${i}`)}
|
items={filteredSubpages.map(({ index }) => `subpage-${index}`)}
|
||||||
strategy={horizontalListSortingStrategy}
|
strategy={horizontalListSortingStrategy}
|
||||||
>
|
>
|
||||||
<div className="subpage-grid">
|
<div className="subpage-grid">
|
||||||
{gallery.subpages.map((sp, spIndex) => (
|
{filteredSubpages.map(({ sp, index }) => (
|
||||||
<SortableSubpageTile
|
<SortableSubpageTile
|
||||||
key={`subpage-${spIndex}`}
|
key={`subpage-${index}`}
|
||||||
sp={sp}
|
sp={sp}
|
||||||
spIndex={spIndex}
|
spIndex={index}
|
||||||
isActive={expandedSubpage === spIndex}
|
isActive={expandedSubpage === index}
|
||||||
onClick={() => setExpandedSubpage(expandedSubpage === spIndex ? null : spIndex)}
|
onClick={() => setExpandedSubpage(expandedSubpage === index ? null : index)}
|
||||||
getFirstThumb={getFirstSubpageThumb}
|
getFirstThumb={getFirstSubpageThumb}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -55,6 +55,51 @@ const PHOTO_FRAMES = ['none', 'passepartout', 'shadow'];
|
|||||||
const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic'];
|
const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic'];
|
||||||
const ASPECT_RATIOS = ['1', '3/2', '2/3', '16/9', 'auto'];
|
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() {
|
export default function SettingsEditor() {
|
||||||
const [settings, setSettings] = useState<Settings>({});
|
const [settings, setSettings] = useState<Settings>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -67,6 +112,16 @@ export default function SettingsEditor() {
|
|||||||
loadSettings();
|
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 ─────────────────────────
|
// ── Keyboard shortcut: ⌘+S / Ctrl+S ─────────────────────────
|
||||||
const handleSaveRef = useCallback(() => {
|
const handleSaveRef = useCallback(() => {
|
||||||
if (dirty && !saving) {
|
if (dirty && !saving) {
|
||||||
@@ -291,16 +346,36 @@ export default function SettingsEditor() {
|
|||||||
<h3>Theme</h3>
|
<h3>Theme</h3>
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label>Preset</label>
|
<label>Preset</label>
|
||||||
<div className="preset-grid">
|
<div className="preset-card-grid">
|
||||||
{PRESETS.map((p) => (
|
{PRESETS.map((p) => {
|
||||||
<button
|
const info = THEME_INFO[p] || { label: p, desc: '', bg: '#fff', tile: '#eee', accent: '#333' };
|
||||||
key={p}
|
const isActive = (settings.theme?.preset || 'studio') === p;
|
||||||
className={`preset-btn ${(settings.theme?.preset || 'studio') === p ? 'active' : ''}`}
|
return (
|
||||||
onClick={() => update('theme.preset', p)}
|
<button
|
||||||
>
|
key={p}
|
||||||
{p}
|
type="button"
|
||||||
</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>
|
</div>
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
|
|||||||
75
app/api/admin/status/route.ts
Normal file
75
app/api/admin/status/route.ts
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -12,6 +12,10 @@ class MemoryCache {
|
|||||||
private store = new Map<string, CacheEntry<unknown>>();
|
private store = new Map<string, CacheEntry<unknown>>();
|
||||||
private maxEntries = 200;
|
private maxEntries = 200;
|
||||||
|
|
||||||
|
get size(): number {
|
||||||
|
return this.store.size;
|
||||||
|
}
|
||||||
|
|
||||||
get<T>(key: string): T | null {
|
get<T>(key: string): T | null {
|
||||||
const entry = this.store.get(key);
|
const entry = this.store.get(key);
|
||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user