'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 type { DoctorLevel } from '@/lib/admin/doctor'; import { DOCTOR_LEVEL_EVENT, systemHealth } from './systemHealth'; import { reportIfSessionExpired } from './sessionExpiry'; import * as Icons from './Icons'; import { useNotify } from './Notifications'; import { adminGet } from './useAdminFetch'; interface Props { onLogout: () => void; /** The active route's panel — Pages, Journal Studio, Settings or Analytics. */ children: ReactNode; } /** * The sidebar, grouped by what the owner is doing (docs/admin-ux-concept.md). * `match` decides the active entry; Overview only matches /admin itself. */ const NAV: { group?: string; items: { label: string; href: string; match: RegExp; icon: ReactNode; badge?: 'messages' | 'health'; }[]; }[] = [ { items: [ { label: 'Overview', href: '/admin', match: /^\/admin\/?$/, icon: , }, ], }, { group: 'Content', items: [ { label: 'Pages', href: '/admin/pages', match: /^\/admin\/pages$/, icon: , }, { label: 'Journal', href: '/admin/journal', match: /^\/admin\/journal/, icon: , }, ], }, { group: 'Visitors', items: [ { label: 'Messages', href: '/admin/messages', match: /^\/admin\/messages/, icon: , badge: 'messages', }, { label: 'Analytics', href: '/admin/analytics', match: /^\/admin\/analytics/, icon: , }, ], }, { group: 'Site', items: [ { label: 'Settings', href: '/admin/settings', match: /^\/admin\/settings/, icon: , }, ], }, { group: 'System', items: [ { label: 'Diagnostics', href: '/admin/diagnostics', match: /^\/admin\/diagnostics/, icon: , badge: 'health', }, { label: 'Help', href: '/admin/help', match: /^\/admin\/help/, icon: , }, ], }, ]; const SIDEBAR_KEY = 'folio_admin_sidebar'; export default function AdminDashboard({ onLogout, children }: Props) { const notify = useNotify(); const pathname = usePathname(); const [saving, setSaving] = useState(false); // Diagnostics & Backup state const [showStatus, setShowStatus] = useState(false); const [showBackupModal, setShowBackupModal] = 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(null); const [status, setStatus] = useState(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); /** * Icons-only sidebar, remembered per browser. The dashboard only renders * after the client-side session check, so reading storage in the initial * state cannot mismatch a server render. */ const [collapsed, setCollapsed] = useState(() => { try { return window.localStorage.getItem(SIDEBAR_KEY) === 'collapsed'; } catch { return false; } }); const toggleCollapsed = () => setCollapsed((c) => { try { window.localStorage.setItem(SIDEBAR_KEY, c ? 'expanded' : 'collapsed'); } catch { // Private mode or blocked storage: the toggle still works for this visit. } return !c; }); /** Unread contact messages, for the count next to Messages. */ const [unread, setUnread] = useState(0); async function handleLogout() { await fetch('/api/admin/auth', { method: 'DELETE' }); onLogout(); } async function handleReload() { setSaving(true); try { const res = await fetch('/api/admin/reload', { method: 'POST' }); // A 401 or a 500 used to be indistinguishable from success here — the // result was never checked at all (#596). if (!res.ok) { if (!reportIfSessionExpired(res)) { notify('error', `Reload failed (HTTP ${res.status}).`); } return; } // 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]); // Re-counted on every navigation: reading a message in the inbox and moving // on should clear the count without a reload. useEffect(() => { let live = true; adminGet<{ messages: { read: boolean }[] }>('/api/admin/messages').then((r) => { if (live && r.data) setUnread(r.data.messages.filter((m) => !m.read).length); }); return () => { live = false; }; }, [pathname]); // The diagnostics page runs the doctor itself; take its answer rather than // asking Immich the same three questions again. useEffect(() => { const onLevel = (e: Event) => setDoctorLevel((e as CustomEvent).detail); window.addEventListener(DOCTOR_LEVEL_EVENT, onLevel); return () => window.removeEventListener(DOCTOR_LEVEL_EVENT, onLevel); }, []); 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; // Colour and words decided together — they used to be two separately-ordered // nested ternaries over the same inputs, and disagreed in two states (#539). const health = systemHealth({ statusLoading, setupIncomplete, immich: immichIndicator.className, doctorLevel, }); const missingCredentials = status?.setup?.credentials === 'missing'; return (
{children}
setShowBackupModal(false)} onRestoreSuccess={() => { fetchStatus(); }} />
); }