'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: 'Proofing',
href: '/admin/proofing',
match: /^\/admin\/proofing/,
icon: ,
},
{
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();
// Phones: the sidebar folds into a header row, and the nav opens from a
// menu button (#732) — spread out above the content, it took most of an
// iPhone screen before anything could be edited.
const [menuOpen, setMenuOpen] = useState(false);
useEffect(() => setMenuOpen(false), [pathname]);
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 (