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:
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
574
app/admin/components/DiagnosticsView.tsx
Normal file
574
app/admin/components/DiagnosticsView.tsx
Normal 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’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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) })
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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)}
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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’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>
|
||||
);
|
||||
}
|
||||
|
||||
30
app/admin/components/page-builder/findAlbumAddress.ts
Normal file
30
app/admin/components/page-builder/findAlbumAddress.ts
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user