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

@@ -7,6 +7,7 @@ import { ProofingProvider, useProofing } from '@/components/ProofingContext';
import { ProofingModal } from '@/components/ProofingModal';
import { FadeIn } from '@/components/FadeIn';
import type { ParsedEssay, EssayBlock } from '@/lib/essay';
import { renderInlineMarkdown } from '@/lib/essay';
import type { PhotoItem } from './PhotoGrid';
import './essay.css';
import { useDictionary } from '@/components/I18nProvider';
@@ -78,7 +79,15 @@ function EssayViewContent({
return (
<blockquote key={idx} className="essay-quote">
<div dangerouslySetInnerHTML={{ __html: block.text }} />
{block.author && <span className="essay-quote__author">— {block.author}</span>}
{block.author && (
<span
className="essay-quote__author"
// The attribution keeps its Markdown and is rendered here, like
// the quote body, so `[link](…)` becomes an anchor (#559).
// renderInlineMarkdown escapes everything it does not emit.
dangerouslySetInnerHTML={{ __html: `— ${renderInlineMarkdown(block.author)}` }}
/>
)}
</blockquote>
);
@@ -312,9 +321,9 @@ function EssayViewContent({
gap: '12px',
padding: '8px 16px',
borderRadius: '30px',
background: 'var(--bg-surface, #1e1e1e)',
background: 'var(--bg-card, #1e1e1e)',
color: 'var(--text-primary, #ffffff)',
border: '1px solid var(--border-color, rgba(255,255,255,0.15))',
border: '1px solid var(--border-subtle, rgba(255,255,255,0.15))',
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
backdropFilter: 'blur(8px)',
}}
@@ -325,8 +334,8 @@ function EssayViewContent({
style={{
background: proofing.isFilterActive
? 'var(--accent, #e60012)'
: 'rgba(255,255,255,0.15)',
color: '#fff',
: 'var(--bg-card-hover)',
color: proofing.isFilterActive ? '#fff' : 'var(--text-primary)',
border: 'none',
padding: '6px 14px',
borderRadius: '20px',

View File

@@ -266,9 +266,9 @@ function PhotoGridInner({
gap: '12px',
padding: '8px 16px',
borderRadius: '30px',
background: 'var(--bg-surface, #1e1e1e)',
background: 'var(--bg-card, #1e1e1e)',
color: 'var(--text-primary, #ffffff)',
border: '1px solid var(--border-color, rgba(255,255,255,0.15))',
border: '1px solid var(--border-subtle, rgba(255,255,255,0.15))',
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
backdropFilter: 'blur(8px)',
}}
@@ -279,8 +279,8 @@ function PhotoGridInner({
style={{
background: proofing.isFilterActive
? 'var(--accent, #e60012)'
: 'rgba(255,255,255,0.15)',
color: '#fff',
: 'var(--bg-card-hover)',
color: proofing.isFilterActive ? '#fff' : 'var(--text-primary)',
border: 'none',
padding: '6px 14px',
borderRadius: '20px',

View File

@@ -55,13 +55,19 @@ function AlbumGrid({
key={album.id}
href={`/${slug}/${album.slug}`}
className="subpage-grid__item"
style={ph?.dominantColor ? { backgroundColor: ph.dominantColor } : undefined}
aria-label={getServerDictionary().subpage.coverAria(
album.albumName,
photoCount(album.assetCount),
)}
>
<span className="subpage-grid__item-media">
{/* The loading colour belongs to the image, not the card: presets
with a caption bar below the cover (studio-modern) would
otherwise paint the bar in the photo's dominant colour, and
their --text-primary caption becomes dark-on-dark in light mode. */}
<span
className="subpage-grid__item-media"
style={ph?.dominantColor ? { backgroundColor: ph.dominantColor } : undefined}
>
{album.albumThumbnailAssetId ? (
<Image
src={imageUrl(album.albumThumbnailAssetId, 'preview')}

View File

@@ -32,7 +32,7 @@
}
.essay-header__title {
font-family: var(--font-heading, serif);
font-family: var(--font-serif, serif);
font-size: clamp(2.2rem, 5vw, 3.8rem);
font-weight: 700;
line-height: 1.15;
@@ -41,7 +41,7 @@
}
.essay-header__subtitle {
font-family: var(--font-body, sans-serif);
font-family: var(--font-sans, sans-serif);
font-size: clamp(1.1rem, 2.5vw, 1.4rem);
opacity: 0.8;
line-height: 1.5;
@@ -60,7 +60,7 @@
.essay-prose {
max-width: var(--essay-measure);
margin: 0 auto 2.5rem auto;
font-family: var(--font-body, sans-serif);
font-family: var(--font-sans, sans-serif);
font-size: clamp(1.05rem, 2vw, 1.2rem);
line-height: 1.75;
color: var(--text-primary, rgba(255, 255, 255, 0.92));
@@ -71,7 +71,7 @@
}
.essay-heading {
font-family: var(--font-heading, serif);
font-family: var(--font-serif, serif);
max-width: var(--essay-measure);
margin: 3.5rem auto 1.5rem auto;
font-weight: 600;
@@ -94,9 +94,9 @@
margin: 3.5rem auto;
padding: 1.5rem 2rem;
border-left: 3px solid var(--accent, #e60012);
background: var(--bg-surface, rgba(255, 255, 255, 0.03));
background: var(--bg-card, rgba(255, 255, 255, 0.03));
border-radius: 0 8px 8px 0;
font-family: var(--font-heading, serif);
font-family: var(--font-serif, serif);
font-style: italic;
font-size: clamp(1.15rem, 2.2vw, 1.35rem);
line-height: 1.6;
@@ -142,7 +142,7 @@
position: relative;
width: 100%;
overflow: hidden;
border-radius: var(--radius, 0px);
border-radius: var(--radius-md, 0px);
max-height: 80vh;
}

View File

@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest';
import { findAlbumAddress } from '../components/page-builder/findAlbumAddress';
import type { Subpage } from '../components/page-builder/types';
const subpage = (name: string, albums: string[], sections: string[][] = []): Subpage => ({
name,
albums: albums.map((id) => ({ id })),
sections: sections.map((ids, i) => ({ title: `S${i}`, albums: ids.map((id) => ({ id })) })),
});
describe('findAlbumAddress', () => {
it('finds a standalone album', () => {
expect(findAlbumAddress({ albums: [{ id: 'x' }, { id: 'a' }], subpages: [] }, 'a')).toEqual({
type: 'standalone',
albumIndex: 1,
});
});
it("finds an album on a subpage's own list", () => {
const gallery = { albums: [], subpages: [subpage('one', ['x']), subpage('two', ['y', 'a'])] };
expect(findAlbumAddress(gallery, 'a')).toEqual({
type: 'subpage',
subpageIndex: 1,
albumIndex: 1,
});
});
it('finds an album inside a section', () => {
const gallery = { albums: [], subpages: [subpage('one', [], [['x'], ['y', 'a']])] };
expect(findAlbumAddress(gallery, 'a')).toEqual({
type: 'section',
subpageIndex: 0,
sectionIndex: 1,
albumIndex: 1,
});
});
it('prefers the standalone listing when an album appears twice', () => {
const gallery = { albums: [{ id: 'a' }], subpages: [subpage('one', ['a'])] };
expect(findAlbumAddress(gallery, 'a')?.type).toBe('standalone');
});
it('returns null for an album the builder does not list', () => {
expect(findAlbumAddress({ albums: [], subpages: [subpage('one', ['x'])] }, 'a')).toBeNull();
});
});

View File

@@ -31,3 +31,4 @@
@import './styles/essay-builder.css';
@import './styles/asset-order.css';
@import './styles/favicon.css';
@import './styles/diagnostics.css';

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';

View File

@@ -0,0 +1,5 @@
import DiagnosticsView from '../components/DiagnosticsView';
export default function AdminDiagnosticsPage() {
return <DiagnosticsView />;
}

View File

@@ -1,10 +1,7 @@
import SettingsEditor from '../../components/SettingsEditor';
export default async function AdminSettingsSectionPage({
params,
}: {
params: Promise<{ section: string }>;
}) {
const { section } = await params;
return <SettingsEditor section={section} />;
/**
* The editor is rendered by the layout and reads the section from the URL
* itself; this route only has to exist.
*/
export default function AdminSettingsSectionPage() {
return null;
}

View File

@@ -0,0 +1,19 @@
import SettingsEditor from '../components/SettingsEditor';
/**
* The settings editor lives in the layout, not in the section pages.
*
* Each section is its own route, and a page remounts when the section changes.
* With the editor in the page, switching from General to Theme threw away every
* unsaved edit and re-read settings.yaml, without a word — the save bar simply
* vanished. A layout stays mounted across its child routes, so the edits, and
* the one save bar that covers them, survive the switch.
*/
export default function AdminSettingsLayout({ children }: { children: React.ReactNode }) {
return (
<>
<SettingsEditor />
{children}
</>
);
}

View File

@@ -1,5 +1,4 @@
import SettingsEditor from '../components/SettingsEditor';
/** The editor is rendered by the layout; this route only has to exist. */
export default function AdminSettingsPage() {
return <SettingsEditor />;
return null;
}

View File

@@ -57,8 +57,9 @@
font-size: 0.85rem;
}
.picker-search input:focus {
outline: none;
.picker-search input:focus-visible {
outline: var(--admin-focus-ring);
outline-offset: 1px;
border-color: var(--admin-border-focus);
}

View File

@@ -155,8 +155,9 @@
font-family: monospace;
}
.asset-picker-uuid-input:focus {
outline: none;
.asset-picker-uuid-input:focus-visible {
outline: var(--admin-focus-ring);
outline-offset: 1px;
border-color: var(--admin-border-focus);
}

View File

@@ -117,10 +117,11 @@
transition: border-color 0.15s;
}
.admin-field input:focus,
.admin-field select:focus,
.admin-field textarea:focus {
outline: none;
.admin-field input:focus-visible,
.admin-field select:focus-visible,
.admin-field textarea:focus-visible {
outline: var(--admin-focus-ring);
outline-offset: 1px;
border-color: var(--admin-border-focus);
}
@@ -159,7 +160,8 @@
}
.admin-listbox-trigger:focus-visible {
outline: none;
outline: var(--admin-focus-ring);
outline-offset: 1px;
border-color: var(--admin-border-focus);
}

View File

@@ -0,0 +1,339 @@
/* The diagnostics page (/admin/diagnostics). */
/* Each check is one line with a severity dot: the point is scanning a list for
the ones that are not green, not reading prose. Green ones recede. */
.diag-page {
max-width: 880px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.diag-head {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: flex-end;
gap: 1rem;
}
.diag-head h2 {
margin: 0;
font-size: 1.35rem;
font-weight: 700;
text-wrap: balance;
}
.diag-sub {
margin: 0.3rem 0 0;
font-size: 0.8rem;
color: var(--admin-text-muted);
}
.diag-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
/* ── Overview tiles ── */
.diag-vitals {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
}
@media (max-width: 720px) {
.diag-vitals {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.diag-vital {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem 0.9rem;
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
border-radius: 10px;
}
.diag-vital-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--admin-text-muted);
}
.diag-vital-val {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 1rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.diag-vital-meta {
min-height: 1em;
font-size: 0.75rem;
color: var(--admin-text-muted);
}
.diag-vital-meta a {
color: var(--admin-warning);
}
/* ── Check groups ── */
.diag-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.diag-group-title {
margin: 0;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--admin-text-muted);
}
.diag-checks {
margin: 0;
padding: 0;
list-style: none;
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
border-radius: 10px;
}
.diag-check {
border-bottom: 1px solid var(--admin-border);
}
.diag-check:last-child {
border-bottom: none;
}
.diag-check-row {
display: grid;
grid-template-columns: 8px minmax(0, 1fr) auto;
align-items: start;
gap: 0.75rem;
padding: 0.8rem 1rem;
}
@media (max-width: 560px) {
.diag-check-row {
grid-template-columns: 8px minmax(0, 1fr);
}
.diag-check-side {
grid-column: 2;
justify-content: flex-start;
}
}
.diag-dot {
width: 8px;
height: 8px;
margin-top: 0.4rem;
border-radius: 50%;
flex-shrink: 0;
background: var(--admin-text-muted);
}
.diag-vital-val .diag-dot {
margin-top: 0;
}
.diag-check--ok .diag-dot,
.diag-dot--ok {
background: var(--admin-success);
}
.diag-check--warn .diag-dot {
background: var(--admin-warning);
}
.diag-check--error .diag-dot,
.diag-dot--error {
background: var(--admin-error);
}
.diag-check-body {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.diag-check-title {
font-size: 0.86rem;
font-weight: 600;
color: var(--admin-text);
}
.diag-check--ok .diag-check-title,
.diag-check--unknown .diag-check-title {
font-weight: 500;
color: var(--admin-text-secondary);
}
.diag-check-detail {
max-width: 70ch;
font-size: 0.78rem;
line-height: 1.45;
color: var(--admin-text-muted);
}
.diag-check-side {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.4rem;
}
/* ── Alt-text list ── */
.diag-alt {
padding: 0 1rem 1rem calc(1rem + 8px + 0.75rem);
}
.diag-coverage {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.4rem;
font-size: 0.75rem;
color: var(--admin-text-secondary);
font-variant-numeric: tabular-nums;
}
.diag-coverage-bar {
flex: 1;
max-width: 320px;
height: 6px;
border-radius: 999px;
background: var(--admin-border);
overflow: hidden;
}
.diag-coverage-bar span {
display: block;
height: 100%;
background: var(--admin-success);
}
.diag-album {
padding: 0.75rem 0;
border-top: 1px dashed var(--admin-border);
}
.diag-album-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.diag-album-name {
font-size: 0.82rem;
font-weight: 600;
}
.diag-album-path {
margin-left: 0.4rem;
font-size: 0.72rem;
font-weight: 400;
color: var(--admin-text-muted);
}
.diag-album-count {
font-size: 0.72rem;
color: var(--admin-warning);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.diag-thumbs {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
gap: 0.5rem;
}
.diag-thumb {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
color: inherit;
text-decoration: none;
}
.diag-thumb img {
width: 100%;
aspect-ratio: 3 / 2;
object-fit: cover;
border: 1px solid var(--admin-border);
border-radius: 6px;
background: var(--admin-bg-elevated);
transition: border-color 0.15s;
}
.diag-thumb:hover img {
border-color: var(--admin-border-hover);
}
.diag-thumb-name {
overflow: hidden;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.68rem;
color: var(--admin-text-muted);
white-space: nowrap;
text-overflow: ellipsis;
}
.diag-thumb-more {
aspect-ratio: 3 / 2;
border: 1px dashed var(--admin-border);
border-radius: 6px;
background: none;
font-family: inherit;
font-size: 0.75rem;
color: var(--admin-text-muted);
cursor: pointer;
}
.diag-thumb-more:hover {
border-color: var(--admin-border-hover);
color: var(--admin-text);
}
.diag-note {
margin: 0.5rem 0 0;
max-width: 70ch;
font-size: 0.75rem;
line-height: 1.45;
color: var(--admin-text-muted);
}
.diag-note code {
padding: 0.05rem 0.3rem;
border-radius: 4px;
background: var(--admin-bg-elevated);
font-size: 0.72rem;
}
@media (prefers-reduced-motion: reduce) {
.diag-thumb img {
transition: none;
}
}

View File

@@ -3,87 +3,32 @@
later sheets override earlier ones, so the order there is the cascade. */
/* ── Drawer Live Preview ────────────────────────────────────── */
.drawer-live-preview-panel {
/* Only the frame is admin styling. Everything inside the canvas is the public
page's own markup, styled by globals.css and the preset — so nothing here
may target the subpage-* classes. */
.subpage-preview {
display: flex;
flex-direction: column;
gap: 1.5rem;
gap: 0.75rem;
padding: 0.5rem 0;
}
.preview-hero-banner {
text-align: center;
padding: 1.75rem 1rem;
background: var(--admin-bg-elevated);
border-radius: 12px;
border: 1px dashed var(--admin-border);
}
.preview-hero-title {
font-size: 1.3rem;
font-weight: 700;
.subpage-preview__note {
margin: 0;
color: var(--admin-text);
}
.preview-hero-sub {
font-size: 0.82rem;
color: var(--admin-text-muted);
margin: 0.35rem 0 0;
}
.preview-section-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.preview-section-title {
font-size: 0.95rem;
font-weight: 600;
margin: 0;
border-bottom: 1px solid var(--admin-border);
padding-bottom: 0.35rem;
}
.preview-section-desc {
font-size: 0.78rem;
color: var(--admin-text-muted);
margin: 0;
}
.preview-albums-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 0.75rem;
}
.preview-album-tile {
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.preview-album-cover {
width: 100%;
aspect-ratio: 4/3;
background: var(--admin-bg-elevated);
overflow: hidden;
}
.preview-album-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-album-title {
font-size: 0.75rem;
font-weight: 500;
padding: 0.4rem 0.5rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--admin-text-muted);
}
.subpage-preview__frame {
border: 1px solid var(--admin-border);
border-radius: 10px;
overflow: hidden;
}
/* The site's own page surface, as <body> paints it. */
.subpage-preview__canvas {
background: var(--bg-primary);
color: var(--text-primary);
font-family: var(--font-sans);
line-height: 1.6;
}

View File

@@ -557,6 +557,7 @@ h3 .svg-icon {
/* Modal Tabs */
.backup-modal-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--admin-border);
@@ -706,6 +707,17 @@ h3 .svg-icon {
border: 1px solid rgba(245, 158, 11, 0.3);
}
.backup-badge.deleted {
background: rgba(239, 68, 68, 0.1);
color: var(--admin-error, #ef4444);
border: 1px solid rgba(239, 68, 68, 0.3);
}
.backup-slug {
font-size: 0.88rem;
color: var(--admin-text);
}
.backup-item-meta code {
font-family: monospace;
font-size: 0.72rem;

View File

@@ -108,13 +108,18 @@
color: var(--admin-text, #f0f0f0) !important;
font-size: 0.85rem !important;
padding: 0.55rem 0.75rem !important;
outline: none !important;
transition: border-color 0.15s !important;
}
.essay-block-editor input[type='text']:focus,
.essay-block-editor textarea:focus,
.essay-block-editor select:focus {
/* The base rule used to carry `outline: none !important`, which removed the
focus ring from these fields for good, not just on hover. It is gone; the
ring is added on focus instead, with the same !important so it wins against
the other !important declarations in this file. */
.essay-block-editor input[type='text']:focus-visible,
.essay-block-editor textarea:focus-visible,
.essay-block-editor select:focus-visible {
outline: var(--admin-focus-ring) !important;
outline-offset: 1px !important;
border-color: var(--admin-border-focus, #888888) !important;
}

View File

@@ -105,10 +105,13 @@
transition: all 0.15s;
}
.builder-search-input:focus {
outline: none;
/* The only field that already got this right (accent border plus shadow).
It moves to the same ring as every other field, so focus looks the same
throughout the admin panel. */
.builder-search-input:focus-visible {
outline: var(--admin-focus-ring);
outline-offset: 1px;
border-color: var(--admin-accent);
box-shadow: 0 0 0 1px var(--admin-accent);
background: var(--admin-bg);
}

View File

@@ -268,8 +268,10 @@
word-break: break-all;
}
/* Subpage Overview Grid */
.subpage-grid {
/* Subpage Overview Grid.
Not `.subpage-grid`: that is the public page's cover grid, which globals.css
styles everywhere — and which the drawer's Live Preview renders for real. */
.subpage-tiles {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
@@ -410,8 +412,9 @@
border-color: var(--admin-border);
}
.subpage-name-input:focus {
outline: none;
.subpage-name-input:focus-visible {
outline: var(--admin-focus-ring);
outline-offset: 1px;
border-color: var(--admin-border-focus);
background: var(--admin-bg-input);
}
@@ -474,8 +477,9 @@
border-radius: 4px;
}
.section-title-input:focus {
outline: none;
.section-title-input:focus-visible {
outline: var(--admin-focus-ring);
outline-offset: 1px;
border-color: var(--admin-border-hover);
background: var(--admin-bg);
}
@@ -484,3 +488,9 @@
font-size: 0.8rem !important;
padding: 0.4rem 0.5rem !important;
}
/* The tile a diagnostics link pointed at (`/admin/pages?album=<id>`). */
.album-tile--linked {
outline: 2px solid var(--admin-warning);
outline-offset: 3px;
}

View File

@@ -1,4 +1,4 @@
/* Sidebar nav, preset preview mockups, the status badge and its dropdown, grouped feature settings, the doctor report. */
/* Sidebar nav, preset preview mockups, the status badge and its dropdown, grouped feature settings. */
/* Split out of admin.css (#533). Loaded from admin.css in a fixed order —
later sheets override earlier ones, so the order there is the cascade. */
@@ -427,68 +427,6 @@
color: var(--admin-text-muted);
}
/* ── Config doctor report (#491) ──────────────────────────────
Each finding is one line with a severity dot: the point is scanning a list
for the ones that are not green, not reading prose. */
.doctor-list {
margin: 0;
padding: 0;
list-style: none;
}
.doctor-item {
display: flex;
align-items: flex-start;
gap: 0.7rem;
padding: 0.8rem 0;
border-bottom: 1px solid var(--admin-border);
}
.doctor-item:last-child {
border-bottom: none;
}
.doctor-dot {
width: 8px;
height: 8px;
margin-top: 0.42rem;
border-radius: 50%;
flex-shrink: 0;
background: var(--admin-success);
}
.doctor-item--warn .doctor-dot {
background: var(--admin-accent);
}
.doctor-item--error .doctor-dot {
background: var(--admin-error);
}
.doctor-item-body {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.doctor-item-title {
font-size: 0.84rem;
font-weight: 600;
color: var(--admin-text);
}
/* A finding that needs nothing recedes; the eye should land on the others. */
.doctor-item--ok .doctor-item-title {
font-weight: 500;
color: var(--admin-text-secondary);
}
.doctor-item-detail {
font-size: 0.75rem;
line-height: 1.45;
color: var(--admin-text-muted);
}
/* The metadata group as one card with an expander: four permanently visible
sub-switches read as four decisions, when they are details of one. */
.metadata-card {
@@ -691,7 +629,7 @@
}
.status-val.update {
color: var(--admin-warning, #f59e0b);
color: var(--admin-warning);
}
.status-val.unknown {

View File

@@ -15,14 +15,24 @@
--admin-bg-input: #161616;
--admin-border: #2e2e2e;
--admin-border-hover: #444444;
--admin-border-focus: #666666;
--admin-border-focus: #777777;
--admin-text: #f0f0f0;
--admin-text-secondary: #a0a0a0;
--admin-text-muted: #707070;
--admin-accent: #f59e0b;
/* Focus used to show only as a border colour change from #2e2e2e to
#666666, a contrast of 2.37:1 where WCAG 1.4.11 asks for 3:1 against
adjacent colours, so keyboard users could barely see where they were.
The border value now clears 3:1, and a real ring comes on top: the
accent sits at 8.8:1 on the dark background. */
--admin-focus-ring: 2px solid var(--admin-accent);
/* One height for every control in the header row. */
--admin-control-height: 36px;
--admin-success: #4ade80;
/* Warnings get their own token rather than the accent: a preset may recolour
the accent (studio-modern makes it red), and a warning must not read as an
error because of the theme. */
--admin-warning: #f59e0b;
--admin-error: #f87171;
--admin-error-bg: #2d1111;
--admin-error-border: #5c2020;
@@ -40,12 +50,13 @@
--admin-bg-input: #ffffff;
--admin-border: #e0e0e0;
--admin-border-hover: #c0c0c0;
--admin-border-focus: #999999;
--admin-border-focus: #7f7f7f;
--admin-text: #1a1a1a;
--admin-text-secondary: #555555;
--admin-text-muted: #888888;
--admin-accent: #d97706;
--admin-success: #16a34a;
--admin-warning: #b45309;
--admin-error: #dc2626;
--admin-error-bg: #fef2f2;
--admin-error-border: #fecaca;

View File

@@ -82,6 +82,14 @@ const ROUTES: {
method: 'GET',
args: () => [new NextRequest('http://localhost/api/admin/doctor')],
},
{
// Lists album titles and raw asset UUIDs.
name: 'GET /api/admin/alt-text',
path: 'alt-text',
load: () => import('../alt-text/route'),
method: 'GET',
args: () => [],
},
{
name: 'GET /api/admin/assets',
path: 'assets',

View File

@@ -0,0 +1,83 @@
import { NextResponse } from 'next/server';
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
import { getConfig } from '@/lib/config';
import { immich } from '@/lib/immich';
import {
albumPaths,
buildAltTextReport,
pickImmichWebUrl,
type AltTextAlbumInput,
} from '@/lib/admin/alt-text';
/**
* Immich's *External domain* setting, or null when unset or unreadable. A
* failure here must not cost the report — the links just fall back.
*/
async function immichExternalDomain(apiUrl: string, apiKey: string, timeoutMs: number) {
try {
const res = await fetch(`${apiUrl}/server/config`, {
headers: { 'x-api-key': apiKey, Accept: 'application/json' },
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) return null;
const body = (await res.json()) as { externalDomain?: string };
return body.externalDomain ?? null;
} catch {
return null;
}
}
/**
* Alt-text coverage of every published album, for the diagnostics page.
*
* Goes through `immich.getAlbum()`, so it shares the album cache with the
* public site: a check right after browsing the gallery costs no Immich calls.
* An album that fails to load is counted, not fatal — the rest still report.
*/
export async function GET() {
if (!isAdminEnabled()) {
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
}
if (!(await isAdminAuthenticated())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const config = getConfig();
if (config.needsCredentials) {
return NextResponse.json({ error: 'Not configured' }, { status: 503 });
}
const paths = albumPaths(config.subpages);
const inputs: AltTextAlbumInput[] = [];
let unreadable = 0;
const [albums, externalDomain] = await Promise.all([
Promise.all(config.albums.map((id) => immich.getAlbum(id).catch(() => undefined))),
immichExternalDomain(config.immich.apiUrl, config.immich.apiKey, config.immichTimeoutMs),
]);
const immichLink = pickImmichWebUrl(externalDomain, config.immich.apiUrl);
albums.forEach((album, i) => {
if (!album) {
// null is "Immich says it does not exist" — the doctor's album-ids check
// already reports that. undefined is a failed request.
if (album === undefined) unreadable++;
return;
}
inputs.push({
id: album.id,
name: album.albumName,
path: paths.get(config.albums[i]) ?? '/',
assets: album.assets,
});
});
return NextResponse.json(
{
...buildAltTextReport(inputs, config.exif.caption),
unreadable,
immichUrl: immichLink.url,
immichUrlSource: immichLink.source,
},
{ headers: { 'Cache-Control': 'no-store' } },
);
}

View File

@@ -2,18 +2,24 @@ import { NextResponse } from 'next/server';
import { revalidatePath } from 'next/cache';
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
import { listBackups, restoreBackup } from '@/lib/admin/yaml-service';
import { listJournalBackups, restoreJournalBackup } from '@/lib/admin/journal-service';
import { invalidateConfigCache } from '@/lib/config';
import { immich } from '@/lib/immich';
export type BackupTarget = 'gallery' | 'settings' | 'about' | 'journal';
export interface BackupItem {
filename: string;
target: 'gallery' | 'settings';
target: BackupTarget;
timestamp: string | null;
isPreRestore: boolean;
/** Journal backups only: the entry the backup belongs to. */
slug?: string;
/** Journal backups only: the snapshot taken when the entry was deleted. */
isDeleted?: boolean;
}
function parseBackupInfo(filename: string): BackupItem {
const target = filename.startsWith('gallery.yaml') ? 'gallery' : 'settings';
function parseBackupInfo(filename: string, target: BackupTarget): BackupItem {
const isPreRestore = filename.includes('pre-restore');
// Extract timestamp from filename like filename.2026-05-31T17-30-00-000Z.bak
@@ -37,7 +43,7 @@ function parseBackupInfo(filename: string): BackupItem {
};
}
/** GET: List all available backups for gallery and settings. */
/** GET: List all available backups, grouped by the file they restore. */
export async function GET() {
if (!isAdminEnabled()) {
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
@@ -47,16 +53,26 @@ export async function GET() {
}
try {
const galleryRaw = await listBackups('gallery.yaml');
const settingsRaw = await listBackups('settings.yaml');
const gallery = galleryRaw.map(parseBackupInfo);
const settings = settingsRaw.map(parseBackupInfo);
const gallery = (await listBackups('gallery.yaml')).map((f) => parseBackupInfo(f, 'gallery'));
const settings = (await listBackups('settings.yaml')).map((f) =>
parseBackupInfo(f, 'settings'),
);
const about = (await listBackups('about.md')).map((f) => parseBackupInfo(f, 'about'));
// Newest first across all entries, so a just-deleted entry sits on top.
const journal = (await listJournalBackups())
.map((b) => ({
...parseBackupInfo(b.filename, 'journal'),
slug: b.slug,
isDeleted: b.kind === 'deleted',
}))
.sort((a, b) => (b.timestamp ?? '').localeCompare(a.timestamp ?? ''));
return NextResponse.json({
backups: {
gallery,
settings,
about,
journal,
},
});
} catch (err) {
@@ -76,7 +92,7 @@ export async function POST(req: Request) {
try {
const body = await req.json();
const { backupFilename } = body || {};
const { backupFilename, target } = body || {};
if (!backupFilename || typeof backupFilename !== 'string') {
return NextResponse.json({ error: 'backupFilename is required' }, { status: 400 });
@@ -95,7 +111,13 @@ export async function POST(req: Request) {
return NextResponse.json({ error: 'Invalid backup file extension' }, { status: 400 });
}
await restoreBackup(backupFilename);
// Journal backups live in their own directory, and `<slug>.md.<ts>.bak`
// cannot be told apart from an about.md backup by name alone.
if (target === 'journal') {
await restoreJournalBackup(backupFilename);
} else {
await restoreBackup(backupFilename);
}
// Invalidate caches & revalidate pages
invalidateConfigCache();

View File

@@ -14,6 +14,22 @@ import { decodeAssetId } from '@/lib/tokens';
import { getConfig } from '@/lib/config';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
import { siteLockResponse } from '@/lib/auth';
import { blankRanges, findMetadataRanges } from '@/lib/metadataStrip';
/**
* Bumping this changes every original's ETag, so a revalidating cache fetches
* the stripped bytes instead of replaying a pre-fix copy. Browsers hold these
* URLs as `immutable` and will not revalidate at all, so a real rollout also
* needs IMAGE_CACHE_VERSION bumped — that changes the URL itself.
*/
const STRIP_VERSION = 's1';
/**
* Originals are buffered whole so their metadata can be blanked before a single
* byte reaches the client. Anything larger is not worth holding in memory; those
* fall back to the preview rendition, which Immich generates without EXIF.
*/
const MAX_STRIP_BYTES = 64 * 1024 * 1024;
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
// ── Rate limiting ──────────────────────────────────
@@ -65,7 +81,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
// `immutable`, so this only matters after expiry or a cache eviction, but a
// matching ETag across two different URLs would be wrong either way.
const cacheVersion = request.nextUrl.searchParams.get('v') ?? '';
const etag = `W/"${token}-${size}${cacheVersion ? `-${cacheVersion}` : ''}"`;
const etag = `W/"${token}-${size}-${STRIP_VERSION}${cacheVersion ? `-${cacheVersion}` : ''}"`;
if (request.headers.get('if-none-match') === etag) {
return new NextResponse(null, {
status: 304,
@@ -113,6 +129,57 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
contentType = 'application/octet-stream';
}
// ── Metadaten aus Originalen entfernen ─────────────
// Das Asset-Token autorisiert das Bild, nicht die Groesse: ein aus dem
// oeffentlichen HTML gelesenes Thumbnail-Token plus ?size=original lieferte
// bis hierher die unveraenderte Datei — mitsamt GPS. app/api/exif haelt
// Koordinaten bewusst zurueck, und /api/map quantisiert sie; dieser Pfad ging
// an beidem vorbei. Nur die Metadaten-Bytes werden genullt, die Bilddaten
// bleiben bitgenau erhalten — deshalb kostet das keine Qualitaet.
let body: BodyInit = result.stream;
// Gesetzt, sobald wir gestrippte Bytes ausliefern — der Wert ist dann die
// maßgebliche Content-Length.
let strippedLength: number | null = null;
if (size === 'original') {
const declaredLength = result.contentLength ? parseInt(result.contentLength, 10) : NaN;
const zuGross = Number.isFinite(declaredLength) && declaredLength > MAX_STRIP_BYTES;
const raw = zuGross ? null : new Uint8Array(await new Response(result.stream).arrayBuffer());
const ranges =
raw && raw.length <= MAX_STRIP_BYTES ? findMetadataRanges(raw, contentType) : null;
if (raw && ranges) {
const blanked = blankRanges(raw, ranges);
strippedLength = blanked.byteLength;
// ArrayBuffer statt Uint8Array: nur der ist ein gueltiger BodyInit. Die
// Zusicherung ist sicher — die Bytes stammen aus Response.arrayBuffer(),
// also nie aus einem SharedArrayBuffer, den ArrayBufferLike mit einschliesst.
body = blanked.buffer.slice(
blanked.byteOffset,
blanked.byteOffset + blanked.byteLength,
) as ArrayBuffer;
} else {
// Unbekanntes Format, kaputter Container oder zu gross: lieber die
// Vorschau ausliefern als ein Original, dessen Metadaten wir nicht
// sicher finden. Stillschweigend durchreichen war genau der Fehler.
console.warn(
`[Image API] Original von ${assetId} (${contentType}) nicht strippbar — liefere Preview.`,
);
const preview = await immich.streamAsset(assetId, 'preview');
if (!preview) {
return NextResponse.json(
{ error: 'Asset not found' },
{ status: 404, headers: { 'Cache-Control': 'no-store' } },
);
}
body = preview.stream;
contentType = preview.contentType.toLowerCase().startsWith('image/')
? preview.contentType
: 'image/jpeg';
result = { ...preview, contentLength: preview.contentLength };
}
}
const headers: Record<string, string> = {
'Content-Type': contentType,
// Images are immutable once uploaded to Immich — cache aggressively
@@ -121,9 +188,13 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
'X-RateLimit-Remaining': String(remaining),
};
if (result.contentLength) {
// Das Nullen ist laengentreu, der Wert bleibt also gueltig; beim
// Preview-Fallback stammt er vom Preview-Abruf.
if (strippedLength !== null) {
headers['Content-Length'] = String(strippedLength);
} else if (result.contentLength) {
headers['Content-Length'] = result.contentLength;
}
return new NextResponse(result.stream, { headers });
return new NextResponse(body, { headers });
}

View File

@@ -111,7 +111,13 @@
/* 56px header + 48px original padding */
}
/* Simple CSS Page Fade-In (Replaces Framer Motion) */
/* Simple CSS Page Fade-In (Replaces Framer Motion).
*
* Opacity only — no `transform`. A transform on `.main` (even the identity
* `translateY(0)` this keyframe used to end on, which `forwards` kept applied)
* turns the element into the containing block for every `position: fixed`
* descendant, so the proofing bar and its modal were pinned to the bottom of
* the page instead of floating over it. */
[data-transitions='true'] .main {
animation: page-fade-in 0.5s ease-out forwards;
}
@@ -119,12 +125,10 @@
@keyframes page-fade-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@@ -1120,6 +1124,25 @@
display: none;
}
.album-header__download {
display: inline-flex;
align-items: center;
gap: 8px;
margin-top: 16px;
font-size: 0.85rem;
color: var(--text-secondary);
transition: color var(--transition-fast);
}
.album-header__download:hover {
color: var(--text-primary);
}
.album-header__download svg {
width: 15px;
height: 15px;
}
.photo-grid {
/* The gap the grid uses when settings.yaml does not set one. Declared rather
than left to each layout's own `var(--grid-gap, N)` fallback, because those
@@ -1516,13 +1539,14 @@
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.1em;
/* --text-muted is already the quietest colour that clears 4.5:1; an extra
opacity on top halved that to about 2:1 in every light preset. */
color: var(--text-muted);
opacity: 0.5;
transition: opacity var(--transition-fast);
transition: color var(--transition-fast);
}
.footer__legal-link:hover {
opacity: 1;
color: var(--text-primary);
}
.footer__links {

View File

@@ -17,7 +17,9 @@
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 0.1em;
opacity: 0.6;
/* Colour tokens rather than opacity: they are tuned for 4.5:1 per theme,
an opacity on top of them is not. */
color: var(--text-muted);
}
.subpage-content {
max-width: 600px;
@@ -29,7 +31,7 @@
.legal-section__title {
font-size: 1.2rem;
margin-bottom: 0.5rem;
opacity: 0.8;
color: var(--text-secondary);
}
.legal-section__text {
line-height: 1.6;
@@ -40,5 +42,5 @@
.legal-source {
margin-top: 4rem;
font-size: 0.85rem;
opacity: 0.6;
color: var(--text-muted);
}

View File

@@ -42,13 +42,18 @@
gap: 2.5rem;
}
/* These rules used to read `--surface`, `--surface-subtle` and `--border`,
none of which exist, so the cards always took their fallbacks: white veils
of 3% (background) and 8% (border). Right on a dark background; in the
light theme the card could not be told apart from the page and had no
visible edge. */
.journal-card {
display: flex;
flex-direction: column;
text-decoration: none;
color: inherit;
background: var(--surface, rgba(255, 255, 255, 0.03));
border: 1px solid var(--border, rgba(255, 255, 255, 0.08));
background: var(--bg-card, rgba(255, 255, 255, 0.03));
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08));
border-radius: 12px;
overflow: hidden;
transition:
@@ -67,7 +72,7 @@
position: relative;
width: 100%;
aspect-ratio: 16 / 10;
background: var(--surface-subtle, rgba(255, 255, 255, 0.02));
background: var(--bg-secondary, rgba(255, 255, 255, 0.02));
overflow: hidden;
}
@@ -143,7 +148,7 @@
align-items: center;
justify-content: space-between;
padding-top: 1rem;
border-top: 1px solid var(--border, rgba(255, 255, 255, 0.06));
border-top: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.06));
font-size: 0.8rem;
font-weight: 500;
}

View File

@@ -34,7 +34,6 @@
text-transform: none;
font-style: italic;
margin-bottom: 48px;
opacity: 0.7;
}
[data-preset='editorial'] .hero__nav-link {

View File

@@ -1,5 +1,20 @@
/* Fonts are loaded dynamically via layout.tsx based on theme config */
/* --text-muted missed the 4.5:1 that WCAG AA requires for text of this size
in every preset, ranging from 2.33:1 (monograph) to 3.50:1 (studio-modern).
It is not a decorative colour: it carries the hero subtitle, section
labels, album metadata and the footer links, all between 0.6 and 0.9 rem.
Only lightness was raised; hue and saturation are unchanged, so the warm
greys of editorial, classic and noir stay warm instead of drifting to
neutral. Each value is checked against --bg-primary AND --bg-card, and the
worse of the two counts.
In minimal and monograph this pushed --text-muted so close to
--text-secondary that the two steps became indistinguishable (#828282 vs
#888888). --text-secondary moved along there, restoring two distinct
levels: about 7:1 for secondary, about 4.7:1 for muted. */
/* ── Design Tokens (Fallback Defaults) ───────────── */
/* These are overridden at runtime by CSS vars injected from gallery.yaml theme config. */
:root {
@@ -11,7 +26,7 @@
--text-primary: #f5f5f0;
--text-secondary: #a0a09a;
--text-muted: #706f6a;
--text-muted: #86857f;
--border-subtle: #2a2a28;
--border-hover: #3a3a38;
@@ -64,7 +79,7 @@
--text-primary: #f4f4f2;
--text-secondary: #9c9c96;
--text-muted: #6b6b66;
--text-muted: #82827c;
--border-subtle: rgba(255, 255, 255, 0.08);
--border-hover: rgba(255, 255, 255, 0.2);
@@ -97,7 +112,7 @@
--text-primary: #161614;
--text-secondary: #5a5a55;
--text-muted: #8a8a84;
--text-muted: #73736e;
--border-subtle: rgba(0, 0, 0, 0.09);
--border-hover: rgba(0, 0, 0, 0.25);
@@ -112,8 +127,8 @@
--bg-overlay: rgba(0, 0, 0, 0.98);
--text-primary: #ffffff;
--text-secondary: #888888;
--text-muted: #555555;
--text-secondary: #9e9e9e;
--text-muted: #7c7c7c;
--border-subtle: #1a1a1a;
--border-hover: #333333;
@@ -128,7 +143,7 @@
--text-primary: #000000;
--text-secondary: #475569;
--text-muted: #64748b;
--text-muted: #627083; /* 4.65:1 on the tinted header (#f6f6f1), not just on white */
--border-subtle: #eeeeee;
--border-hover: #cccccc;
@@ -144,7 +159,7 @@
--text-primary: #ede8e0;
--text-secondary: #9a9086;
--text-muted: #6b6359;
--text-muted: #8c8275;
--border-subtle: #2a2620;
--border-hover: #3a3530;
@@ -175,7 +190,7 @@
--text-primary: #f0ece4;
--text-secondary: #a09888;
--text-muted: #706858;
--text-muted: #897f6c;
--border-subtle: #2a2822;
--border-hover: #3a3830;
@@ -206,7 +221,7 @@
--text-primary: #f0ebe0;
--text-secondary: #998f80;
--text-muted: #665c50;
--text-muted: #8a7c6c;
--border-subtle: #252228;
--border-hover: #3a3540;
@@ -236,8 +251,8 @@
--bg-overlay: rgba(15, 15, 15, 0.98);
--text-primary: #e8e8e8;
--text-secondary: #888888;
--text-muted: #555555;
--text-secondary: #a5a5a5;
--text-muted: #828282;
--border-subtle: #222222;
--border-hover: #3a3a3a;
@@ -252,7 +267,7 @@
--text-primary: #111111;
--text-secondary: #475569;
--text-muted: #64748b;
--text-muted: #627083; /* 4.65:1 on the tinted header (#f6f6f1), not just on white */
--border-subtle: #e5e5e5;
--border-hover: #cccccc;