release: v0.15.0 (#585)

Eighteen changes since v0.14.0. See CHANGELOG.md for the full list and the upgrade notes (bump IMAGE_CACHE_VERSION).
This commit is contained in:
Ralksta
2026-09-19 22:24:10 +02:00
committed by GitHub
parent e99d3f2f1b
commit c3f92ca4ec
74 changed files with 4791 additions and 2146 deletions

View File

@@ -4,9 +4,8 @@ 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 { systemHealth } from './systemHealth';
import { DOCTOR_LEVEL_EVENT, systemHealth } from './systemHealth';
import * as Icons from './Icons';
interface Props {
@@ -30,7 +29,6 @@ export default function AdminDashboard({ onLogout, children }: Props) {
// 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);
@@ -104,6 +102,14 @@ export default function AdminDashboard({ onLogout, children }: Props) {
fetchDoctor();
}, [fetchDoctor]);
// 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<DoctorLevel>).detail);
window.addEventListener(DOCTOR_LEVEL_EVENT, onLevel);
return () => window.removeEventListener(DOCTOR_LEVEL_EVENT, onLevel);
}, []);
const immichIndicator = indicator(
status?.immich?.status === 'connected',
'Connected',
@@ -242,15 +248,13 @@ export default function AdminDashboard({ onLogout, children }: Props) {
</div>
</div>
<div className="status-dropdown-footer">
<button
<Link
href="/admin/diagnostics"
className="admin-btn admin-btn-sm"
onClick={() => {
setShowStatus(false);
setShowDoctor(true);
}}
onClick={() => setShowStatus(false)}
>
<Icons.IconShieldCheck size={14} /> Diagnostics
</button>
</Link>
<button
className="admin-btn admin-btn-sm"
onClick={() => {
@@ -300,14 +304,6 @@ export default function AdminDashboard({ onLogout, children }: Props) {
<main className="admin-main">{children}</main>
<DoctorModal
isOpen={showDoctor}
onClose={() => {
setShowDoctor(false);
fetchDoctor();
}}
/>
<BackupManagerModal
isOpen={showBackupModal}
onClose={() => setShowBackupModal(false)}

View File

@@ -2,6 +2,7 @@
import { useState, useMemo } from 'react';
import { useScrollLock } from './useScrollLock';
import { useModalDialog } from '@/hooks/useModalDialog';
import { IconFolder } from './Icons';
interface ImmichAlbumInfo {
@@ -37,11 +38,20 @@ export default function AlbumPicker({ albums, onSelect, onClose, usedAlbumIds }:
);
}, [albums, search]);
const cardRef = useModalDialog(onClose);
return (
<div className="picker-overlay" onClick={onClose}>
<div className="picker-modal" onClick={(e) => e.stopPropagation()}>
<div
className="picker-modal"
ref={cardRef}
role="dialog"
aria-modal="true"
aria-labelledby="album-picker-title"
onClick={(e) => e.stopPropagation()}
>
<div className="picker-header">
<h3>Select Album</h3>
<h3 id="album-picker-title">Select Album</h3>
<button className="admin-btn-icon" onClick={onClose}>
×
</button>

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useModalDialog } from '@/hooks/useModalDialog';
import { IconCheck, IconFolder, IconImage, IconStar } from './Icons';
import { useScrollLock } from './useScrollLock';
@@ -29,6 +30,7 @@ export default function AssetPicker({
title,
}: Props) {
useScrollLock(true);
const cardRef = useModalDialog(onClose);
const [tab, setTab] = useState<Tab>(albumId ? 'album' : 'favorites');
const [assets, setAssets] = useState<AssetInfo[]>([]);
const [loading, setLoading] = useState(true);
@@ -114,9 +116,16 @@ export default function AssetPicker({
return (
<div className="picker-overlay" onClick={onClose}>
<div className="asset-picker-modal" onClick={(e) => e.stopPropagation()}>
<div
className="asset-picker-modal"
ref={cardRef}
role="dialog"
aria-modal="true"
aria-labelledby="asset-picker-title"
onClick={(e) => e.stopPropagation()}
>
<div className="picker-header">
<h3>{title || 'Select Hero Image'}</h3>
<h3 id="asset-picker-title">{title || 'Select Hero Image'}</h3>
<button className="admin-btn-icon" onClick={onClose}>
×
</button>

View File

@@ -1,9 +1,10 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import type { BackupItem } from '@/app/api/admin/backups/route';
import type { BackupItem, BackupTarget } from '@/app/api/admin/backups/route';
import * as Icons from './Icons';
import { useScrollLock } from './useScrollLock';
import { useModalDialog } from '@/hooks/useModalDialog';
interface Props {
isOpen: boolean;
@@ -11,17 +12,25 @@ interface Props {
onRestoreSuccess: () => void;
}
const TABS: { target: BackupTarget; label: string; file: string }[] = [
{ target: 'gallery', label: 'Gallery', file: 'gallery.yaml' },
{ target: 'settings', label: 'Settings', file: 'settings.yaml' },
{ target: 'about', label: 'About', file: 'about.md' },
{ target: 'journal', label: 'Journal', file: 'journal entries' },
];
type BackupLists = Record<BackupTarget, BackupItem[]>;
const EMPTY_LISTS: BackupLists = { gallery: [], settings: [], about: [], journal: [] };
export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }: Props) {
const [activeTab, setActiveTab] = useState<'gallery' | 'settings'>('gallery');
const [activeTab, setActiveTab] = useState<BackupTarget>('gallery');
const [showAllBackups, setShowAllBackups] = useState(false);
const [backups, setBackups] = useState<{ gallery: BackupItem[]; settings: BackupItem[] }>({
gallery: [],
settings: [],
});
const [backups, setBackups] = useState<BackupLists>(EMPTY_LISTS);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [restoringFilename, setRestoringFilename] = useState<string | null>(null);
const [confirmFilename, setConfirmFilename] = useState<string | null>(null);
const [confirmItem, setConfirmItem] = useState<BackupItem | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
const fetchBackups = useCallback(async () => {
@@ -33,7 +42,7 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
throw new Error('Failed to fetch backup history');
}
const data = await res.json();
setBackups(data.backups || { gallery: [], settings: [] });
setBackups({ ...EMPTY_LISTS, ...data.backups });
} catch (err: any) {
setError(err.message || 'Error loading backups');
} finally {
@@ -44,17 +53,20 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
useEffect(() => {
if (isOpen) {
fetchBackups();
setConfirmFilename(null);
setConfirmItem(null);
setSuccessMsg(null);
setShowAllBackups(false);
}
}, [isOpen, fetchBackups]);
useScrollLock(isOpen);
/* Before the early `return null`: hooks must not run conditionally. */
const cardRef = useModalDialog(onClose, isOpen);
if (!isOpen) return null;
async function handleRestore(filename: string) {
async function handleRestore(item: BackupItem) {
const filename = item.filename;
setRestoringFilename(filename);
setError(null);
setSuccessMsg(null);
@@ -63,7 +75,7 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
const res = await fetch('/api/admin/backups', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ backupFilename: filename }),
body: JSON.stringify({ backupFilename: filename, target: item.target }),
});
const data = await res.json();
@@ -72,8 +84,12 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
throw new Error(data.error || 'Failed to restore backup');
}
setSuccessMsg(`Backup restored successfully! (${filename})`);
setConfirmFilename(null);
setSuccessMsg(
item.slug
? `Journal entry "${item.slug}" restored.`
: `Backup restored successfully! (${filename})`,
);
setConfirmItem(null);
await fetchBackups();
onRestoreSuccess();
} catch (err: any) {
@@ -90,12 +106,20 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
return (
<div className="backup-modal-backdrop" onClick={onClose}>
<div className="backup-modal-container" onClick={(e) => e.stopPropagation()}>
<div
className="backup-modal-container"
ref={cardRef}
role="dialog"
aria-modal="true"
aria-labelledby="backup-modal-title"
onClick={(e) => e.stopPropagation()}
>
<div className="backup-modal-header">
<div>
<h2>Backup History & Restoration</h2>
<h2 id="backup-modal-title">Backup History &amp; Restoration</h2>
<p className="backup-modal-subtitle">
Restore previous states of your configuration files with 1-click.
Restore an earlier version of your pages, settings, about page or journal — including
deleted journal entries.
</p>
</div>
<button className="backup-modal-close-btn" onClick={onClose} aria-label="Close">
@@ -105,26 +129,19 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
{/* Tab Selection */}
<div className="backup-modal-tabs">
<button
className={`backup-modal-tab ${activeTab === 'gallery' ? 'active' : ''}`}
onClick={() => {
setActiveTab('gallery');
setConfirmFilename(null);
setShowAllBackups(false);
}}
>
Gallery Backups ({backups.gallery.length})
</button>
<button
className={`backup-modal-tab ${activeTab === 'settings' ? 'active' : ''}`}
onClick={() => {
setActiveTab('settings');
setConfirmFilename(null);
setShowAllBackups(false);
}}
>
Settings Backups ({backups.settings.length})
</button>
{TABS.map((tab) => (
<button
key={tab.target}
className={`backup-modal-tab ${activeTab === tab.target ? 'active' : ''}`}
onClick={() => {
setActiveTab(tab.target);
setConfirmItem(null);
setShowAllBackups(false);
}}
>
{tab.label} ({backups[tab.target].length})
</button>
))}
</div>
{/* Status Messages */}
@@ -132,25 +149,25 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
{successMsg && <div className="backup-status-alert success">{successMsg}</div>}
{/* Confirmation Modal Section */}
{confirmFilename && (
{confirmItem && (
<div className="backup-confirm-box">
<div className="backup-confirm-content">
<strong>Confirm Restoration</strong>
<p>
Are you sure you want to restore <code>{confirmFilename}</code>? A safety snapshot
of your current state will be created automatically before reverting.
Are you sure you want to restore <code>{confirmItem.filename}</code>? A safety
snapshot of your current state will be created automatically before reverting.
</p>
<div className="backup-confirm-actions">
<button
className="btn btn-secondary"
onClick={() => setConfirmFilename(null)}
onClick={() => setConfirmItem(null)}
disabled={!!restoringFilename}
>
Cancel
</button>
<button
className="btn btn-danger"
onClick={() => handleRestore(confirmFilename)}
onClick={() => handleRestore(confirmItem)}
disabled={!!restoringFilename}
>
{restoringFilename ? 'Restoring...' : 'Yes, Restore Now'}
@@ -165,7 +182,9 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
{loading ? (
<div className="backup-loading">Loading backups...</div>
) : currentList.length === 0 ? (
<div className="backup-empty">No backups available yet for {activeTab}.yaml</div>
<div className="backup-empty">
No backups available yet for {TABS.find((t) => t.target === activeTab)?.file}
</div>
) : (
<div className="backup-list">
{visibleList.map((item) => {
@@ -182,7 +201,16 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
<div key={item.filename} className="backup-item">
<div className="backup-item-info">
<div className="backup-item-title">
{item.slug && <strong className="backup-slug">{item.slug}</strong>}
<span className="backup-filename">{formattedDate}</span>
{item.isDeleted && (
<span
className="backup-badge deleted"
title="Snapshot taken when the entry was deleted"
>
Deleted entry
</span>
)}
{item.isPreRestore && (
<span
className="backup-badge pre-restore"
@@ -198,8 +226,8 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
</div>
<button
className="btn btn-outline-danger btn-sm"
onClick={() => setConfirmFilename(item.filename)}
disabled={!!restoringFilename || confirmFilename === item.filename}
onClick={() => setConfirmItem(item)}
disabled={!!restoringFilename || confirmItem?.filename === item.filename}
>
Restore
</button>

View File

@@ -0,0 +1,574 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import BackupManagerModal from './BackupManagerModal';
import * as Icons from './Icons';
import { DOCTOR_LEVEL_EVENT } from './systemHealth';
import type { DoctorFinding, DoctorLevel } from '@/lib/admin/doctor';
import type { AltTextReport } from '@/lib/admin/alt-text';
/**
* Diagnostics as a page (/admin/diagnostics).
*
* It started as a modal (#491), which was fine for seven one-line findings and
* nothing else: a photo list does not fit in 640px, a modal cannot be linked
* from an issue, and backups and the cache lived in two further places. This
* page holds all of it, worst news first.
*
* The status badge in the top bar still answers only from the doctor. Alt text
* is shown here but kept out of the badge on purpose: most libraries have some
* photos without a description, and a badge that is never green stops being
* read.
*/
interface StatusData {
immich?: { status: 'connected' | 'disconnected' };
config?: { status: 'valid' | 'invalid' | 'setup'; gallery: string; settings: string };
cache?: { size: number };
backups?: { count: number; lastBackup: string | null };
update?: { current: string; latest?: string; updateAvailable?: boolean };
}
type AltTextData = AltTextReport & {
unreadable: number;
immichUrl: string;
/** `external-domain` is Immich's own public address; `api-url` may be internal. */
immichUrlSource: 'external-domain' | 'api-url';
};
/** Which group a doctor check belongs to. An unknown id lands in Content. */
const GROUPS: { id: string; title: string; checks: string[] }[] = [
{ id: 'connection', title: 'Connection', checks: ['immich-api', 'album-ids'] },
{ id: 'security', title: 'Security', checks: ['auth-secret', 'passwords', 'proxy-hops'] },
{ id: 'content', title: 'Content', checks: ['albums-shared', 'content-writable', 'alt-text'] },
];
const LEVEL_LABEL: Record<DoctorLevel, string> = { ok: 'OK', warn: 'Check', error: 'Problem' };
const LEVEL_RANK: Record<DoctorLevel, number> = { error: 0, warn: 1, ok: 2 };
const DOCS = 'https://github.com/ralksta/immich-folio/blob/main';
/** Where each doctor finding is fixed. Admin paths are in-app links. */
const FIXES: Record<string, { label: string; href: string }> = {
'auth-secret': {
label: 'How to set it',
href: `${DOCS}/README.md#environment-variables-envlocal`,
},
'proxy-hops': { label: 'How to set it', href: `${DOCS}/docs/gallery-config.md#trusted-proxies` },
passwords: { label: 'Open settings', href: '/admin/settings/security' },
'album-ids': { label: 'Open pages', href: '/admin/pages' },
'albums-shared': { label: 'Open pages', href: '/admin/pages' },
'content-writable': { label: 'How to fix', href: `${DOCS}/docs/admin-panel.md#docker-usage` },
'immich-api': {
label: 'API key permissions',
href: `${DOCS}/README.md#immich-api-key-permissions`,
},
};
/** Photos shown per album before the "+n more" tile. */
const PREVIEW_COUNT = 12;
function groupOf(id: string): string {
return GROUPS.find((g) => g.checks.includes(id))?.id ?? 'content';
}
/** The alt-text report, phrased as one more finding so it sorts with the rest. */
function altTextFinding(report: AltTextData): DoctorFinding {
if (!report.captionsEnabled) {
return {
id: 'alt-text',
level: 'warn',
title: 'Photo descriptions are not used as alt text',
detail:
'“Photo Description” is switched off in Settings → General, so every photo on the site has an empty alt text, whatever Immich holds.',
};
}
const missing = report.total - report.described;
const unread = report.unreadable
? ` ${report.unreadable} ${report.unreadable === 1 ? 'album' : 'albums'} could not be read and are not counted.`
: '';
if (!missing) {
return {
id: 'alt-text',
level: 'ok',
title: `All ${report.total} published photos have alt text`,
detail: `Every photo carries its Immich description.${unread}`,
};
}
return {
id: 'alt-text',
level: 'warn',
title: `${missing} published ${missing === 1 ? 'photo has' : 'photos have'} no alt text`,
detail: `Folio uses the Immich description as alt text. Without one, screen readers announce nothing and search engines index nothing for the photo.${unread}`,
};
}
export default function DiagnosticsView() {
const [status, setStatus] = useState<StatusData | null>(null);
const [findings, setFindings] = useState<DoctorFinding[] | null>(null);
const [altText, setAltText] = useState<AltTextData | null>(null);
const [altTextFailed, setAltTextFailed] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [ranAt, setRanAt] = useState<Date | null>(null);
const [copied, setCopied] = useState(false);
const [showBackups, setShowBackups] = useState(false);
const [clearing, setClearing] = useState(false);
const [altTextOpen, setAltTextOpen] = useState(false);
const [expandedAlbums, setExpandedAlbums] = useState<Set<string>>(new Set());
const fetchStatus = useCallback(async () => {
try {
const res = await fetch('/api/admin/status');
setStatus(res.ok ? await res.json() : null);
} catch {
setStatus(null);
}
}, []);
const run = useCallback(async () => {
setLoading(true);
setError(null);
setCopied(false);
// The alt-text report reads every published album and is the slow one, so
// it arrives on its own and does not hold up the rest of the page.
setAltTextFailed(false);
fetch('/api/admin/alt-text')
.then(async (res) => {
if (!res.ok) throw new Error();
setAltText(await res.json());
})
.catch(() => {
setAltText(null);
setAltTextFailed(true);
});
try {
const [doctorRes] = await Promise.all([fetch('/api/admin/doctor'), fetchStatus()]);
if (!doctorRes.ok) throw new Error(`Diagnostics failed (${doctorRes.status})`);
const data = await doctorRes.json();
setFindings(data.findings || []);
setRanAt(new Date());
if (data.level) {
window.dispatchEvent(new CustomEvent(DOCTOR_LEVEL_EVENT, { detail: data.level }));
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Diagnostics failed');
} finally {
setLoading(false);
}
}, [fetchStatus]);
useEffect(() => {
run();
}, [run]);
async function clearCache() {
setClearing(true);
try {
await fetch('/api/admin/reload', { method: 'POST' });
await fetchStatus();
} finally {
setClearing(false);
}
}
const all: DoctorFinding[] = [...(findings ?? []), ...(altText ? [altTextFinding(altText)] : [])];
const attention = all
.filter((f) => f.level !== 'ok')
.sort((a, b) => LEVEL_RANK[a.level] - LEVEL_RANK[b.level]);
/** Markdown, so it can be pasted straight into a GitHub issue. */
async function copyReport() {
const body = all
.map((f) => `- **${LEVEL_LABEL[f.level]}** — ${f.title}\n ${f.detail}`)
.join('\n');
const version = status?.update?.current ? `\nVersion ${status.update.current}\n` : '';
try {
await navigator.clipboard.writeText(`### Immich Folio diagnostics\n${version}\n${body}\n`);
setCopied(true);
} catch {
setError('Could not copy — your browser refused clipboard access.');
}
}
function toggleAlbum(id: string) {
setExpandedAlbums((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
const headline = loading
? 'Checking…'
: attention.length === 0
? 'Nothing needs your attention'
: `${attention.length} of ${all.length} checks want a look`;
const renderAltTextList = (report: AltTextData) => (
<div className="diag-alt" id="diag-alt-list">
<div className="diag-coverage">
<div className="diag-coverage-bar" aria-hidden="true">
<span style={{ width: `${Math.round((report.described / report.total) * 100)}%` }} />
</div>
<span>
{report.described} of {report.total} described ·{' '}
{Math.round((report.described / report.total) * 100)}%
</span>
</div>
{report.albums.map((album) => {
const expanded = expandedAlbums.has(album.albumId);
const shown = expanded ? album.missing : album.missing.slice(0, PREVIEW_COUNT);
const hidden = album.missing.length - shown.length;
return (
<div className="diag-album" key={album.albumId}>
<div className="diag-album-head">
<span className="diag-album-name">
{album.albumName}
<span className="diag-album-path">{album.path}</span>
</span>
<span className="diag-album-count">
{album.missing.length} of {album.total} missing
</span>
</div>
<div className="diag-thumbs">
{shown.map((gap) => (
<a
key={gap.assetId}
className="diag-thumb"
href={`${report.immichUrl}/photos/${gap.assetId}`}
target="_blank"
rel="noopener noreferrer"
title={`${gap.fileName} — open in Immich`}
>
{/* eslint-disable-next-line @next/next/no-img-element -- admin thumbnail proxy, not an optimisable image */}
<img src={`/api/admin/thumbnail/${gap.assetId}`} alt="" loading="lazy" />
<span className="diag-thumb-name">{gap.fileName}</span>
</a>
))}
{hidden > 0 && (
<button
type="button"
className="diag-thumb-more"
onClick={() => toggleAlbum(album.albumId)}
>
+{hidden} more
</button>
)}
</div>
</div>
);
})}
<p className="diag-note">
Add the description in Immich, then press Reload in the top bar.{' '}
{report.immichUrlSource === 'external-domain' ? (
<>
Photos open at <code>{report.immichUrl}</code>, Immich&rsquo;s external domain.
</>
) : (
<>
Photos open at <code>{report.immichUrl}</code> — the address Folio reaches Immich at,
which your browser may not be able to open. Set <strong>External domain</strong> in
Immich (Administration → Settings → Server) and the links use that instead.
</>
)}
</p>
</div>
);
/**
* The way to the fix, for a finding that wants one. Where the fix lives in
* the admin the button goes there; where it is an environment variable or a
* volume, it goes to the part of the docs that explains it. A finding with
* no entry gets no button rather than a label that looks like one.
*/
const renderFix = (f: DoctorFinding) => {
const id = f.id;
// A finding about exactly one album opens the page it is published on.
if (f.albumIds?.length === 1) {
return (
<Link
href={`/admin/pages?album=${encodeURIComponent(f.albumIds[0])}`}
className="admin-btn admin-btn-sm"
>
Open in Pages
</Link>
);
}
if (id === 'alt-text') {
if (altText && !altText.captionsEnabled) {
return (
<Link href="/admin/settings/general" className="admin-btn admin-btn-sm">
Open settings
</Link>
);
}
return null; // The photo list toggle is rendered separately.
}
const fix = FIXES[id];
if (!fix) return null;
return fix.href.startsWith('/') ? (
<Link href={fix.href} className="admin-btn admin-btn-sm">
{fix.label}
</Link>
) : (
<a
href={fix.href}
target="_blank"
rel="noopener noreferrer"
className="admin-btn admin-btn-sm"
>
{fix.label} ↗
</a>
);
};
const renderFinding = (f: DoctorFinding) => {
const isAltText = f.id === 'alt-text' && altText && altText.captionsEnabled;
const hasList = isAltText && altText.albums.length > 0;
return (
<li key={f.id} className={`diag-check diag-check--${f.level}`}>
<div className="diag-check-row">
<span className="diag-dot" aria-hidden="true" />
<div className="diag-check-body">
<span className="diag-check-title">
{f.level !== 'ok' && <span className="sr-only">{LEVEL_LABEL[f.level]}: </span>}
{f.title}
</span>
<span className="diag-check-detail">{f.detail}</span>
</div>
<div className="diag-check-side">
{f.level !== 'ok' && renderFix(f)}
{hasList && (
<button
type="button"
className="admin-btn admin-btn-sm"
aria-expanded={altTextOpen}
aria-controls="diag-alt-list"
onClick={() => setAltTextOpen(!altTextOpen)}
>
{altTextOpen ? 'Hide photos' : 'Show photos'}
</button>
)}
</div>
</div>
{hasList && altTextOpen && renderAltTextList(altText)}
</li>
);
};
const okIn = (groupId: string) =>
all.filter((f) => f.level === 'ok' && groupOf(f.id) === groupId);
const immichOk = status?.immich?.status === 'connected';
const configState = status?.config?.status;
return (
<div className="diag-page">
<div className="diag-head">
<div>
<h2>{headline}</h2>
<p className="diag-sub">
{ranAt ? `Last run ${ranAt.toLocaleString()}` : 'Running the checks…'}
</p>
</div>
<div className="diag-actions">
<button className="admin-btn admin-btn-sm" onClick={run} disabled={loading}>
<Icons.IconRefresh size={14} /> {loading ? 'Checking…' : 'Run again'}
</button>
<button
className="admin-btn admin-btn-sm"
onClick={copyReport}
disabled={loading || !all.length}
title="Markdown, ready to paste into an issue"
>
<Icons.IconCopy size={14} /> {copied ? 'Copied' : 'Copy report'}
</button>
</div>
</div>
{error && <div className="admin-error">{error}</div>}
<section className="diag-vitals" aria-label="Overview">
<div className="diag-vital">
<span className="diag-vital-label">Immich</span>
<span className="diag-vital-val">
<span
className={`diag-dot diag-dot--${status ? (immichOk ? 'ok' : 'error') : 'unknown'}`}
/>
{status ? (immichOk ? 'Connected' : 'Disconnected') : '—'}
</span>
</div>
<div className="diag-vital">
<span className="diag-vital-label">Config</span>
<span className="diag-vital-val">
<span
className={`diag-dot diag-dot--${
configState === 'valid' ? 'ok' : configState === 'invalid' ? 'error' : 'unknown'
}`}
/>
{configState === 'valid'
? 'Valid'
: configState === 'invalid'
? 'Invalid'
: configState === 'setup'
? 'Not set up'
: '—'}
</span>
</div>
<div className="diag-vital">
<span className="diag-vital-label">Backups</span>
<span className="diag-vital-val">{status?.backups?.count ?? '—'}</span>
<span className="diag-vital-meta">
{status?.backups?.lastBackup
? `latest ${new Date(status.backups.lastBackup).toLocaleDateString()}`
: 'none yet'}
</span>
</div>
<div className="diag-vital">
<span className="diag-vital-label">Version</span>
<span className="diag-vital-val">{status?.update?.current ?? '—'}</span>
<span className="diag-vital-meta">
{status?.update?.updateAvailable ? (
<a
href="https://github.com/ralksta/immich-folio/releases/latest"
target="_blank"
rel="noopener noreferrer"
>
{status.update.latest} available
</a>
) : status?.update?.current ? (
'up to date'
) : (
''
)}
</span>
</div>
</section>
{loading && !findings ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : (
<>
{attention.length > 0 && (
<section className="diag-group" aria-labelledby="diag-attention">
<h3 id="diag-attention" className="diag-group-title">
Needs attention
</h3>
<ul className="diag-checks">{attention.map(renderFinding)}</ul>
</section>
)}
{GROUPS.map((group) => {
const ok = okIn(group.id);
const pendingAltText = group.id === 'content' && !altText && !altTextFailed;
const failedAltText = group.id === 'content' && altTextFailed;
if (!ok.length && !pendingAltText && !failedAltText) {
return null;
}
return (
<section key={group.id} className="diag-group" aria-labelledby={`diag-${group.id}`}>
<h3 id={`diag-${group.id}`} className="diag-group-title">
{group.title}
</h3>
<ul className="diag-checks">
{ok.map(renderFinding)}
{pendingAltText && (
<li className="diag-check diag-check--unknown">
<div className="diag-check-row">
<span className="diag-dot" aria-hidden="true" />
<div className="diag-check-body">
<span className="diag-check-title">Checking alt text…</span>
<span className="diag-check-detail">
Reading every published album; large libraries take a moment.
</span>
</div>
</div>
</li>
)}
{failedAltText && (
<li className="diag-check diag-check--unknown">
<div className="diag-check-row">
<span className="diag-dot" aria-hidden="true" />
<div className="diag-check-body">
<span className="diag-check-title">Alt text could not be checked</span>
<span className="diag-check-detail">
The albums did not load. If Immich is disconnected, that is the reason —
try again once it is back.
</span>
</div>
</div>
</li>
)}
</ul>
</section>
);
})}
<section className="diag-group" aria-labelledby="diag-storage">
<h3 id="diag-storage" className="diag-group-title">
Storage &amp; cache
</h3>
<ul className="diag-checks">
<li className="diag-check diag-check--ok">
<div className="diag-check-row">
<span className="diag-dot" aria-hidden="true" />
<div className="diag-check-body">
<span className="diag-check-title">
{status?.backups?.count ?? 0} config backups
</span>
<span className="diag-check-detail">
Written before every admin save, ten per file.
</span>
</div>
<div className="diag-check-side">
<button className="admin-btn admin-btn-sm" onClick={() => setShowBackups(true)}>
<Icons.IconArchive size={14} /> Manage backups
</button>
</div>
</div>
</li>
<li className="diag-check diag-check--ok">
<div className="diag-check-row">
<span className="diag-dot" aria-hidden="true" />
<div className="diag-check-body">
<span className="diag-check-title">
In-memory cache holds {status?.cache?.size ?? 0} items
</span>
<span className="diag-check-detail">
Album lists and photo metadata from Immich. Clearing it makes the next page
views slower, nothing else.
</span>
</div>
<div className="diag-check-side">
<button
className="admin-btn admin-btn-sm"
onClick={clearCache}
disabled={clearing}
>
<Icons.IconRefresh size={14} /> {clearing ? 'Clearing…' : 'Clear cache'}
</button>
</div>
</div>
</li>
</ul>
</section>
</>
)}
<BackupManagerModal
isOpen={showBackups}
onClose={() => setShowBackups(false)}
onRestoreSuccess={fetchStatus}
/>
</div>
);
}

View File

@@ -1,131 +0,0 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import type { DoctorFinding, DoctorLevel } from '@/lib/admin/doctor';
import * as Icons from './Icons';
import { useScrollLock } from './useScrollLock';
interface Props {
isOpen: boolean;
onClose: () => void;
}
const LEVEL_LABEL: Record<DoctorLevel, string> = {
ok: 'OK',
warn: 'Check',
error: 'Problem',
};
/**
* The config doctor's report (#491).
*
* Most "it does not work for me" reports are one of a handful of
* misconfigurations that fail silently — a wrong proxy hop count, a deleted
* album ID, a plaintext password. Each of those already logs a warning to the
* server log, where nobody looks. This puts them where the operator is.
*/
export default function DoctorModal({ isOpen, onClose }: Props) {
const [findings, setFindings] = useState<DoctorFinding[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const run = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/admin/doctor');
if (!res.ok) throw new Error(`Diagnostics failed (${res.status})`);
const data = await res.json();
setFindings(data.findings || []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Diagnostics failed');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (isOpen) {
setCopied(false);
run();
}
}, [isOpen, run]);
useScrollLock(isOpen);
if (!isOpen) return null;
/** Markdown, so it can be pasted straight into a GitHub issue. */
async function copyReport() {
const body = findings
.map((f) => `- **${LEVEL_LABEL[f.level]}** — ${f.title}\n ${f.detail}`)
.join('\n');
try {
await navigator.clipboard.writeText(`### Immich Folio diagnostics\n\n${body}\n`);
setCopied(true);
} catch {
setError('Could not copy — your browser refused clipboard access.');
}
}
const problems = findings.filter((f) => f.level !== 'ok').length;
return (
<div className="backup-modal-backdrop" onClick={onClose}>
<div className="backup-modal-container" onClick={(e) => e.stopPropagation()}>
<div className="backup-modal-header">
<div>
<h2>Diagnostics</h2>
<p className="backup-modal-subtitle">
{loading
? 'Checking…'
: problems === 0
? 'Nothing needs your attention.'
: `${problems} of ${findings.length} checks want a look.`}
</p>
</div>
<button className="backup-modal-close-btn" onClick={onClose} aria-label="Close">
<Icons.IconX size={18} />
</button>
</div>
<div className="backup-modal-body">
{error && <div className="admin-error">{error}</div>}
{loading && !findings.length ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : (
<ul className="doctor-list">
{findings.map((f) => (
<li key={f.id} className={`doctor-item doctor-item--${f.level}`}>
<span className="doctor-dot" aria-hidden="true" />
<span className="doctor-item-body">
<span className="doctor-item-title">{f.title}</span>
<span className="doctor-item-detail">{f.detail}</span>
</span>
</li>
))}
</ul>
)}
</div>
<div className="backup-modal-footer">
<button className="admin-btn admin-btn-sm" onClick={run} disabled={loading}>
<Icons.IconRefresh size={14} /> {loading ? 'Checking…' : 'Run again'}
</button>
<button
className="admin-btn admin-btn-sm"
onClick={copyReport}
disabled={loading || !findings.length}
title="Markdown, ready to paste into an issue"
>
<Icons.IconCopy size={14} /> {copied ? 'Copied' : 'Copy report'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -218,6 +218,7 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
{block.type === 'heading' && (
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<select
aria-label="Heading level"
value={block.level}
onChange={(e) =>
handleUpdateBlock(idx, { ...block, level: Number(e.target.value) })

View File

@@ -1,7 +1,6 @@
'use client';
import { useState } from 'react';
import DoctorModal from './DoctorModal';
import Link from 'next/link';
import * as Icons from './Icons';
import { en } from '@/lib/i18n/locales/en';
import { LIGHTBOX_SHORTCUTS, shortcutDisplayKeys } from '@/lib/lightboxShortcuts';
@@ -19,8 +18,6 @@ import { LIGHTBOX_SHORTCUTS, shortcutDisplayKeys } from '@/lib/lightboxShortcuts
* English-only, hence `en` directly.
*/
export default function HelpView() {
const [showDoctor, setShowDoctor] = useState(false);
return (
<div className="settings-panel">
<div className="settings-section-header">
@@ -80,13 +77,11 @@ export default function HelpView() {
</div>
<div className="help-action">
<button type="button" className="admin-btn" onClick={() => setShowDoctor(true)}>
<Icons.IconShieldCheck size={14} /> Run diagnostics
</button>
<Link href="/admin/diagnostics" className="admin-btn">
<Icons.IconShieldCheck size={14} /> Open diagnostics
</Link>
</div>
</div>
<DoctorModal isOpen={showDoctor} onClose={() => setShowDoctor(false)} />
</div>
);
}

View File

@@ -106,7 +106,12 @@ export function JournalStudio({ slug: activeSlug }: JournalStudioProps) {
};
const handleDelete = async (slug: string) => {
if (!confirm(`Are you sure you want to delete "${slug}"?`)) return;
if (
!confirm(
`Delete "${slug}"?\n\nA copy is kept and can be restored from Backups on the dashboard.`,
)
)
return;
try {
const res = await fetch(`/api/admin/journal/${slug}`, {
@@ -812,6 +817,7 @@ function JournalEditor({ slug, onBack }: JournalEditorProps) {
{block.type === 'heading' && (
<div className="journal-heading-row">
<select
aria-label="Heading level"
className="admin-input journal-level-select"
value={block.level}
onChange={(e) =>
@@ -918,6 +924,7 @@ function JournalEditor({ slug, onBack }: JournalEditorProps) {
>
<div className="journal-photo-layout-row">
<select
aria-label="Photo layout"
className="admin-input"
value={block.layout}
onChange={(e) =>

View File

@@ -26,6 +26,7 @@ import SaveBar from './SaveBar';
import AlbumDrawer from './page-builder/AlbumDrawer';
import { SortableAlbumCard } from './page-builder/AlbumCard';
import SubpageDrawer from './page-builder/SubpageDrawer';
import { findAlbumAddress } from './page-builder/findAlbumAddress';
import {
seedCoverGrid,
type ActiveEditAlbumAddress,
@@ -280,6 +281,8 @@ export default function PageBuilder() {
const [editingAlbumAddress, setEditingAlbumAddress] = useState<ActiveEditAlbumAddress | null>(
null,
);
/** Album a `?album=` link pointed at, marked in its subpage sheet for a moment. */
const [linkedAlbumId, setLinkedAlbumId] = useState<string | null>(null);
// Keep the builder still behind either drawer. One combined lock rather than
// one per drawer: the album drawer opens from inside the subpage drawer, and
@@ -331,6 +334,26 @@ export default function PageBuilder() {
useUnsavedGuard(dirty);
// A subpage opened from a `?album=` link can hold dozens of albums: bring the
// linked one into view, and let the mark fade after a moment. The mark itself
// is a prop on the tile — a class added to the DOM here would be wiped by the
// next render of the tile.
useEffect(() => {
if (!linkedAlbumId || expandedSubpage === null) return;
const frame = requestAnimationFrame(() => {
const tile = document.querySelector<HTMLElement>(
`.subpage-drawer-container [data-album-id="${window.CSS.escape(linkedAlbumId)}"]`,
);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
tile?.scrollIntoView({ block: 'center', behavior: reduce ? 'auto' : 'smooth' });
});
const fade = window.setTimeout(() => setLinkedAlbumId(null), 2600);
return () => {
cancelAnimationFrame(frame);
window.clearTimeout(fade);
};
}, [linkedAlbumId, expandedSubpage]);
async function loadData() {
setLoading(true);
try {
@@ -341,7 +364,9 @@ export default function PageBuilder() {
if (galleryRes.ok) {
const { gallery: raw } = await galleryRes.json();
setGallery(parseGalleryYaml(raw));
const parsed = parseGalleryYaml(raw);
setGallery(parsed);
openAlbumFromLink(parsed);
}
if (albumsRes.ok) {
@@ -355,6 +380,35 @@ export default function PageBuilder() {
}
}
/**
* `?album=<id>` opens the place that album is published from — the
* diagnostics page links here from a finding about one album. That is the
* subpage's sheet when the album sits on a subpage: the fix for such a
* finding (take it off the page, or check which page shows it) lives there,
* not in the album's own details. A standalone album has no page, so its
* own drawer is the place.
*
* One-shot: the parameter is dropped once read, so a reload after closing
* the sheet does not open it again.
*/
function openAlbumFromLink(state: GalleryState) {
const params = new URLSearchParams(window.location.search);
const albumId = params.get('album');
if (!albumId) return;
params.delete('album');
const query = params.toString();
window.history.replaceState(null, '', window.location.pathname + (query ? `?${query}` : ''));
const address = findAlbumAddress(state, albumId);
if (!address) return;
if (address.subpageIndex !== undefined) {
setExpandedSubpage(address.subpageIndex);
setLinkedAlbumId(albumId);
} else {
setEditingAlbumAddress(address);
}
}
function parseGalleryYaml(raw: Record<string, unknown>): GalleryState {
const hero = Array.isArray(raw.hero) ? raw.hero : raw.hero ? [raw.hero as string] : [];
const albums = parseAlbumEntries(
@@ -613,6 +667,13 @@ export default function PageBuilder() {
markDirty();
}
/** The "03 — Collection" number the page renders: counted over enabled subpages only. */
function enabledPosition(index: number): number | undefined {
const sp = gallery.subpages[index];
if (!sp || sp.enabled === false) return undefined;
return gallery.subpages.slice(0, index).filter((s) => s.enabled !== false).length + 1;
}
function removeSubpage(index: number) {
if (!confirm('Remove this subpage?')) return;
setGallery((g) => ({
@@ -966,7 +1027,7 @@ export default function PageBuilder() {
items={filteredSubpages.map(({ index }) => `subpage-${index}`)}
strategy={horizontalListSortingStrategy}
>
<div className="subpage-grid">
<div className="subpage-tiles">
{filteredSubpages.map(({ sp, index }) => (
<SortableSubpageTile
key={`subpage-${index}`}
@@ -986,11 +1047,15 @@ export default function PageBuilder() {
<SubpageDrawer
sp={gallery.subpages[expandedSubpage]}
spIndex={expandedSubpage}
kickerIndex={enabledPosition(expandedSubpage)}
immichAlbums={immichAlbums}
sensors={sensors}
drawerMode={drawerMode}
onDrawerModeChange={setDrawerMode}
onClose={() => setExpandedSubpage(null)}
onClose={() => {
setExpandedSubpage(null);
setLinkedAlbumId(null);
}}
updateSubpage={updateSubpage}
removeSubpage={removeSubpage}
addSection={addSection}
@@ -1005,6 +1070,7 @@ export default function PageBuilder() {
getAlbumName={getAlbumName}
getAlbumCount={getAlbumCount}
getAlbumThumbnailId={getAlbumThumbnailId}
highlightedAlbumId={linkedAlbumId}
/>
)}
</section>

View File

@@ -1,7 +1,7 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import * as Icons from './Icons';
import SaveBar from './SaveBar';
@@ -486,11 +486,6 @@ const SETTINGS_SECTIONS = [
{ id: 'about', label: 'About' },
];
interface SettingsEditorProps {
/** Sub-section to show, taken from the /admin/settings/[section] route. */
section?: string;
}
/**
* One labelled block of related switches.
*
@@ -576,8 +571,11 @@ function SettingRow({
);
}
export default function SettingsEditor({ section }: SettingsEditorProps) {
export default function SettingsEditor() {
const router = useRouter();
// Read here rather than passed in: the editor is mounted by the settings
// layout, which does not receive the child route's params.
const section = useParams<{ section?: string }>()?.section;
// An unknown section in the URL falls back to General instead of an empty panel.
const activeSection = SETTINGS_SECTIONS.some((sec) => sec.id === section)
? (section as string)
@@ -609,6 +607,7 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
}>({});
const [aboutBody, setAboutBody] = useState('');
const [aboutLoading, setAboutLoading] = useState(false);
const [aboutLoaded, setAboutLoaded] = useState(false);
const [aboutSaving, setAboutSaving] = useState(false);
const [aboutDirty, setAboutDirty] = useState(false);
const [aboutMessage, setAboutMessage] = useState('');
@@ -636,11 +635,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
}
}, [settings.theme?.preset, settings.theme?.accent]);
// The About panel has its own route, so load its content when that section opens
// Loaded the first time the About section opens, and only then: the editor
// stays mounted across sections, so reloading on every visit would overwrite
// unsaved About edits with the file on disk.
useEffect(() => {
if (activeSection === 'about') loadAboutContent();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeSection]);
if (activeSection === 'about' && !aboutLoaded) loadAboutContent();
}, [activeSection, aboutLoaded]);
const toggleMode = (mode: 'dark' | 'light') => {
setCurrentMode(mode);
@@ -652,25 +652,9 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
// ── Keyboard shortcut: ⌘+S / Ctrl+S ─────────────────────────
const handleSaveRef = useCallback(() => {
if (activeSection === 'about') {
if (aboutDirty && !aboutSaving) saveAboutContent();
return;
}
if (dirty && !saving) {
handleSave();
}
saveAll();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
dirty,
saving,
settings,
activeSection,
aboutDirty,
aboutSaving,
aboutMeta,
aboutBody,
aboutGearText,
]);
}, [dirty, saving, settings, aboutDirty, aboutSaving, aboutMeta, aboutBody, aboutGearText]);
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
@@ -683,7 +667,7 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleSaveRef]);
useUnsavedGuard(dirty);
useUnsavedGuard(dirty || aboutDirty);
async function loadSettings() {
setLoading(true);
@@ -786,6 +770,7 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
setAboutMeta(data.meta || {});
setAboutBody(data.body || '');
setAboutGearText(data.meta?.gear?.join('\n') || '');
setAboutLoaded(true);
}
} catch (err) {
console.error('Failed to load about content:', err);
@@ -833,6 +818,16 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
}
}
/**
* Save whatever is unsaved, in either file. The owner edits one set of
* settings; that About lives in about.md and the rest in settings.yaml is
* not theirs to keep track of.
*/
function saveAll() {
if (dirty && !saving) handleSave();
if (aboutDirty && !aboutSaving) saveAboutContent();
}
function updateAboutMeta(key: string, value: unknown) {
setAboutMeta((m) => ({ ...m, [key]: value }));
setAboutDirty(true);
@@ -875,26 +870,21 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
'exif.caption': !anyMetadata,
});
// An error from either save wins over the other's success.
const saveBarMessage =
[saveMessage, aboutMessage].find((m) => m.startsWith('Error')) || saveMessage || aboutMessage;
return (
<div className="settings-editor">
{/* The About panel edits about.md, not settings.yaml, so it drives the bar itself. */}
{activeSection === 'about' ? (
<SaveBar
dirty={aboutDirty}
saving={aboutSaving}
saveMessage={aboutMessage}
onSave={saveAboutContent}
label="Save About"
/>
) : (
<SaveBar
dirty={dirty}
saving={saving}
saveMessage={saveMessage}
onSave={handleSave}
label="Save Settings"
/>
)}
{/* One bar for both files, on every section: an About edit stays visible
from Theme, and a settings edit from About. */}
<SaveBar
dirty={dirty || aboutDirty}
saving={saving || aboutSaving}
saveMessage={saveBarMessage}
onSave={saveAll}
label="Save Changes"
/>
<p className="settings-live-sync-note">
<Icons.IconRefresh size={13} /> Live Sync (No Docker restart required)
@@ -946,6 +936,7 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
<div className="admin-field">
<label>Language</label>
<select
aria-label="Language"
value={settings.lang || 'en'}
onChange={(e) => update('lang', e.target.value)}
>
@@ -1209,12 +1200,13 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
<div className="admin-field">
<label>Visitor Default Mode</label>
<select
aria-label="Visitor default mode"
value={settings.mode || 'dark'}
onChange={(e) => update('mode', e.target.value)}
>
<option value="dark">Dark</option>
<option value="light">Light</option>
<option value="auto">Follow the visitor's system</option>
<option value="auto">Follow the visitor&apos;s system</option>
</select>
<p className="admin-field-hint">
What a first-time visitor sees. The light/dark toggle in the site header still
@@ -1880,6 +1872,7 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
<div className="admin-field">
<label>Position</label>
<select
aria-label="Watermark position"
value={settings.watermark?.position || 'bottom-right'}
onChange={(e) => update('watermark.position', e.target.value)}
>

View File

@@ -38,10 +38,11 @@
border-color: var(--admin-border-hover, #444);
}
.journal-studio .admin-input:focus,
.journal-editor-container .admin-input:focus,
.journal-modal-card .admin-input:focus {
outline: none;
.journal-studio .admin-input:focus-visible,
.journal-editor-container .admin-input:focus-visible,
.journal-modal-card .admin-input:focus-visible {
outline: var(--admin-focus-ring);
outline-offset: 1px;
border-color: var(--admin-border-focus, #6366f1);
}
@@ -261,7 +262,14 @@
.journal-editor-resizer:focus-visible,
.journal-editor-split.is-dragging .journal-editor-resizer {
background: var(--admin-accent, #6366f1);
outline: none;
}
/* The resizer used to change colour on focus exactly as it does on hover,
so from the keyboard there was no telling whether it had the focus or the
mouse was merely resting on it. */
.journal-editor-resizer:focus-visible {
outline: var(--admin-focus-ring);
outline-offset: 2px;
}
/* Grip dots, and a widened invisible hit area so the divider is easy to grab. */
@@ -295,7 +303,7 @@
display: flex;
flex-direction: column;
overflow-y: auto;
background: var(--surface, #111);
background: var(--admin-bg, #111);
position: relative;
}
@@ -357,8 +365,9 @@
resize: none;
}
.journal-raw-markdown-editor:focus {
outline: none;
.journal-raw-markdown-editor:focus-visible {
outline: var(--admin-focus-ring);
outline-offset: 1px;
border-color: var(--admin-accent, #6366f1);
}

View File

@@ -15,6 +15,7 @@ export function SortableAlbumCard({
thumbnailId,
onRemove,
onEdit,
highlighted,
}: {
album: AlbumEntry;
index: number;
@@ -23,6 +24,7 @@ export function SortableAlbumCard({
thumbnailId: string | null;
onRemove: () => void;
onEdit: () => void;
highlighted?: boolean;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: `album-${album.id}-${index}`,
@@ -45,6 +47,7 @@ export function SortableAlbumCard({
onRemove={onRemove}
onEdit={onEdit}
dragListeners={listeners}
highlighted={highlighted}
/>
</div>
);
@@ -58,6 +61,8 @@ interface AlbumCardProps {
onRemove: () => void;
onEdit: () => void;
dragListeners?: Record<string, unknown>;
/** Marked briefly when a diagnostics link pointed at this album. */
highlighted?: boolean;
}
export default function AlbumCard({
@@ -68,13 +73,17 @@ export default function AlbumCard({
onRemove,
onEdit,
dragListeners,
highlighted,
}: AlbumCardProps) {
const heroThumb = album.heroImage || thumbnailId;
const hasPassword = !!album.password;
const hasTitleOverride = !!album.title;
return (
<div className={`album-tile ${hasPassword ? 'has-password' : ''}`}>
<div
className={`album-tile ${hasPassword ? 'has-password' : ''} ${highlighted ? 'album-tile--linked' : ''}`}
data-album-id={album.id}
>
<div className="album-tile-cover">
{dragListeners && (
<div className="album-tile-drag" {...dragListeners} title="Drag to reorder">

View File

@@ -106,6 +106,7 @@ export default function AlbumDrawer({
<div className="admin-field">
<label>Layout override (Experimental)</label>
<select
aria-label="Layout override"
value={album.grid?.layout || ''}
onChange={(e) => {
const layout = e.target.value || undefined;

View File

@@ -45,6 +45,8 @@ import {
interface SubpageDrawerProps {
sp: Subpage;
spIndex: number;
/** 1-based position among the enabled subpages; undefined while disabled. */
kickerIndex?: number;
immichAlbums: ImmichAlbumInfo[];
sensors: SensorDescriptor<SensorOptions>[];
drawerMode: 'edit' | 'preview';
@@ -64,6 +66,8 @@ interface SubpageDrawerProps {
getAlbumName: (id: string) => string;
getAlbumCount: (id: string) => number;
getAlbumThumbnailId: (id: string) => string | null;
/** Album to mark, when the sheet was opened from a diagnostics link. */
highlightedAlbumId?: string | null;
}
/**
@@ -76,6 +80,7 @@ interface SubpageDrawerProps {
export default function SubpageDrawer({
sp,
spIndex,
kickerIndex,
immichAlbums,
sensors,
drawerMode,
@@ -95,6 +100,7 @@ export default function SubpageDrawer({
getAlbumName,
getAlbumCount,
getAlbumThumbnailId,
highlightedAlbumId,
}: SubpageDrawerProps) {
return (
<div className="subpage-drawer-backdrop" onClick={() => onClose()}>
@@ -141,7 +147,7 @@ export default function SubpageDrawer({
<div className="subpage-drawer-body">
{drawerMode === 'preview' ? (
<SubpagePreview sp={sp} immichAlbums={immichAlbums} />
<SubpagePreview sp={sp} immichAlbums={immichAlbums} index={kickerIndex} />
) : (
<div className="admin-sheet-columns admin-sheet-columns--settings-aside">
<div className="admin-sheet-col">
@@ -300,6 +306,7 @@ export default function SubpageDrawer({
<div className="admin-field" style={{ marginTop: '1rem' }}>
<label>Page Layout Style</label>
<select
aria-label="Page layout style"
value={sp.grid?.layout || 'masonry'}
onChange={(e) => {
const newLayout = e.target.value;
@@ -446,6 +453,7 @@ export default function SubpageDrawer({
name={getAlbumName(album.id)}
count={getAlbumCount(album.id)}
thumbnailId={getAlbumThumbnailId(album.id)}
highlighted={album.id === highlightedAlbumId}
onRemove={() => removeSubpageAlbum(spIndex, aIndex)}
onEdit={() =>
onEditAlbum({
@@ -506,6 +514,7 @@ export default function SubpageDrawer({
name={getAlbumName(album.id)}
count={getAlbumCount(album.id)}
thumbnailId={getAlbumThumbnailId(album.id)}
highlighted={album.id === highlightedAlbumId}
onRemove={() => removeSectionAlbum(spIndex, secIndex, aIndex)}
onEdit={() =>
onEditAlbum({

View File

@@ -1,77 +1,215 @@
'use client';
import { IconFolder } from '../Icons';
import type { ImmichAlbumInfo, Subpage } from './types';
import { useEffect, useRef, useState } from 'react';
import { buildCoverGridVars } from '@/lib/config/schema';
import { dictionaryFor } from '@/lib/i18n';
import type { AlbumEntry, ImmichAlbumInfo, Subpage } from './types';
interface SubpagePreviewProps {
sp: Subpage;
immichAlbums: ImmichAlbumInfo[];
/** 1-based position among the enabled subpages, for the header kicker. */
index?: number;
}
/** The drawer's read-only "Live Preview" tab: hero banner, sections, covers. */
export default function SubpagePreview({ sp, immichAlbums }: SubpagePreviewProps) {
/**
* The width the page is laid out at before it is scaled into the drawer —
* a desktop viewport, which is also what the stylesheet's media queries see.
*/
const CANVAS_WIDTH = 1280;
/** Mirrors the fallback in lib/config/index.ts when settings.yaml sets none. */
const DEFAULT_GRID_COLUMNS = 3;
const pad2 = (n: number) => String(n).padStart(2, '0');
interface PreviewAlbum {
key: string;
name: string;
assetCount: number;
thumb: string | null;
coverPosition?: string;
}
/**
* The drawer's read-only "Live Preview" tab.
*
* It used to draw its own hero banner and tiles in admin styling, which knew
* nothing of the preset, the cover grid or the caption bar — the preview showed
* a page the site never rendered. It now emits the same markup and classes as
* `SubpageGridView`, so the preset CSS (keyed on `[data-preset]`, which the
* admin's <html> carries as well) styles it exactly like the public page. The
* cover grid is sized by the same `buildCoverGridVars()` from the same inputs.
*
* The page is laid out at CANVAS_WIDTH and scaled down with `zoom` rather than
* squeezed into the drawer, so a three-column grid still looks like one.
*/
export default function SubpagePreview({ sp, immichAlbums, index }: SubpagePreviewProps) {
const frameRef = useRef<HTMLDivElement>(null);
const [zoom, setZoom] = useState(0.4);
const [site, setSite] = useState<{ columns: number; lang?: string }>({
columns: DEFAULT_GRID_COLUMNS,
});
useEffect(() => {
const frame = frameRef.current;
if (!frame) return;
const observer = new ResizeObserver(([entry]) => {
setZoom(entry.contentRect.width / CANVAS_WIDTH);
});
observer.observe(frame);
return () => observer.disconnect();
}, []);
// Fetched on every open rather than cached: the global column count and the
// language may have been changed in Settings since the page builder loaded.
useEffect(() => {
let cancelled = false;
fetch('/api/admin/settings')
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (cancelled || !data?.settings) return;
setSite({
columns: data.settings.grid?.columns ?? DEFAULT_GRID_COLUMNS,
lang: data.settings.lang,
});
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
const t = dictionaryFor(site.lang);
// Only the two keys the cover grid reads; see buildCoverGridVars().
const gridStyle = buildCoverGridVars(
{ columns: sp.coverGrid?.columns, gap: sp.coverGrid?.gap },
site.columns,
) as React.CSSProperties;
const toPreviewAlbum = (alb: AlbumEntry, i: number): PreviewAlbum => {
const immichAlb = immichAlbums.find((a) => a.id === alb.id);
return {
key: `${alb.id}-${i}`,
name: alb.title || immichAlb?.albumName || alb.id,
assetCount: immichAlb?.assetCount ?? 0,
thumb: alb.heroImage || immichAlb?.thumbnailAssetId || null,
coverPosition: alb.coverPosition,
};
};
const sections = sp.sections ?? [];
const allAlbums = (sections.length > 0 ? sections.flatMap((sec) => sec.albums) : sp.albums).map(
toPreviewAlbum,
);
const totalPhotos = allAlbums.reduce((sum, a) => sum + a.assetCount, 0);
const title = sp.title || sp.name;
const grid = (albums: PreviewAlbum[]) => (
<div className="subpage-grid" style={gridStyle}>
{albums.map((album, i) => (
<div key={album.key} className="subpage-grid__item">
<span className="subpage-grid__item-media">
{album.thumb ? (
// A plain <img> styled like next/image's `fill`: the admin
// thumbnail route takes a raw asset ID, which the image loader
// does not.
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/admin/thumbnail/${album.thumb}`}
alt=""
style={{
position: 'absolute',
inset: 0,
...(album.coverPosition ? { objectPosition: album.coverPosition } : {}),
}}
/>
) : (
<span
className="skeleton"
style={{ display: 'block', width: '100%', height: '100%' }}
/>
)}
<span className="subpage-grid__item-badge">{t.common.photos(album.assetCount)}</span>
<span className="subpage-grid__item-overlay">
<span className="subpage-grid__item-title">{album.name}</span>
<span className="subpage-grid__item-count">{t.common.photos(album.assetCount)}</span>
</span>
</span>
<span className="subpage-grid__item-caption">
<span className="subpage-grid__item-index">{pad2(i + 1)}</span>
<span className="subpage-grid__item-caption-title">{album.name}</span>
<span className="subpage-grid__item-caption-count">
{t.common.photos(album.assetCount)}
</span>
</span>
</div>
))}
</div>
);
return (
<div className="drawer-live-preview-panel">
<div className="preview-hero-banner">
<h2 className="preview-hero-title">{sp.title || sp.name || 'Untitled Page'}</h2>
{sp.subtitle && <p className="preview-hero-sub">{sp.subtitle}</p>}
</div>
<div className="subpage-preview">
<p className="subpage-preview__note">
Rendered with the site&rsquo;s saved theme preset at desktop width. Unsaved edits in this
drawer show up here; theme changes show up once saved in Settings.
</p>
<div className="subpage-preview__frame" ref={frameRef}>
{/* inert: a picture of the page, not a second copy of its links. */}
<div
className="subpage-preview__canvas"
style={{ width: CANVAS_WIDTH, zoom }}
inert
aria-hidden="true"
>
<div className="main">
<div className="subpage-container">
<header className="subpage-header">
<div className="subpage-header__main">
{index !== undefined && (
<p className="subpage-header__kicker">
{t.subpage.collectionKicker(pad2(index))}
</p>
)}
{title && <h1 className="subpage-title">{title}</h1>}
{sp.subtitle && <p className="subpage-subtitle">{sp.subtitle}</p>}
</div>
<p className="subpage-header__meta">
{t.common.albums(allAlbums.length)} · {t.common.photos(totalPhotos)}
</p>
</header>
{sp.sections && sp.sections.length > 0 ? (
sp.sections.map((sec, sIdx) => (
<div key={sIdx} className="preview-section-group">
<h3 className="preview-section-title">{sec.title || 'Untitled Section'}</h3>
{sec.description && <p className="preview-section-desc">{sec.description}</p>}
<div className="preview-albums-grid">
{sec.albums.map((alb, aIdx) => {
const immichAlb = immichAlbums.find((a) => a.id === alb.id);
const thumb = alb.heroImage || immichAlb?.thumbnailAssetId;
return (
<div key={aIdx} className="preview-album-tile">
<div className="preview-album-cover">
{thumb ? (
<img src={`/api/admin/thumbnail/${thumb}`} alt="" />
) : (
<div className="subpage-tile-placeholder">
<IconFolder />
</div>
)}
</div>
<span className="preview-album-title">
{alb.title || immichAlb?.albumName || alb.id}
{sections.length > 0 && (
<nav className="subpage-toc">
{sections.map((sec, i) => (
<span key={i} className="subpage-toc__entry">
<span className="subpage-toc__link">
<span className="subpage-toc__num">{pad2(i + 1)}</span>
<span className="subpage-toc__label">{sec.title}</span>
</span>
</span>
</div>
);
})}
))}
</nav>
)}
{sections.length > 0
? sections.map((sec, i) => (
<section key={i} className="subpage-section">
<header className="subpage-section__header">
<h2 className="subpage-section__title">{sec.title}</h2>
{sec.description && (
<p className="subpage-section__desc">{sec.description}</p>
)}
<div className="subpage-section__rule" />
</header>
{grid(sec.albums.map(toPreviewAlbum))}
</section>
))
: grid(allAlbums)}
</div>
</div>
))
) : (
<div className="preview-albums-grid">
{sp.albums.map((alb, aIdx) => {
const immichAlb = immichAlbums.find((a) => a.id === alb.id);
const thumb = alb.heroImage || immichAlb?.thumbnailAssetId;
return (
<div key={aIdx} className="preview-album-tile">
<div className="preview-album-cover">
{thumb ? (
<img src={`/api/admin/thumbnail/${thumb}`} alt="" />
) : (
<div className="subpage-tile-placeholder">
<IconFolder />
</div>
)}
</div>
<span className="preview-album-title">
{alb.title || immichAlb?.albumName || alb.id}
</span>
</div>
);
})}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import type { ActiveEditAlbumAddress, AlbumEntry, Subpage } from './types';
/**
* Where an album sits in the page builder, as the address its drawer opens by.
*
* Used by the `/admin/pages?album=<id>` deep link from the diagnostics page. An
* album can be listed more than once; the first place wins — standalone, then
* each subpage's own albums, then its sections — which is also the order the
* builder shows them in.
*/
export function findAlbumAddress(
gallery: { albums: AlbumEntry[]; subpages: Subpage[] },
albumId: string,
): ActiveEditAlbumAddress | null {
const standalone = gallery.albums.findIndex((a) => a.id === albumId);
if (standalone !== -1) return { type: 'standalone', albumIndex: standalone };
for (const [subpageIndex, sp] of gallery.subpages.entries()) {
const direct = sp.albums.findIndex((a) => a.id === albumId);
if (direct !== -1) return { type: 'subpage', subpageIndex, albumIndex: direct };
for (const [sectionIndex, section] of (sp.sections ?? []).entries()) {
const inSection = section.albums.findIndex((a) => a.id === albumId);
if (inSection !== -1) {
return { type: 'section', subpageIndex, sectionIndex, albumIndex: inSection };
}
}
}
return null;
}

View File

@@ -1,5 +1,12 @@
import type { DoctorLevel } from '@/lib/admin/doctor';
/**
* Fired on `window` by the diagnostics page after each run, with the doctor's
* level as `detail`. The badge lives in the layout and would otherwise keep
* showing the level from when the admin was opened until the next full load.
*/
export const DOCTOR_LEVEL_EVENT = 'folio:doctor-level';
/** The three tones `status-badge-btn` is styled for. */
export type BadgeTone = 'connected' | 'disconnected' | 'unknown';