Files
immich-folio/app/admin/components/AdminOverview.tsx
Ralksta ad8b09700b release: v0.19.0
Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of
[CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md)
for the full list.

Highlights: client proofing links (#683), custom content pages (#738),
asset picker search (#740), concurrent-edit detection (#742), env-locked
settings (#739), an accessibility baseline (#741), three security fixes
with advisories (#737, #755, #759), and a pre-release quality pass
(#780–#797).

---------

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>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
2026-09-29 22:50:30 +02:00

235 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
/**
* /admin: the first screen after signing in (docs/admin-ux-concept.md).
*
* Says what needs attention before it is asked for (unread messages, doctor
* findings), then the site at a glance. Everything on it links to the screen
* where it is acted on. It only reads the existing admin endpoints; a failed
* one leaves its part out rather than failing the page.
*/
import { useEffect, useState } from 'react';
import Link from 'next/link';
import * as Icons from './Icons';
import { adminGet } from './useAdminFetch';
import type { DoctorFinding, DoctorLevel } from '@/lib/admin/doctor';
import { DOCTOR_LEVEL_EVENT } from './systemHealth';
import { parseGalleryYaml } from './page-builder/galleryYaml';
interface OverviewData {
/** gallery.yaml as stored; `subpages` may be a list or the older name → albums map. */
gallery: Record<string, unknown> | null;
journal: Array<{ frontmatter: { draft?: boolean } }> | null;
messages: Array<{ read: boolean; name: string }> | null;
analytics: { days?: Record<string, { pageviews?: number }> } | null;
doctor: { level: DoctorLevel; findings: DoctorFinding[] } | null;
}
/** Where a doctor finding is fixed; mirrors FIXES in DiagnosticsView. */
const FIX_ROUTE: Record<string, string> = {
legal: '/admin/settings/legal',
contact: '/admin/settings/legal',
privacy: '/admin/settings/legal',
passwords: '/admin/settings/security',
'album-ids': '/admin/pages',
'albums-shared': '/admin/pages',
'content-pages': '/admin/pages',
};
function viewsLastDays(
days: Record<string, { pageviews?: number }> | undefined,
n: number,
): number {
if (!days) return 0;
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - (n - 1));
const from = cutoff.toISOString().slice(0, 10);
return Object.entries(days)
.filter(([date]) => date >= from)
.reduce((sum, [, d]) => sum + (d.pageviews || 0), 0);
}
function greeting(): string {
const h = new Date().getHours();
if (h < 5) return 'Good night';
if (h < 12) return 'Good morning';
if (h < 18) return 'Good afternoon';
return 'Good evening';
}
export default function AdminOverview() {
const [data, setData] = useState<OverviewData | null>(null);
useEffect(() => {
let live = true;
Promise.all([
adminGet<{ gallery: OverviewData['gallery'] }>('/api/admin/gallery'),
adminGet<{ entries: NonNullable<OverviewData['journal']> }>('/api/admin/journal'),
adminGet<{ messages: NonNullable<OverviewData['messages']> }>('/api/admin/messages'),
adminGet<NonNullable<OverviewData['analytics']>>('/api/admin/analytics'),
adminGet<NonNullable<OverviewData['doctor']>>('/api/admin/doctor'),
]).then(([gallery, journal, messages, analytics, doctor]) => {
if (!live) return;
setData({
gallery: gallery.data?.gallery ?? null,
journal: journal.data?.entries ?? null,
messages: messages.data?.messages ?? null,
analytics: analytics.data ?? null,
doctor: doctor.data ?? null,
});
// The sidebar badge takes the level from here instead of asking again.
if (doctor.data?.level) {
window.dispatchEvent(new CustomEvent(DOCTOR_LEVEL_EVENT, { detail: doctor.data.level }));
}
});
return () => {
live = false;
};
}, []);
// Read the way the page builder reads it: both shapes of `subpages:`, and
// `- page:` entries, which are content pages in the menu, not subpages (#722).
const subpages = data?.gallery ? parseGalleryYaml(data.gallery).subpages : [];
const published = subpages.filter((sp) => sp.enabled !== false).length;
const heroRaw = data?.gallery?.hero;
const heroCount = Array.isArray(heroRaw) ? heroRaw.length : heroRaw ? 1 : 0;
const entries = data?.journal ?? [];
const drafts = entries.filter((e) => e.frontmatter.draft).length;
const unread = (data?.messages ?? []).filter((m) => !m.read);
const views7 = viewsLastDays(data?.analytics?.days, 7);
const findings = (data?.doctor?.findings ?? []).filter((f) => f.level !== 'ok');
const attention: Array<{
tone: 'accent' | 'warn' | 'error';
title: string;
detail?: string;
href: string;
}> = [];
if (unread.length) {
attention.push({
tone: 'accent',
title: `${unread.length} unread message${unread.length === 1 ? '' : 's'}`,
detail: `From ${[...new Set(unread.map((m) => m.name))].slice(0, 3).join(', ')}`,
href: '/admin/messages',
});
}
for (const f of findings) {
attention.push({
tone: f.level === 'error' ? 'error' : 'warn',
title: f.title,
detail: f.detail,
href: FIX_ROUTE[f.id] ?? '/admin/diagnostics',
});
}
return (
<div className="admin-overview">
<header className="overview-head">
<div>
<p className="overview-kicker">Overview</p>
<h1 className="overview-title">{greeting()}.</h1>
<p className="overview-sub">
{data
? attention.length
? `${attention.length} thing${attention.length === 1 ? '' : 's'} could use a look.`
: 'Everything is in order.'
: 'Checking the site…'}
</p>
</div>
<div className="overview-actions">
<a
className="admin-btn admin-btn-ghost"
href="/?fresh=1"
target="_blank"
rel="noopener noreferrer"
>
<Icons.IconLink size={14} /> View site
</a>
<Link className="admin-btn admin-btn-primary" href="/admin/pages">
<Icons.IconPencil size={14} /> Edit pages
</Link>
</div>
</header>
<section aria-labelledby="overview-attention">
<h2 id="overview-attention" className="overview-section-label">
Needs attention
</h2>
{!data ? (
<div className="admin-spinner" />
) : attention.length ? (
<div className="overview-attention">
{attention.map((a, i) => (
<Link key={i} href={a.href} className="overview-attention-item">
<span className={`overview-attention-marker ${a.tone}`} aria-hidden="true" />
<span className="overview-attention-text">
{a.title}
{a.detail && <span className="overview-attention-detail">{a.detail}</span>}
</span>
<span className="overview-attention-go">Open</span>
</Link>
))}
</div>
) : (
<p className="overview-calm">Nothing waiting. New messages and problems show up here.</p>
)}
</section>
<section aria-labelledby="overview-glance">
<h2 id="overview-glance" className="overview-section-label">
At a glance
</h2>
<div className="overview-stats">
<Link href="/admin/pages" className="overview-stat">
<span className="overview-stat-label">Pages</span>
<span className="overview-stat-value">{data ? published : '–'}</span>
<span className="overview-stat-note">
{subpages.length - published > 0
? `${subpages.length - published} switched off`
: 'all published'}
</span>
</Link>
<Link href="/admin/pages" className="overview-stat">
<span className="overview-stat-label">Hero photos</span>
<span className="overview-stat-value">{data ? heroCount : '–'}</span>
<span className="overview-stat-note">on the home page</span>
</Link>
<Link href="/admin/journal" className="overview-stat">
<span className="overview-stat-label">Journal</span>
<span className="overview-stat-value">{data ? entries.length : '–'}</span>
<span className="overview-stat-note">
{drafts ? `${drafts} draft${drafts === 1 ? '' : 's'}` : 'no drafts'}
</span>
</Link>
<Link href="/admin/analytics" className="overview-stat">
<span className="overview-stat-label">Views · 7 days</span>
<span className="overview-stat-value">{data ? views7.toLocaleString() : '–'}</span>
<span className="overview-stat-note">cookieless count</span>
</Link>
</div>
</section>
<section aria-labelledby="overview-next">
<h2 id="overview-next" className="overview-section-label">
Quick actions
</h2>
<div className="overview-actions">
<Link className="admin-btn admin-btn-ghost" href="/admin/journal">
<Icons.IconPlus size={14} /> New journal entry
</Link>
<Link className="admin-btn admin-btn-ghost" href="/admin/messages">
<Icons.IconFileText size={14} /> Messages
</Link>
<Link className="admin-btn admin-btn-ghost" href="/admin/settings/theme">
<Icons.IconPalette size={14} /> Theme
</Link>
<Link className="admin-btn admin-btn-ghost" href="/admin/diagnostics">
<Icons.IconShieldCheck size={14} /> Diagnostics
</Link>
</div>
</section>
</div>
);
}