Files
immich-folio/app/admin/components/AdminDashboard.tsx
Ralksta a813433891 release: v0.13.0 (#535)
Twenty-nine commits since v0.12.0. See CHANGELOG.md for the full list
and the upgrade notes.

Ten additions — the SEO package (site URL, robots.txt, sitemap.xml,
JSON-LD), slideshow mode, opt-in original downloads, copy-link,
previous/next album navigation, alt text from the Immich description,
per-album location precision, the update notice, a Help tab, and a
subpage cover grid separate from its photo grid. Two fixes: CJK album
slugs, and the lightbox control bar.

Photo of the Day (#476) was built on `dev` and reverted as not planned;
it never appears in a release.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01DmsHVRL9yQvckUB1ZpVWre

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 10:08:12 +02:00

333 lines
13 KiB
TypeScript

'use client';
import { useState, useEffect, useCallback, type ReactNode } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import BackupManagerModal from './BackupManagerModal';
import DoctorModal from './DoctorModal';
import type { DoctorLevel } from '@/lib/admin/doctor';
import * as Icons from './Icons';
interface Props {
onLogout: () => void;
/** The active route's panel — Pages, Journal Studio, Settings or Analytics. */
children: ReactNode;
}
const TABS = [
{ label: 'Pages', href: '/admin/pages', match: /^\/admin(\/pages)?$/ },
{ label: 'Journal', href: '/admin/journal', match: /^\/admin\/journal/ },
{ label: 'Settings', href: '/admin/settings', match: /^\/admin\/settings/ },
{ label: 'Analytics', href: '/admin/analytics', match: /^\/admin\/analytics/ },
{ label: 'Help', href: '/admin/help', match: /^\/admin\/help/ },
];
export default function AdminDashboard({ onLogout, children }: Props) {
const pathname = usePathname();
const [saving, setSaving] = useState(false);
// Diagnostics & Backup state
const [showStatus, setShowStatus] = useState(false);
const [showBackupModal, setShowBackupModal] = useState(false);
const [showDoctor, setShowDoctor] = useState(false);
// The badge's job is "does anything need me?", and the doctor is the only
// check that can answer that beyond "is Immich up" (#491).
const [doctorLevel, setDoctorLevel] = useState<DoctorLevel | null>(null);
const [status, setStatus] = useState<any>(null);
const [statusLoading, setStatusLoading] = useState(false);
// Distinct from `status === null`: the check itself did not run. Without this
// an expired session or a 500 rendered exactly like a real outage — every
// indicator flipped to its alarming value at once (#341).
const [statusError, setStatusError] = useState(false);
async function handleLogout() {
await fetch('/api/admin/auth', { method: 'DELETE' });
onLogout();
}
async function handleReload() {
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);
setStatusError(false);
} else {
setStatus(null);
setStatusError(true);
}
} catch {
setStatus(null);
setStatusError(true);
} finally {
setStatusLoading(false);
}
};
/**
* Render one diagnostic as ok / bad / unknown. "Unknown" covers both the
* in-flight check and a check that failed to run — neither is evidence that
* the thing being checked is broken.
*/
const indicator = (healthy: boolean, okLabel: string, badLabel: string) => {
if (statusLoading) return { className: 'unknown', label: 'Checking…' };
if (statusError || !status) return { className: 'unknown', label: 'Unknown' };
return healthy ? { className: 'ok', label: okLabel } : { className: 'error', label: badLabel };
};
const fetchDoctor = useCallback(async () => {
try {
const res = await fetch('/api/admin/doctor');
if (!res.ok) return;
const data = await res.json();
setDoctorLevel(data.level ?? null);
} catch {
// A failed check must not colour the badge — "unknown" is not "broken".
}
}, []);
useEffect(() => {
fetchStatus();
fetchDoctor();
}, [fetchDoctor]);
const immichIndicator = indicator(
status?.immich?.status === 'connected',
'Connected',
'Disconnected',
);
const configIndicator =
!statusLoading && !statusError && status?.config?.status === 'setup'
? { className: 'unknown', label: 'Not set up' }
: indicator(status?.config?.status === 'valid', 'Valid', 'Degraded');
// An installation that was never finished is not a fault. It used to render
// as "System Degraded" with no further hint, which sent operators looking for
// a network problem that did not exist (#507).
const setupIncomplete = !statusLoading && !statusError && status?.setup?.complete === false;
const missingCredentials = status?.setup?.credentials === 'missing';
return (
<div className="admin-dashboard">
<header className="admin-header">
<div className="admin-header-left">
<h1>Immich Folio</h1>
<nav className="admin-tabs">
{TABS.map((t) => (
<Link
key={t.href}
href={t.href}
className={`admin-tab ${t.match.test(pathname) ? 'active' : ''}`}
>
{t.label}
</Link>
))}
</nav>
</div>
<div className="admin-header-right">
{/* Diagnostics Badge */}
<div className="status-indicator-container">
<button
className={`status-badge-btn ${
setupIncomplete || doctorLevel === 'warn'
? 'unknown'
: immichIndicator.className === 'error' || doctorLevel === 'error'
? 'disconnected'
: immichIndicator.className === 'ok'
? 'connected'
: 'unknown'
}`}
onClick={() => {
setShowStatus(!showStatus);
if (!showStatus) fetchStatus();
}}
title="Show system status"
>
<span className="status-dot"></span>
<span className="status-text">
{statusLoading
? 'Checking...'
: setupIncomplete
? 'Setup Incomplete'
: immichIndicator.className === 'error'
? 'System Degraded'
: doctorLevel === 'error'
? 'Needs Attention'
: doctorLevel === 'warn'
? 'Check Diagnostics'
: immichIndicator.className === 'ok'
? 'System OK'
: 'Status Unknown'}
</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">
{setupIncomplete && (
<p className="status-setup-note">
{missingCredentials
? 'No Immich URL or API key configured. Set IMMICH_API_URL and IMMICH_API_KEY, or run the '
: 'No content/gallery.yaml yet — the public site has nothing to show. Add a page below, or run the '}
<a href="/install" target="_blank" rel="noopener noreferrer">
setup wizard
</a>
{missingCredentials
? '.'
: ' (the one-time token is printed to the server log).'}
</p>
)}
<div className="status-item">
<span className="status-label">Immich Connection</span>
<span className={`status-val ${immichIndicator.className}`}>
{immichIndicator.label}
</span>
</div>
<div className="status-item">
<span className="status-label">Config Integrity</span>
<span className={`status-val ${configIndicator.className}`}>
{configIndicator.label}
</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>
{/*
Discreet on purpose: a row in the status list, not a
banner. When the check is off or could not run, the row
still names the running version and says nothing about
updates (#496).
*/}
<div className="status-item">
<span className="status-label">Version</span>
{status?.update?.updateAvailable ? (
<a
className="status-val update"
href="https://github.com/ralksta/immich-folio/releases/latest"
target="_blank"
rel="noopener noreferrer"
title={`Version ${status.update.latest} is available`}
>
{status.update.current} → {status.update.latest}
</a>
) : (
<span className="status-val">{status?.update?.current ?? '—'}</span>
)}
</div>
</div>
<div className="status-dropdown-footer">
<button
className="admin-btn admin-btn-sm"
onClick={() => {
setShowStatus(false);
setShowDoctor(true);
}}
>
<Icons.IconShieldCheck size={14} /> Diagnostics
</button>
<button
className="admin-btn admin-btn-sm"
onClick={() => {
setShowStatus(false);
setShowBackupModal(true);
}}
>
<Icons.IconArchive size={14} /> Manage Backups
</button>
</div>
</div>
</>
)}
</div>
<span className="admin-header-divider" aria-hidden="true" />
<a
href="/?fresh=1"
target="_blank"
rel="noopener noreferrer"
className="admin-btn admin-btn-ghost"
title="Open site in new tab (bypassing cache)"
>
<Icons.IconLink size={14} /> Site
</a>
<button
className="admin-btn admin-btn-ghost"
onClick={() => setShowBackupModal(true)}
title="Manage config backups & restore"
>
<Icons.IconArchive size={14} /> Backups
</button>
<button
className="admin-btn admin-btn-ghost"
onClick={handleReload}
disabled={saving}
title="Reload config & clear cache"
>
<Icons.IconRefresh size={14} /> Reload
</button>
<button className="admin-btn admin-btn-ghost" onClick={handleLogout}>
Logout
</button>
</div>
</header>
<main className="admin-main">{children}</main>
<DoctorModal
isOpen={showDoctor}
onClose={() => {
setShowDoctor(false);
fetchDoctor();
}}
/>
<BackupManagerModal
isOpen={showBackupModal}
onClose={() => setShowBackupModal(false)}
onRestoreSuccess={() => {
fetchStatus();
}}
/>
</div>
);
}