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

@@ -145,7 +145,7 @@ jobs:
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: playwright-report/

View File

@@ -7,6 +7,179 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Releases up to and including v0.9.2 are documented in the
[GitHub releases](https://github.com/ralksta/immich-folio/releases).
## [0.15.0] — 2026-09-19
### Security
- **Full-size originals no longer carry their GPS position to anonymous
visitors** ([#558](https://github.com/ralksta/immich-folio/pull/558)). The
image proxy streamed `?size=original` straight from Immich. An asset token
authorises the _image_, not the size, so a thumbnail token read from the
public HTML plus `?size=original` returned the untouched file — GPS
coordinates and camera model included — to anyone. That went around two
deliberate protections: `/api/exif` only ever hands out city and country,
and `/api/map` quantises coordinates on the server.
The metadata is now overwritten with zeros rather than cut out: JPEG APP1
(EXIF/XMP), APP13 (IPTC) and comments, and the `Exif` and `mime` items of
AVIF/HEIC/HEIF containers. The pixel data stays bit-identical — re-encoding
would cost quality — and the file length stays constant, which keeps
`Content-Length` valid and every absolute offset in an AVIF `iloc` box
correct. The JFIF header and the ICC colour profile are kept on purpose:
dropping the profile would shift colours, which is a rendering change, not a
privacy one. A format that cannot be parsed safely falls back to the preview
instead of being passed through, because silently passing it through was the
bug. The per-album download route is unchanged — it is an explicit opt-in and
gated on its own. **See the upgrade notes: this needs an
`IMAGE_CACHE_VERSION` bump to reach browsers that already cached an
original.**
- **Next.js 16.3.1 → 16.3.5**
([#571](https://github.com/ralksta/immich-folio/pull/571),
[#566](https://github.com/ralksta/immich-folio/pull/566)). `npm audit`
reported 16.3.1 as critical (GHSA-p293-qw3h-jr36, unauthenticated RCE on
Windows-hosted servers). The shipped image runs on Linux and was not exposed,
but the move stays within the minor. A high-severity advisory for `js-yaml`,
which parses `gallery.yaml` at runtime, landed in the same week and is
resolved in the lockfile. `npm audit` is clean.
### Added
- **Diagnostics is a page: `/admin/diagnostics`**, with an alt-text report
([#579](https://github.com/ralksta/immich-folio/pull/579)). The doctor used to
be a modal. It is now a page that lists the findings that want a look first,
groups the passed checks into connection, security and content, and gathers
backups and cache clearing that were spread over two other places. **Every
finding links to its fix** — an admin route where the fix lives in the panel,
a docs section where it is an environment variable or the volume — instead of
a severity pill that looked like a button and did nothing. A finding about a
single album opens the page it is published on and marks its tile.
The new **alt-text report** answers a question nothing answered before.
Folio's alt text is the Immich description, used only while _Photo
Description_ is on; a photo without one rendered with an empty `alt`,
silently. The report lists the published photos without a description, by
album, with links into Immich. It is deliberately kept out of the status
badge: most libraries have gaps, and a badge that never turns green stops
being read.
- **Markdown links in journal quote attributions**
([#563](https://github.com/ralksta/immich-folio/pull/563), by
[@lancetm714](https://github.com/lancetm714)).
`Quote text -- [Source](https://…)` now renders a link, like the quote body
and captions already did. It reuses the same renderer, so the URL scheme
allowlist applies unchanged.
- **The Backup Manager restores journal entries and the About page, including
deleted journal entries**
([#581](https://github.com/ralksta/immich-folio/pull/581)). Deleting a
journal entry was a bare file delete — the one irreversible action in the
journal was the one without a way back — and the Backup Manager only knew
`gallery.yaml` and `settings.yaml`, so the journal and About backups that
saves did write could not be restored from the panel at all. A delete now
keeps a copy first and is refused if that copy cannot be written; the manager
gains About and Journal tabs, and a deleted entry comes back under its old
slug. Deleted and pre-restore snapshots are exempt from the ten-backup
rotation.
### Fixed
- **Settings no longer discard unsaved edits when you switch sections**
([#582](https://github.com/ralksta/immich-folio/pull/582)). Each settings
section is its own route, and the editor was rendered by the section page, so
it remounted on every switch: change the site title, open another section,
and the edit was gone — no warning, the save bar simply vanished. The editor
now lives in the settings layout, which stays mounted. There is one "Save
Changes" bar for all sections, saving `settings.yaml` and `about.md`
together, and the leave-page warning now covers About edits too, which it
never did.
- **The subpage Live Preview shows the page the site renders**
([#583](https://github.com/ralksta/immich-folio/pull/583)). The drawer drew
its own banner and tiles in admin styling and knew nothing of the theme
preset, the cover grid columns, the caption bar or the header kicker. It now
renders the public page's own markup, styled by the preset, sized by the same
cover-grid calculation, laid out at desktop width and scaled into the drawer.
- **Portrait photos render as portrait tiles**
([#565](https://github.com/ralksta/immich-folio/issues/565),
[#572](https://github.com/ralksta/immich-folio/pull/572), by
[@ImScheinox](https://github.com/ImScheinox)). Most cameras store a portrait
frame in landscape pixels plus an EXIF orientation flag, and Immich's API
reports the unrotated dimensions. The aspect ratio ignored the flag, so in
`masonry`, `justified` and `editorial-flow` every portrait frame got a
landscape tile and the masonry grid lost its stagger. Orientations 5–8 now
swap width and height. (The same bug was fixed again independently in #576;
that was reverted in #577 so the fix lands under the person who found it
first.)
- **Text meets WCAG AA contrast in every preset**
([#571](https://github.com/ralksta/immich-folio/pull/571),
[#580](https://github.com/ralksta/immich-folio/pull/580)). `--text-muted`,
which carries hero subtitles, section labels, album metadata and footer
links, was below 4.5:1 in all fourteen theme blocks — as low as 2.33:1 in
monograph. It was recomputed against both background tokens with hue and
saturation untouched, so the warm greys stay warm. A follow-up replaced the
`opacity` dimming that sat on top of those tuned tokens (footer Impressum
link, `/impressum` headings, the editorial hero subtitle). An axe audit across
all seven presets went from 129 failures to 0 in light mode.
`npm run audit:contrast` runs that audit against a running site.
- **The admin panel works from the keyboard**
([#571](https://github.com/ralksta/immich-folio/pull/571)). The focus signal
was a 1px border change at 2.37:1; there is now a real focus ring on
`:focus-visible`. The asset picker, album picker, backup manager and proofing
modal were mouse-only — no Escape, focus left behind, Tab walking out into
the page — and now share one dialog hook with focus trapping and restoration.
Eight admin selects gained an accessible name.
- **Light mode no longer paints dark text on dark surfaces**
([#562](https://github.com/ralksta/immich-folio/pull/562), by
[@lancetm714](https://github.com/lancetm714);
[#571](https://github.com/ralksta/immich-folio/pull/571)). Nine CSS tokens
were read but never defined, so they fell back to dark-theme defaults. The
proofing bar and its Share & Export modal were about 1.05:1 in light mode;
journal cards had no visible edge; essays fell back to the browser's generic
fonts instead of the theme's.
- **The proofing bar floats again with page transitions on**
([#560](https://github.com/ralksta/immich-folio/pull/560), by
[@lancetm714](https://github.com/lancetm714)). The page fade-in animated a
`transform`, and `animation-fill-mode: forwards` kept it applied forever. Any
transform — even the identity — makes an element the containing block for
`position: fixed` descendants, so the selection bar and its modal scrolled
away with the page. The fade is opacity only now.
- **Album titles on `studio-modern` cover cards are readable in light mode**
([#578](https://github.com/ralksta/immich-folio/pull/578)). The cover's
dominant colour — its loading placeholder — was set on the whole card, so
`studio-modern`'s caption bar took the photo's colour under dark text. It now
sits on the image box only.
### Internal
- Dependency updates: React 19.3.0, `@playwright/test` 1.63.0,
`actions/upload-artifact` 7
([#574](https://github.com/ralksta/immich-folio/pull/574),
[#573](https://github.com/ralksta/immich-folio/pull/573),
[#567](https://github.com/ralksta/immich-folio/pull/567)). `axe-core` is a
direct devDependency for the contrast audit.
### Upgrade notes
**Bump `IMAGE_CACHE_VERSION` when you deploy this release.** Image URLs are
served `immutable` with a one-year cache, so an original a browser already
fetched keeps its metadata in that cache after the upgrade. Changing
`IMAGE_CACHE_VERSION` changes every image URL and is the only thing that
reaches those caches. If it is not set yet, set it to anything:
```bash
IMAGE_CACHE_VERSION=2
```
No configuration schema changed; there is nothing else to migrate. An original
that already left your server cannot be recalled.
## [0.14.0] — 2026-08-30
### Added

View File

@@ -367,7 +367,13 @@ easier to live with.
turns a fresh install into a few clicks in the browser instead of hand-written
config files. Also added custom favicons, so your portfolio gets its own icon
in the browser tab, and made a gallery with no albums yet a perfectly valid
starting point rather than an error.
starting point rather than an error. In v0.15.0, made client proofing work
properly: the selection bar floats again with page transitions on, and it is
readable in light mode. Also brought links to journal quote attributions.
- **[@ImScheinox](https://github.com/ImScheinox)** — found and fixed portrait
photos rendering as landscape tiles, because the grid ignored the camera's
EXIF orientation flag. That brought the masonry layout's stagger back for
anyone whose camera records portrait frames that way, which is most of them.
- **[Jules](https://jules.google.com)** — an automated reviewer that has been
quietly hardening the project in the background: better screen-reader support
in the photo grid, and a series of fixes keeping the public endpoints from

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;

View File

@@ -48,11 +48,16 @@
color: var(--text-primary);
font-family: var(--font-sans);
font-size: 0.9rem;
outline: none;
transition: border-color var(--transition-fast);
}
.input:focus {
/* The base rule used to set `outline: none`, leaving a 1px border colour
change as the only focus signal. That is too little on a page whose only
purpose is typing, and a colour-only cue can be lost on colour-blind
visitors. The border change stays; the ring is added. */
.input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-color: var(--accent);
}

View File

@@ -1,15 +1,22 @@
'use client';
import React, { useState } from 'react';
import React, { useState, useCallback } from 'react';
import { useProofing } from './ProofingContext';
import { IconCheck, IconCopy, IconLink } from './Icons';
import { useDictionary } from './I18nProvider';
import { useModalDialog } from '@/hooks/useModalDialog';
export function ProofingModal() {
const t = useDictionary();
const proofing = useProofing();
const [copiedState, setCopiedState] = useState<'none' | 'link' | 'list'>('none');
/* Before the early `return null`: hooks must not run conditionally.
`proofing` can be null, hence the optional calls. */
const isOpen = Boolean(proofing?.isModalOpen);
const close = useCallback(() => proofing?.setIsModalOpen(false), [proofing]);
const cardRef = useModalDialog(close, isOpen);
if (!proofing || !proofing.isModalOpen) return null;
const {
@@ -59,14 +66,24 @@ export function ProofingModal() {
padding: '1rem',
}}
>
{/* This card used to read `--bg-surface` and `--border-color`, which
are defined nowhere, so it always fell back to the hard-coded dark
values. In the light theme that meant a #1e1e1e card under
`--text-primary`, which does exist there and resolves to #1a1a18:
dark text on a dark background, about 1.05:1. It now uses tokens
that exist. */}
<div
className="proofing-modal-card"
ref={cardRef}
role="dialog"
aria-modal="true"
aria-labelledby="proofing-modal-title"
onClick={(e) => e.stopPropagation()}
style={{
background: 'var(--bg-surface, #1e1e1e)',
background: 'var(--bg-card, #1e1e1e)',
color: 'var(--text-primary, #ffffff)',
borderRadius: 'var(--radius-md, 12px)',
border: '1px solid var(--border-color, rgba(255,255,255,0.1))',
border: '1px solid var(--border-subtle, rgba(255,255,255,0.1))',
padding: '1.5rem',
maxWidth: '480px',
width: '100%',
@@ -81,7 +98,7 @@ export function ProofingModal() {
marginBottom: '1rem',
}}
>
<h3 style={{ margin: 0, fontSize: '1.2rem', fontWeight: 600 }}>
<h3 id="proofing-modal-title" style={{ margin: 0, fontSize: '1.2rem', fontWeight: 600 }}>
{t.proofing.modalTitle(favorites.size)}
</h3>
<button
@@ -145,9 +162,9 @@ export function ProofingModal() {
gap: '0.5rem',
padding: '0.75rem 1rem',
borderRadius: 'var(--radius-sm, 6px)',
background: 'rgba(255,255,255,0.1)',
background: 'var(--bg-card-hover)',
color: 'inherit',
border: '1px solid rgba(255,255,255,0.15)',
border: '1px solid var(--border-subtle)',
fontWeight: 500,
cursor: 'pointer',
}}
@@ -174,9 +191,9 @@ export function ProofingModal() {
gap: '0.5rem',
padding: '0.75rem 1rem',
borderRadius: 'var(--radius-sm, 6px)',
background: 'rgba(255,255,255,0.1)',
background: 'var(--bg-card-hover)',
color: 'inherit',
border: '1px solid rgba(255,255,255,0.15)',
border: '1px solid var(--border-subtle)',
fontWeight: 500,
cursor: 'pointer',
}}

View File

@@ -153,20 +153,26 @@ View counts per page and album, read from `content/analytics.json`. No cookies,
## Backups
Every time you save, the previous file is backed up automatically:
Every time you save, the previous file is backed up automatically. Deleting a
journal entry keeps a copy too:
```
content/.backups/
├── gallery.yaml.2026-05-29T14-30-00-000Z.bak
├── settings.yaml.2026-05-29T14-30-00-000Z.bak
├── about.md.2026-05-29T14-30-00-000Z.bak
└── ...
content/journal/.backups/
└── my-story.md.2026-05-29T14-30-00-000Z.bak
├── my-story.md.2026-05-29T14-30-00-000Z.bak
└── old-trip.md.2026-05-30T09-12-00-000Z.deleted.bak
```
- Up to **10 backups** per file are retained (oldest are pruned)
- The **Backup Manager** lists them and restores any one with a single click
- The **Backup Manager** (dashboard) has a tab each for gallery, settings, about
and journal, and restores any backup with a single click — a deleted journal
entry comes back under its old slug
- Before a restore, a `*.pre-restore.bak` snapshot is created
- `*.deleted.bak` and `*.pre-restore.bak` snapshots are never pruned
- All writes are **atomic** (write to temp file, then rename) — no risk of a half-written YAML
## Docker Usage
@@ -198,7 +204,11 @@ This is useful after making external changes to config files or when Immich data
### Diagnostics
The panel reports the health of the installation: whether Immich answers, whether `gallery.yaml` and `settings.yaml` parse, the number of cached entries, and the backup count with the timestamp of the most recent one. `settings.yaml` is optional, so its absence is not a fault — only a file that exists and cannot be parsed counts as invalid. A check that could not run (an expired session, for example) is reported as such rather than as an outage.
The status badge in the header opens a short summary: whether Immich answers, whether `gallery.yaml` and `settings.yaml` parse, the number of cached entries, and the backup count with the timestamp of the most recent one. `settings.yaml` is optional, so its absence is not a fault — only a file that exists and cannot be parsed counts as invalid. A check that could not run (an expired session, for example) is reported as such rather than as an outage.
Its **Diagnostics** button leads to the full page at `/admin/diagnostics`. Anything that wants a look is listed first; the checks that passed follow, grouped into connection, security and content. Each finding that wants a look links to where it is fixed — a finding about one album opens the subpage it is published on (or, for a standalone album, the album itself). The page also manages backups and clears the cache, and **Copy report** puts the findings on the clipboard as Markdown, ready for a GitHub issue.
It also reports **alt text**. Folio has no alt-text field of its own: the Immich description of a photo becomes its alt text, as long as **Photo Description** is switched on under Settings → General. The page counts the published photos that have no description and lists them by album, each linking to the photo in Immich. Add the description there and press **Reload**. The links use Immich's own **External domain** (Administration → Settings → Server in Immich) when it is set. Otherwise they fall back to the address Folio reaches Immich at, which is often an internal Docker name your browser cannot open. Missing alt text does not colour the status badge — most libraries have some, and a badge that is never green stops being read.
Separately, while you are logged in, the public site shows a diagnostic banner on any album Immich returns empty — the case that otherwise looks like a broken page to you and to nobody else.

View File

@@ -114,9 +114,11 @@ whether every published album ID still exists in Immich (and is shared there),
whether any password is still stored in plaintext or as an unusable bcrypt
hash, and whether `content/` can be written to.
This is the same set of checks as the **Diagnostics** panel in `/admin`, but it
This is the same set of checks as the **Diagnostics** page in `/admin`, but it
needs neither a running app nor an admin password — which is the point. It is
the tool for the case where the site will not come up at all.
the tool for the case where the site will not come up at all. (The page adds
an alt-text report on top, which needs the albums loaded and has no CLI
counterpart.)
It runs in the shipped image too:
@@ -146,7 +148,7 @@ docker compose exec folio npm run doctor
The other is `TRUSTED_PROXY_HOPS`, judged against the
`X-Forwarded-For` chain of a live request, and a CLI has none. It reports the
configured value and says so rather than guessing — use the Diagnostics panel,
configured value and says so rather than guessing — use the Diagnostics page,
reached over your public URL, to have that one measured.
Both are printed under **NOTES** rather than among the passed checks, since

115
hooks/useModalDialog.ts Normal file
View File

@@ -0,0 +1,115 @@
import { useEffect, useRef } from 'react';
/**
* Turns a plain overlay `<div>` into something that behaves like a dialog.
*
* The admin modals (asset picker, album picker, backup manager) and the
* proofing modal were all built the same way: an overlay div whose `onClick`
* closes it, and a card inside that stops the click from bubbling. That works
* with a mouse and with nothing else. There was no Escape key, the focus
* stayed on whatever had opened the modal, Tab walked straight out of the
* dialog into the page behind it, and on close the focus was left nowhere at
* all — a screen reader user landed back at the top of the document.
*
* This hook supplies the four pieces that were missing, so the markup only
* has to add `role="dialog"`, `aria-modal="true"` and a label:
*
* 1. Escape closes.
* 2. The focus moves into the dialog when it opens — onto the element
* carrying `autoFocus`, if there is one, otherwise the first thing that
* can take it.
* 3. Tab and Shift+Tab cycle inside the dialog instead of leaving it.
* 4. On close the focus returns to whatever had it before.
*
* Returns the ref to put on the dialog card (not the overlay).
*
* `active` exists for the modals that render `null` while closed but still
* have to call the hook — hooks may not run conditionally. Without it the
* effect would run once against an unmounted card, bail out, and never run
* again, because `onClose` alone does not change when the modal opens.
*/
const FOCUSABLE = [
'a[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
function focusable(root: HTMLElement): HTMLElement[] {
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter((el) =>
// A hidden branch (a collapsed panel, a closed tab) must not swallow the
// focus. `offsetParent !== null` is the usual shorthand for this and is
// wrong here: it is null for anything `position: fixed`, which is exactly
// what these modals are made of. checkVisibility() answers the actual
// question; where it does not exist, counting an element as visible is
// the safe side — a focus trap that lists one element too many still
// traps.
typeof el.checkVisibility === 'function' ? el.checkVisibility() : true,
);
}
export function useModalDialog(onClose: () => void, active = true) {
const cardRef = useRef<HTMLDivElement>(null);
// Kept in a ref so a new `onClose` identity does not re-run the effect.
// Callers pass inline arrows (and ProofingContext a fresh value object on
// every render); re-running would bounce the focus out of the dialog and
// back onto its first element whenever the parent re-rendered.
const onCloseRef = useRef(onClose);
useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
useEffect(() => {
const card = cardRef.current;
if (!active || !card) return;
const previous = document.activeElement as HTMLElement | null;
// React has already applied `autoFocus` by the time the effect runs, so
// an existing focus inside the card is respected rather than overridden.
if (!card.contains(document.activeElement)) {
(card.querySelector<HTMLElement>('[autofocus]') ?? focusable(card)[0])?.focus();
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.stopPropagation();
onCloseRef.current();
return;
}
if (e.key !== 'Tab' || !card) return;
const items = focusable(card);
if (items.length === 0) {
// Nothing to land on — keep the focus in the dialog rather than
// letting Tab escape into the page behind it.
e.preventDefault();
return;
}
const first = items[0];
const last = items[items.length - 1];
const current = document.activeElement;
if (e.shiftKey && (current === first || !card.contains(current))) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && current === last) {
e.preventDefault();
first.focus();
}
}
document.addEventListener('keydown', onKeyDown, true);
return () => {
document.removeEventListener('keydown', onKeyDown, true);
// Only take the focus back if it is still inside the closing dialog.
// Something else may legitimately have claimed it in the meantime.
if (previous && document.contains(previous)) previous.focus();
};
}, [active]);
return cardRef;
}

View File

@@ -0,0 +1,135 @@
import { describe, it, expect } from 'vitest';
import {
albumPaths,
buildAltTextReport,
immichWebUrl,
pickImmichWebUrl,
type AltTextAssetInput,
} from '../admin/alt-text';
const photo = (id: string, description: string | null = null): AltTextAssetInput => ({
id,
type: 'IMAGE',
originalFileName: `${id}.jpg`,
exifInfo: { description },
});
describe('buildAltTextReport', () => {
it('lists photos without a description, per album', () => {
const report = buildAltTextReport(
[
{
id: 'a1',
name: 'Island',
path: '/travel',
assets: [photo('p1', 'A fjord'), photo('p2')],
},
],
true,
);
expect(report.total).toBe(2);
expect(report.described).toBe(1);
expect(report.albums).toEqual([
{
albumId: 'a1',
albumName: 'Island',
path: '/travel',
total: 2,
missing: [{ assetId: 'p2', fileName: 'p2.jpg' }],
},
]);
});
/** A description of spaces renders as an empty alt all the same. */
it('treats a blank description as missing', () => {
const report = buildAltTextReport(
[{ id: 'a1', name: 'A', path: '/', assets: [photo('p1', ' ')] }],
true,
);
expect(report.described).toBe(0);
expect(report.albums[0].missing).toHaveLength(1);
});
/** Videos have no alt attribute to fill. */
it('does not count videos', () => {
const report = buildAltTextReport(
[
{
id: 'a1',
name: 'A',
path: '/',
assets: [{ id: 'v1', type: 'VIDEO', originalFileName: 'v1.mp4' }, photo('p1', 'x')],
},
],
true,
);
expect(report.total).toBe(1);
expect(report.albums).toEqual([]);
});
it('counts a photo that sits in two albums once', () => {
const shared = photo('p1');
const report = buildAltTextReport(
[
{ id: 'a1', name: 'A', path: '/', assets: [shared] },
{ id: 'a2', name: 'B', path: '/', assets: [shared] },
],
true,
);
expect(report.total).toBe(1);
expect(report.albums).toHaveLength(2);
});
it('puts the album with the most gaps first and leaves out complete ones', () => {
const report = buildAltTextReport(
[
{ id: 'few', name: 'Few', path: '/', assets: [photo('p1')] },
{ id: 'done', name: 'Done', path: '/', assets: [photo('p2', 'x')] },
{ id: 'many', name: 'Many', path: '/', assets: [photo('p3'), photo('p4')] },
],
true,
);
expect(report.albums.map((a) => a.albumId)).toEqual(['many', 'few']);
});
it('passes the caption setting through', () => {
expect(buildAltTextReport([], false).captionsEnabled).toBe(false);
});
});
describe('albumPaths', () => {
it('uses the first subpage that lists an album', () => {
const paths = albumPaths([
{ slug: 'travel', albumIds: ['a1'] },
{ slug: 'best-of', albumIds: ['a1', 'a2'] },
]);
expect(paths.get('a1')).toBe('/travel');
expect(paths.get('a2')).toBe('/best-of');
expect(paths.get('a3')).toBeUndefined();
});
});
describe('immichWebUrl', () => {
it('drops the /api suffix Folio appends', () => {
expect(immichWebUrl('http://immich:2283/api')).toBe('http://immich:2283');
expect(immichWebUrl('https://photos.example.com/api/')).toBe('https://photos.example.com');
});
});
describe('pickImmichWebUrl', () => {
it("prefers Immich's external domain", () => {
expect(pickImmichWebUrl('https://photos.example.com/', 'http://immich:2283/api')).toEqual({
url: 'https://photos.example.com',
source: 'external-domain',
});
});
it('falls back to the API address when the domain is unset or not a URL', () => {
for (const domain of ['', ' ', null, undefined, 'photos.example.com']) {
expect(pickImmichWebUrl(domain, 'http://immich:2283/api')).toEqual({
url: 'http://immich:2283',
source: 'api-url',
});
}
});
});

View File

@@ -36,6 +36,8 @@ describe('restoreBackup rejects anything that is not a backup it produced', () =
'gallery.yaml', // the live file, not a backup
'gallery.yaml.2026-05-31T17-30-00-000Z.bak.txt',
'evil-gallery.yaml.2026-05-31T17-30-00-000Z.bak',
'about.yaml.2026-05-31T17-30-00-000Z.bak',
'trip.md.2026-05-31T17-30-00-000Z.bak', // journal backups go through the journal service
'',
];
@@ -66,6 +68,13 @@ describe('restoreBackup accepts the names it writes', () => {
expect(fs.copyFile).toHaveBeenCalled();
});
it('restores an about.md backup onto content/about.md', async () => {
await restoreBackup('about.md.2026-05-31T17-30-00-000Z.bak');
const calls = vi.mocked(fs.copyFile).mock.calls;
expect(String(calls[calls.length - 1][1])).toMatch(/content\/about\.md$/);
});
it('restores a pre-restore backup', async () => {
await expect(
restoreBackup('settings.yaml.2026-05-31T17-30-00-000Z.pre-restore.bak'),

View File

@@ -110,6 +110,7 @@ describe('checkAlbumIds', () => {
const f = checkAlbumIds(['a', 'ghost'], known);
expect(f.level).toBe('error');
expect(f.detail).toContain('ghost');
expect(f.albumIds).toEqual(['ghost']);
});
it('flags an empty gallery', () => {
@@ -132,6 +133,7 @@ describe('checkAlbumsShared', () => {
const f = checkAlbumsShared(['a', 'b'], known);
expect(f.level).toBe('warn');
expect(f.detail).toContain('Private trip');
expect(f.albumIds).toEqual(['b']);
});
/** An older Immich may not report the flag; silence beats a false alarm. */

View File

@@ -142,4 +142,45 @@ A closing paragraph with **bold** details.`;
expect(markdown).toContain('![photo-1:fullbleed](Caption)');
expect(markdown).toContain('![p1, p2](Pair caption)');
});
it('keeps a markdown link in the quote attribution for rendering (#559)', () => {
const markdown = '> Silence was absolute. -- [BBC Archive](https://example.com/archive)';
const parsed = parseEssayMarkdown(markdown);
const quote = parsed.blocks[0];
expect(quote.type).toBe('quote');
if (quote.type !== 'quote') return;
// The attribution stays Markdown; EssayView renders it with
// renderInlineMarkdown, which is what turns the link into an anchor.
expect(quote.author).toBe('[BBC Archive](https://example.com/archive)');
expect(renderInlineMarkdown(quote.author!)).toContain(
'<a href="https://example.com/archive" target="_blank" rel="noopener noreferrer">BBC Archive</a>',
);
});
it('survives a serialize/parse round-trip without losing the attribution link', () => {
const markdown = '> Silence was absolute. -- [BBC Archive](https://example.com/archive)';
const once = serializeEssayMarkdown(parseEssayMarkdown(markdown));
expect(once).toContain('> Silence was absolute. -- [BBC Archive](https://example.com/archive)');
const reparsed = parseEssayMarkdown(once);
const quote = reparsed.blocks[0];
expect(quote.type).toBe('quote');
if (quote.type === 'quote') {
expect(quote.author).toBe('[BBC Archive](https://example.com/archive)');
}
});
it('does not turn an unsafe scheme in the attribution into a link', () => {
const markdown = '> Careful. -- [x](javascript:alert(1))';
const parsed = parseEssayMarkdown(markdown);
const quote = parsed.blocks[0];
expect(quote.type).toBe('quote');
if (quote.type !== 'quote') return;
const html = renderInlineMarkdown(quote.author!);
expect(html).not.toContain('<a');
expect(html).not.toContain('href');
});
});

View File

@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import os from 'os';
import path from 'path';
import { mkdtemp, mkdir, readdir, readFile, rm, writeFile } from 'fs/promises';
/**
* Deleting a journal entry used to be a bare unlink, while saving rotated a
* backup — the one irreversible action was the one without a way back, and the
* Backup Manager could not restore journal backups at all.
*
* The service resolves content/ from process.cwd() at import time, so each test
* points cwd at a fresh temp directory and re-imports it.
*/
let root: string;
let journalDir: string;
let backupDir: string;
let service: typeof import('../admin/journal-service');
beforeEach(async () => {
root = await mkdtemp(path.join(os.tmpdir(), 'folio-journal-'));
journalDir = path.join(root, 'content', 'journal');
backupDir = path.join(journalDir, '.backups');
await mkdir(journalDir, { recursive: true });
vi.spyOn(process, 'cwd').mockReturnValue(root);
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.resetModules();
service = await import('../admin/journal-service');
});
afterEach(async () => {
vi.restoreAllMocks();
await rm(root, { recursive: true, force: true });
});
const entry = (title: string) => `---\ntitle: "${title}"\n---\n\nBody of ${title}\n`;
describe('deleteJournalEntry', () => {
it('keeps a .deleted.bak copy before removing the file', async () => {
await writeFile(path.join(journalDir, 'trip.md'), entry('Trip'));
await expect(service.deleteJournalEntry('trip')).resolves.toBe(true);
await expect(readdir(journalDir)).resolves.not.toContain('trip.md');
const backups = await readdir(backupDir);
expect(backups).toHaveLength(1);
expect(backups[0]).toMatch(/^trip\.md\.[\w-]+\.deleted\.bak$/);
await expect(readFile(path.join(backupDir, backups[0]), 'utf8')).resolves.toBe(entry('Trip'));
});
it('returns false for a missing entry without writing a backup', async () => {
await expect(service.deleteJournalEntry('nope')).resolves.toBe(false);
await expect(readdir(backupDir)).rejects.toThrow();
});
it('refuses to delete when the backup cannot be written', async () => {
await writeFile(path.join(journalDir, 'trip.md'), entry('Trip'));
// A file where the backup directory should be makes mkdir and copy fail.
await writeFile(backupDir, 'not a directory');
await expect(service.deleteJournalEntry('trip')).rejects.toThrow();
await expect(readdir(journalDir)).resolves.toContain('trip.md');
});
});
describe('restoreJournalBackup', () => {
it('brings a deleted entry back under its slug', async () => {
await writeFile(path.join(journalDir, 'trip.md'), entry('Trip'));
await service.deleteJournalEntry('trip');
const [deleted] = await service.listJournalBackups();
expect(deleted).toMatchObject({ slug: 'trip', kind: 'deleted' });
await expect(service.restoreJournalBackup(deleted.filename)).resolves.toBe('trip');
await expect(readFile(path.join(journalDir, 'trip.md'), 'utf8')).resolves.toBe(entry('Trip'));
});
it('snapshots the current version before overwriting it', async () => {
await writeFile(path.join(journalDir, 'trip.md'), entry('Old'));
await service.writeJournalEntry('trip', entry('New'));
const [saved] = await service.listJournalBackups();
await service.restoreJournalBackup(saved.filename);
await expect(readFile(path.join(journalDir, 'trip.md'), 'utf8')).resolves.toBe(entry('Old'));
const kinds = (await service.listJournalBackups()).map((b) => b.kind).sort();
expect(kinds).toEqual(['pre-restore', 'save']);
});
const rejected = [
'../../../../etc/passwd',
'trip.md',
'../trip.md.2026-05-31T17-30-00-000Z.bak',
'a/trip.md.2026-05-31T17-30-00-000Z.bak',
'trip.md.2026-05-31T17-30-00-000Z.bak.txt',
'gallery.yaml.2026-05-31T17-30-00-000Z.bak',
'',
];
for (const name of rejected) {
it(`refuses ${JSON.stringify(name)}`, async () => {
await expect(service.restoreJournalBackup(name)).rejects.toThrow(/unrecognised backup name/i);
});
}
});
describe('backup rotation', () => {
// Backup names carry a millisecond timestamp; saves in a tight loop would
// share one and overwrite each other, which a person clicking Save cannot.
let clock = Date.parse('2026-09-01T00:00:00Z');
beforeEach(() => vi.useFakeTimers({ toFake: ['Date'] }));
afterEach(() => vi.useRealTimers());
const save = async (slug: string, body: string) => {
vi.setSystemTime((clock += 1000));
await service.writeJournalEntry(slug, body);
};
it('prunes save backups but never the deleted snapshot', async () => {
await writeFile(path.join(journalDir, 'trip.md'), entry('Gone'));
await service.deleteJournalEntry('trip');
// Re-create the slug and save it well past the rotation limit.
await save('trip', entry('v0'));
for (let i = 1; i <= 12; i++) {
await save('trip', entry(`v${i}`));
}
const backups = await service.listJournalBackups();
expect(backups.filter((b) => b.kind === 'save')).toHaveLength(10);
expect(backups.filter((b) => b.kind === 'deleted')).toHaveLength(1);
});
it('does not prune another entry whose slug shares a prefix', async () => {
await save('trip-2', entry('Other v0'));
await save('trip-2', entry('Other v1'));
await save('trip', entry('v0'));
for (let i = 1; i <= 12; i++) {
await save('trip', entry(`v${i}`));
}
const other = (await service.listJournalBackups()).filter((b) => b.slug === 'trip-2');
expect(other).toEqual([expect.objectContaining({ kind: 'save' })]);
});
});

View File

@@ -0,0 +1,177 @@
import { describe, it, expect } from 'vitest';
import {
findJpegMetadataRanges,
findIsobmffMetadataRanges,
findMetadataRanges,
stripMetadata,
isStrippable,
} from '../metadataStrip';
// ── kleine Bau-Helfer ───────────────────────────────────────────────────────
const cat = (...parts: Uint8Array[]) => {
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let o = 0;
for (const p of parts) {
out.set(p, o);
o += p.length;
}
return out;
};
const bytes = (...n: number[]) => new Uint8Array(n);
const ascii = (s: string) => new Uint8Array([...s].map((c) => c.charCodeAt(0)));
const be16 = (n: number) => bytes((n >> 8) & 0xff, n & 0xff);
const be32 = (n: number) => bytes((n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff);
/** JPEG-Segment: FFxx <len inkl. Längenfeld> <payload> */
const seg = (marker: number, payload: Uint8Array) =>
cat(bytes(0xff, marker), be16(payload.length + 2), payload);
/** ISOBMFF-Box mit korrekt berechneter Größe. */
const box = (type: string, ...content: Uint8Array[]) => {
const body = cat(...content);
return cat(be32(body.length + 8), ascii(type), body);
};
describe('isStrippable', () => {
it('kennt JPEG und die ISOBMFF-Familie', () => {
expect(isStrippable('image/jpeg')).toBe(true);
expect(isStrippable('IMAGE/AVIF')).toBe(true);
expect(isStrippable('image/jpeg; charset=binary')).toBe(true);
expect(isStrippable('image/png')).toBe(false);
expect(isStrippable('application/octet-stream')).toBe(false);
});
});
describe('JPEG', () => {
const exifPayload = cat(ascii('Exif\0\0'), ascii('MM\0*GPS 54.156, 15.3717'));
const iccPayload = cat(ascii('ICC_PROFILE\0'), bytes(1, 1, 2, 3, 4, 5));
const jfifPayload = cat(ascii('JFIF\0'), bytes(1, 2, 0, 0, 1, 0, 1, 0, 0));
const bildDaten = bytes(0x12, 0x34, 0x56, 0x78, 0x9a);
const bauJpeg = () =>
cat(
bytes(0xff, 0xd8), // SOI
seg(0xe0, jfifPayload), // APP0 JFIF — bleibt
seg(0xe1, exifPayload), // APP1 EXIF — muss weg
seg(0xe2, iccPayload), // APP2 ICC — bleibt (Farbprofil!)
seg(0xed, ascii('Photoshop 3.0\0IPTC')), // APP13 — muss weg
seg(0xfe, ascii('ein Kommentar')), // COM — muss weg
bytes(0xff, 0xda),
be16(8),
bytes(1, 0, 0, 63, 0), // SOS
bildDaten,
bytes(0xff, 0xd9), // EOI
);
it('findet EXIF, IPTC und Kommentar, aber nicht JFIF/ICC', () => {
const b = bauJpeg();
const ranges = findJpegMetadataRanges(b);
expect(ranges).toHaveLength(3);
// Das EXIF-Segment muss vollständig erfasst sein
const exifStart = b.indexOf(0x45); // 'E' von 'Exif'
expect(ranges[0].start).toBe(exifStart);
expect(ranges[0].end - ranges[0].start).toBe(exifPayload.length);
});
it('nullt die GPS-Daten und lässt alles andere unangetastet', () => {
const original = bauJpeg();
const gestrippt = stripMetadata(bauJpeg(), 'image/jpeg')!;
expect(gestrippt).not.toBeNull();
// Länge unverändert -> Content-Length bleibt gültig
expect(gestrippt.length).toBe(original.length);
const alsText = new TextDecoder('latin1').decode(gestrippt);
expect(alsText).not.toContain('GPS 54.156');
expect(alsText).not.toContain('Exif');
expect(alsText).not.toContain('IPTC');
expect(alsText).not.toContain('ein Kommentar');
// Farbprofil und JFIF müssen erhalten bleiben, sonst kippen die Farben
expect(alsText).toContain('ICC_PROFILE');
expect(alsText).toContain('JFIF');
// Bilddaten hinter SOS Byte für Byte identisch
const sos = original.length - bildDaten.length - 2;
expect(Array.from(gestrippt.slice(sos))).toEqual(Array.from(original.slice(sos)));
});
it('weist etwas zurück, das kein JPEG ist', () => {
expect(() => findJpegMetadataRanges(bytes(0x00, 0x01, 0x02, 0x03))).toThrow();
expect(findMetadataRanges(bytes(0x00, 0x01, 0x02, 0x03), 'image/jpeg')).toBeNull();
});
});
describe('AVIF / ISOBMFF', () => {
const exifNutzlast = ascii('MM\0*GEHEIME-GPS-KOORDINATEN');
const bildNutzlast = ascii('AV1-BILDDATEN-UNANTASTBAR');
/** infe v2: version+flags, item_ID(16), protection(16), item_type(4) */
const infe = (id: number, typ: string) =>
box('infe', bytes(2, 0, 0, 0), be16(id), be16(0), ascii(typ), bytes(0));
const bauAvif = () => {
// Erst ohne mdat bauen, um die Offsets zu kennen.
const ftyp = box('ftyp', ascii('avif'), be32(0), ascii('avifmif1'));
const iinf = box('iinf', bytes(0, 0, 0, 0), be16(2), infe(1, 'Exif'), infe(2, 'av01'));
// iloc v0: offset_size=4, length_size=4, base_offset_size=0
const ilocEintrag = (id: number, offset: number, len: number) =>
cat(be16(id), be16(0), be16(1), be32(offset), be32(len));
// Platzhalter, um die Gesamtlänge des Headers zu bestimmen
const ilocRoh = (o1: number, o2: number) =>
box(
'iloc',
bytes(0, 0, 0, 0),
bytes(0x44, 0x00),
be16(2),
ilocEintrag(1, o1, exifNutzlast.length),
ilocEintrag(2, o2, bildNutzlast.length),
);
const kopfLaenge = (iloc: Uint8Array) =>
ftyp.length + box('meta', bytes(0, 0, 0, 0), iinf, iloc).length + 8; // +8 mdat-Header
let iloc = ilocRoh(0, 0);
const basis = kopfLaenge(iloc);
iloc = ilocRoh(basis, basis + exifNutzlast.length);
const meta = box('meta', bytes(0, 0, 0, 0), iinf, iloc);
const mdat = box('mdat', exifNutzlast, bildNutzlast);
return cat(ftyp, meta, mdat);
};
it('findet das Exif-Item über iinf und iloc', () => {
const b = bauAvif();
const ranges = findIsobmffMetadataRanges(b);
expect(ranges).toHaveLength(1);
const gefunden = new TextDecoder('latin1').decode(b.slice(ranges[0].start, ranges[0].end));
expect(gefunden).toBe('MM\0*GEHEIME-GPS-KOORDINATEN');
});
it('nullt EXIF, lässt die Bilddaten und die Länge unberührt', () => {
const original = bauAvif();
const gestrippt = stripMetadata(bauAvif(), 'image/avif')!;
expect(gestrippt.length).toBe(original.length);
const alsText = new TextDecoder('latin1').decode(gestrippt);
expect(alsText).not.toContain('GEHEIME-GPS-KOORDINATEN');
expect(alsText).toContain('AV1-BILDDATEN-UNANTASTBAR');
// Die Container-Struktur muss intakt bleiben
expect(alsText).toContain('ftyp');
expect(alsText).toContain('iloc');
});
it('gibt null zurück, wenn es kein ISOBMFF ist', () => {
expect(findMetadataRanges(ascii('nicht mal ansatzweise'), 'image/avif')).toBeNull();
});
});
describe('unbekannte Formate', () => {
it('liefern null — lieber nicht ausliefern als leaken', () => {
expect(findMetadataRanges(bytes(0x89, 0x50, 0x4e, 0x47), 'image/png')).toBeNull();
expect(stripMetadata(bytes(1, 2, 3), 'application/octet-stream')).toBeNull();
});
});

View File

@@ -168,6 +168,58 @@ describe('assetAspectRatio', () => {
}),
).toBeUndefined();
});
/**
* A portrait frame off a Sony A7 V: the sensor is read out in landscape and
* the quarter turn lives in the orientation flag, so the stored dimensions
* are the displayed ones swapped.
*/
const rotated = (orientation?: string | null) => ({
exifInfo: {
make: null,
model: null,
lensModel: null,
focalLength: null,
fNumber: null,
exposureTime: null,
iso: null,
exifImageWidth: 7008,
exifImageHeight: 4672,
orientation,
latitude: null,
longitude: null,
city: null,
state: null,
country: null,
dateTimeOriginal: null,
description: null,
},
});
it.each([
['6', 'rotated 90 CW'],
['8', 'rotated 90 CCW'],
['5', 'mirrored and rotated 90 CCW'],
['7', 'mirrored and rotated 90 CW'],
])('swaps width and height for orientation %s (%s)', (orientation) => {
expect(assetAspectRatio(rotated(orientation))).toBeCloseTo(4672 / 7008);
});
it.each([
['1', 'upright'],
['2', 'mirrored horizontally'],
['3', 'rotated 180'],
['4', 'mirrored vertically'],
])('leaves the ratio alone for orientation %s (%s)', (orientation) => {
expect(assetAspectRatio(rotated(orientation))).toBeCloseTo(7008 / 4672);
});
it.each([
['null', null],
['absent', undefined],
])('treats a %s orientation as upright', (_label, orientation) => {
expect(assetAspectRatio(rotated(orientation))).toBeCloseTo(7008 / 4672);
});
});
describe('assetCaption', () => {

134
lib/admin/alt-text.ts Normal file
View File

@@ -0,0 +1,134 @@
/**
* Alt-text coverage — which published photos reach the site without one.
*
* Folio has no alt-text field of its own: the Immich description becomes the
* `alt` of a photo, and only while the `caption` EXIF group is switched on
* (`assetCaption` in lib/urls.ts). A photo without a description therefore
* renders with an empty alt, silently, and nothing in the admin said so.
*
* Pure, like lib/admin/doctor.ts: the route gathers the albums, this judges
* them. The report is admin-only, so it carries raw asset UUIDs — the same as
* /api/admin/thumbnail, which renders them.
*/
export interface AltTextAssetInput {
id: string;
type: 'IMAGE' | 'VIDEO';
originalFileName: string;
exifInfo?: { description?: string | null } | null;
}
export interface AltTextAlbumInput {
id: string;
name: string;
/** Where the album is published — `/<subpage-slug>`, or `/` for a standalone one. */
path: string;
assets: AltTextAssetInput[];
}
export interface AltTextGap {
assetId: string;
fileName: string;
}
export interface AltTextAlbumReport {
albumId: string;
albumName: string;
path: string;
/** Photos in the album — videos are not counted, they have no alt. */
total: number;
missing: AltTextGap[];
}
export interface AltTextReport {
/** Whether descriptions are used as alt text at all (`exif.caption`). */
captionsEnabled: boolean;
/** Distinct published photos — one that sits in two albums counts once. */
total: number;
described: number;
/** Only albums with gaps, the most gaps first. */
albums: AltTextAlbumReport[];
}
function hasDescription(asset: AltTextAssetInput): boolean {
return !!asset.exifInfo?.description?.trim();
}
export function buildAltTextReport(
albums: AltTextAlbumInput[],
captionsEnabled: boolean,
): AltTextReport {
const seen = new Map<string, boolean>();
const reports: AltTextAlbumReport[] = [];
for (const album of albums) {
const photos = album.assets.filter((a) => a.type === 'IMAGE');
for (const photo of photos) seen.set(photo.id, hasDescription(photo));
const missing = photos
.filter((p) => !hasDescription(p))
.map((p) => ({ assetId: p.id, fileName: p.originalFileName }));
if (missing.length) {
reports.push({
albumId: album.id,
albumName: album.name,
path: album.path,
total: photos.length,
missing,
});
}
}
reports.sort((a, b) => b.missing.length - a.missing.length);
return {
captionsEnabled,
total: seen.size,
described: [...seen.values()].filter(Boolean).length,
albums: reports,
};
}
/**
* The published path of each album: the first subpage that lists it, else `/`.
* An album can sit on several subpages; one path is enough to find it again.
*/
export function albumPaths(
subpages: Array<{ slug: string; albumIds: string[] }>,
): Map<string, string> {
const paths = new Map<string, string>();
for (const sp of subpages) {
for (const id of sp.albumIds) {
if (!paths.has(id)) paths.set(id, `/${sp.slug}`);
}
}
return paths;
}
/**
* The Immich web app's address, derived from the API URL Folio talks to.
*
* This is the address *Folio* reaches Immich at. On a Docker network that is
* often `http://immich:2283`, which the operator's browser cannot open — see
* `pickImmichWebUrl` for the address preferred when there is one.
*/
export function immichWebUrl(apiUrl: string): string {
return apiUrl.replace(/\/+$/, '').replace(/\/api$/, '');
}
/**
* Where links into Immich should point: Immich's own *External domain*
* (Administration → Settings → Server) when it is set — the address Immich
* itself uses for share links, and so one a browser can open — else the
* address derived from the API URL, which may be internal.
*/
export function pickImmichWebUrl(
externalDomain: string | null | undefined,
apiUrl: string,
): { url: string; source: 'external-domain' | 'api-url' } {
const external = externalDomain?.trim().replace(/\/+$/, '');
if (external && /^https?:\/\//i.test(external)) {
return { url: external, source: 'external-domain' };
}
return { url: immichWebUrl(apiUrl), source: 'api-url' };
}

View File

@@ -19,6 +19,11 @@ export interface DoctorFinding {
title: string;
/** One sentence: what was observed, and what to do about it. */
detail: string;
/**
* The Immich album IDs a finding is about, when it is about albums — so the
* diagnostics page can link straight to the album in the page builder.
*/
albumIds?: string[];
}
/** The worst level present — what the status badge should show. */
@@ -194,6 +199,7 @@ export function checkAlbumIds(configured: string[], known: AlbumRef[]): DoctorFi
level: 'error',
title: `${missing.length} of ${configured.length} album IDs do not exist in Immich`,
detail: `Those pages are silently empty. Check gallery.yaml for: ${missing.join(', ')}`,
albumIds: missing,
};
}
@@ -234,6 +240,7 @@ export function checkAlbumsShared(configured: string[], known: AlbumRef[]): Doct
detail:
'They are served to visitors regardless — the allowlist in gallery.yaml is what decides. ' +
`Worth a look if it was unintentional: ${unshared.map((a) => a.albumName).join(', ')}`,
albumIds: unshared.map((a) => a.id),
};
}

View File

@@ -17,9 +17,129 @@ import {
const JOURNAL_DIR = path.join(process.cwd(), 'content', 'journal');
const LEGACY_ESSAYS_DIR = path.join(process.cwd(), 'content', 'essays');
const BACKUP_DIR = path.join(JOURNAL_DIR, '.backups');
const MAX_BACKUPS = 10;
let tmpCounter = 0;
/**
* Names this service writes into .backups/, and the only ones it restores from.
*
* `<slug>.md.<timestamp>[.deleted|.pre-restore].bak`. `[\w-]` is exactly the
* isValidSlug alphabet and matches neither `/` nor `.`, so a traversal cannot
* match. Group 1 is the slug, group 2 the snapshot kind.
*/
const JOURNAL_BACKUP_NAME = /^([\w-]+)\.md\.[\w-]+\.(?:(deleted|pre-restore)\.)?bak$/;
export interface JournalBackup {
filename: string;
slug: string;
/** `save` rotates; `deleted` and `pre-restore` snapshots are never pruned. */
kind: 'save' | 'deleted' | 'pre-restore';
}
/** Copy an entry into .backups/. Throws when the copy fails. */
async function snapshotEntry(
filePath: string,
filename: string,
kind?: 'deleted' | 'pre-restore',
): Promise<void> {
await fs.mkdir(BACKUP_DIR, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const suffix = kind ? `.${kind}` : '';
// `filename` is always `<slug>.md` with a validated slug, but the validation
// lives in callers and another module; containedPath is the barrier the
// taint analysis can see, as for every other path in this file.
const backupPath = containedPath(BACKUP_DIR, `${filename}.${timestamp}${suffix}.bak`);
if (!backupPath) {
throw new Error(`Refusing to write a backup outside ${BACKUP_DIR}: "${filename}"`);
}
await fs.copyFile(filePath, backupPath);
}
/**
* Keep the newest MAX_BACKUPS save backups of one entry.
*
* Deleted and pre-restore snapshots are left alone, as yaml-service does with
* its pre-restore files: ten saves of a re-created entry must not push out the
* only copy of the one that was deleted.
*/
async function pruneEntryBackups(filename: string): Promise<void> {
const backups = (await fs.readdir(BACKUP_DIR))
.filter((f) => {
const match = JOURNAL_BACKUP_NAME.exec(f);
return match !== null && `${match[1]}.md` === filename && match[2] === undefined;
})
.sort();
for (const old of backups.slice(0, Math.max(0, backups.length - MAX_BACKUPS))) {
await fs.unlink(path.join(BACKUP_DIR, old)).catch(() => {});
}
}
/** Every journal backup on disk, newest filename first per entry. */
export async function listJournalBackups(): Promise<JournalBackup[]> {
let files: string[];
try {
files = await fs.readdir(BACKUP_DIR);
} catch {
return [];
}
return files
.map((filename) => {
const match = JOURNAL_BACKUP_NAME.exec(filename);
if (!match) return null;
const kind = (match[2] ?? 'save') as JournalBackup['kind'];
return { filename, slug: match[1], kind };
})
.filter((b): b is JournalBackup => b !== null)
.sort((a, b) => b.filename.localeCompare(a.filename));
}
/**
* Restore an entry from one of its backups, returning the restored slug.
*
* The entry's current state, if it has one, is snapshotted first. The restore
* always lands in content/journal/, which resolveJournalFilePath checks before
* the legacy essays/ directory.
*/
export async function restoreJournalBackup(backupFilename: string): Promise<string> {
const match = JOURNAL_BACKUP_NAME.exec(backupFilename);
if (!match || path.basename(backupFilename) !== backupFilename || !isValidSlug(match[1])) {
throw new Error(`Refusing to restore from an unrecognised backup name: "${backupFilename}"`);
}
const slug = match[1];
const filename = `${slug}.md`;
const source = containedPath(BACKUP_DIR, backupFilename);
const target = containedPath(JOURNAL_DIR, filename);
if (!source || !target) {
throw new Error(`Refusing to restore from an unrecognised backup name: "${backupFilename}"`);
}
// Read before touching anything, so a missing backup changes nothing.
const content = await fs.readFile(source, 'utf8');
try {
await fs.access(target);
await snapshotEntry(target, filename, 'pre-restore');
} catch (err) {
// No current file is fine — that is the deleted-entry case. A failed
// snapshot of an existing file is not.
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
await fs.mkdir(JOURNAL_DIR, { recursive: true });
const tmpPath = `${target}.${process.pid}.${++tmpCounter}.tmp`;
try {
await fs.writeFile(tmpPath, content, 'utf8');
await fs.rename(tmpPath, target);
} catch (err) {
await fs.unlink(tmpPath).catch(() => {});
throw err;
}
console.log(`[Journal] 🔄 Restored ${filename} from ${backupFilename}`);
return slug;
}
/**
* Join a filename onto a content directory, or return null if the result would
* escape it.
@@ -163,22 +283,8 @@ export async function writeJournalEntry(slug: string, rawMarkdown: string): Prom
// Create rolling backup if file already exists
try {
await fs.access(filePath);
const backupDir = path.join(JOURNAL_DIR, '.backups');
await fs.mkdir(backupDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupName = `${filename}.${timestamp}.bak`;
await fs.copyFile(filePath, path.join(backupDir, backupName));
// Prune backups
const backups = (await fs.readdir(backupDir))
.filter((f) => f.startsWith(`${filename}.`) && f.endsWith('.bak'))
.sort();
if (backups.length > MAX_BACKUPS) {
for (const old of backups.slice(0, backups.length - MAX_BACKUPS)) {
await fs.unlink(path.join(backupDir, old)).catch(() => {});
}
}
await snapshotEntry(filePath, filename);
await pruneEntryBackups(filename);
} catch {
// New file, no backup needed
}
@@ -196,7 +302,15 @@ export async function writeJournalEntry(slug: string, rawMarkdown: string): Prom
console.log(`[Journal] ✅ Saved ${filename}`);
}
/** Safely delete a journal entry */
/**
* Delete a journal entry, keeping a `.deleted.bak` copy the Backup Manager can
* restore.
*
* Saving always rotated a backup; deleting did not, so the one irreversible
* action in the journal was the one without a way back. The snapshot is taken
* first and is not optional: if it cannot be written, the delete is refused
* rather than carried out unprotected.
*/
export async function deleteJournalEntry(slug: string): Promise<boolean> {
if (!isValidSlug(slug)) {
throw new Error(`Invalid journal slug: "${slug}"`);
@@ -205,6 +319,13 @@ export async function deleteJournalEntry(slug: string): Promise<boolean> {
const filePath = resolveJournalFilePath(slug);
if (!filePath) return false;
try {
await fs.access(filePath);
} catch {
return false;
}
await snapshotEntry(filePath, `${slug}.md`, 'deleted');
try {
await fs.unlink(filePath);
console.log(`[Journal] 🗑️ Deleted ${slug}.md`);

View File

@@ -128,7 +128,7 @@ export async function listBackups(filename: string): Promise<string[]> {
* readable file over content/settings.yaml — which the admin GET endpoints then
* hand straight back.
*/
const BACKUP_FILENAME = /^(gallery|settings)\.yaml\.[\w-]+\.(pre-restore\.)?bak$/;
const BACKUP_FILENAME = /^(gallery\.yaml|settings\.yaml|about\.md)\.[\w-]+\.(pre-restore\.)?bak$/;
/** Restore a specific backup. */
export async function restoreBackup(backupFilename: string): Promise<void> {
@@ -144,7 +144,8 @@ export async function restoreBackup(backupFilename: string): Promise<void> {
// Derived from the matched group, never from a substring search on the input:
// `includes('gallery.yaml')` would let the caller pick the destination.
const originalFilename = `${match[1]}.yaml`;
// about.md shares this directory: the about route writes its backups here.
const originalFilename = match[1];
const targetPath = path.join(CONTENT_DIR, originalFilename);
// Create a backup of current state first

View File

@@ -51,6 +51,13 @@ export interface ImmichExifInfo {
iso: number | null;
exifImageWidth: number | null;
exifImageHeight: number | null;
/**
* EXIF orientation flag, as a numeric string ('1'..'8').
*
* Optional: Immich only fills it when the file carries the tag, and older
* responses omit the field entirely.
*/
orientation?: string | null;
latitude: number | null;
longitude: number | null;
city: string | null;

251
lib/metadataStrip.ts Normal file
View File

@@ -0,0 +1,251 @@
/**
* Removes camera metadata (EXIF, XMP, IPTC) from image bytes on the way out.
*
* Why this exists: `/api/image/:token?size=original` streams the untouched file
* from Immich, and the asset token authorises *the asset, not the size* — so a
* thumbnail token lifted from the public HTML plus `?size=original` returns the
* full original, GPS coordinates and all. `app/api/exif/[id]/route.ts`
* deliberately hands the browser only city and country; the original download
* walked straight past that.
*
* Why zero the bytes instead of cutting them out: the pixel data must stay
* bit-identical — re-encoding would cost quality, which is the whole reason we
* serve originals. Overwriting in place keeps every other byte and the total
* length untouched, so Content-Length stays valid and, crucially for AVIF, the
* absolute offsets in the `iloc` box keep pointing where they did. Removing an
* item would mean rewriting every offset in the container.
*
* A zeroed APPn segment is simply an application segment a decoder does not
* recognise, and decoders skip those. A zeroed ISOBMFF item payload is still
* addressed by `iloc`, but no longer parses as EXIF.
*
* NOT stripped: JPEG APP0 (JFIF) and APP2 (ICC colour profile). Dropping the
* ICC profile would visibly shift colours — that is a rendering change, not a
* privacy one.
*/
/** Half-open byte range [start, end). */
export type ByteRange = { start: number; end: number };
/** Formats whose metadata layout we can locate with confidence. */
export const STRIPPABLE_CONTENT_TYPES = [
'image/jpeg',
'image/avif',
'image/heic',
'image/heif',
] as const;
export function isStrippable(contentType: string): boolean {
const t = contentType.toLowerCase().split(';')[0].trim();
return (STRIPPABLE_CONTENT_TYPES as readonly string[]).includes(t);
}
const u16 = (b: Uint8Array, i: number) => (b[i] << 8) | b[i + 1];
const u32 = (b: Uint8Array, i: number) =>
((b[i] << 24) | (b[i + 1] << 16) | (b[i + 2] << 8) | b[i + 3]) >>> 0;
const fourcc = (b: Uint8Array, i: number) =>
String.fromCharCode(b[i], b[i + 1], b[i + 2], b[i + 3]);
/** Read a big-endian unsigned integer of `n` bytes (n <= 8). */
function uint(b: Uint8Array, i: number, n: number): number {
let v = 0;
for (let k = 0; k < n; k++) v = v * 256 + b[i + k];
return v;
}
// ── JPEG ────────────────────────────────────────────────────────────────────
// Segment layout: 0xFF <marker> <2-byte length incl. itself> <payload>.
// Metadata always precedes SOS (0xDA); everything after it is entropy-coded
// image data that must not be touched.
const JPEG_STRIP_MARKERS = new Set([
0xe1, // APP1 — EXIF and XMP
0xed, // APP13 — Photoshop/IPTC
0xfe, // COM — free-text comment
]);
export function findJpegMetadataRanges(b: Uint8Array): ByteRange[] {
if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8) {
throw new Error('not a JPEG (missing SOI)');
}
const ranges: ByteRange[] = [];
let pos = 2;
while (pos + 3 < b.length) {
if (b[pos] !== 0xff) throw new Error(`lost segment alignment at ${pos}`);
const marker = b[pos + 1];
// Standalone markers carry no length field.
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
pos += 2;
continue;
}
if (marker === 0xd9) break; // EOI
if (marker === 0xda) break; // SOS — image data follows
const len = u16(b, pos + 2);
if (len < 2) throw new Error(`bogus segment length ${len} at ${pos}`);
const payloadStart = pos + 4;
const segmentEnd = pos + 2 + len;
if (segmentEnd > b.length) break; // truncated head — stop where we are
if (JPEG_STRIP_MARKERS.has(marker) && segmentEnd > payloadStart) {
ranges.push({ start: payloadStart, end: segmentEnd });
}
pos = segmentEnd;
}
return ranges;
}
// ── ISOBMFF (AVIF / HEIC / HEIF) ────────────────────────────────────────────
// Boxes are <4-byte size><4-byte type>[<8-byte largesize>]<payload>. Metadata
// items live in `meta`: `iinf`/`infe` name them, `iloc` says where their bytes
// are. We zero exactly those byte ranges.
type Box = { type: string; start: number; contentStart: number; end: number };
function readBoxes(b: Uint8Array, from: number, to: number): Box[] {
const boxes: Box[] = [];
let pos = from;
while (pos + 8 <= to) {
let size = u32(b, pos);
let contentStart = pos + 8;
if (size === 1) {
if (pos + 16 > to) break;
size = uint(b, pos + 8, 8);
contentStart = pos + 16;
} else if (size === 0) {
size = to - pos; // extends to the end
}
if (size < 8) break;
const end = Math.min(pos + size, to);
boxes.push({ type: fourcc(b, pos + 4), start: pos, contentStart, end });
if (end <= pos) break;
pos = end;
}
return boxes;
}
/** item_ID -> item_type, from the `iinf` box. */
function readItemTypes(b: Uint8Array, iinf: Box): Map<number, string> {
const types = new Map<number, string>();
const version = b[iinf.contentStart];
let pos = iinf.contentStart + 4; // version + flags
if (version === 0) pos += 2;
else pos += 4; // entry_count
for (const infe of readBoxes(b, pos, iinf.end)) {
if (infe.type !== 'infe') continue;
const v = b[infe.contentStart];
let p = infe.contentStart + 4;
if (v < 2) continue; // versions 0/1 predate item_type
const itemId = v === 2 ? u16(b, p) : u32(b, p);
p += v === 2 ? 2 : 4;
p += 2; // item_protection_index
types.set(itemId, fourcc(b, p));
}
return types;
}
/** item_ID -> byte ranges, from the `iloc` box. */
function readItemLocations(b: Uint8Array, iloc: Box): Map<number, ByteRange[]> {
const out = new Map<number, ByteRange[]>();
const version = b[iloc.contentStart];
let p = iloc.contentStart + 4; // version + flags
const offsetSize = b[p] >> 4;
const lengthSize = b[p] & 0x0f;
const baseOffsetSize = b[p + 1] >> 4;
const indexSize = version === 1 || version === 2 ? b[p + 1] & 0x0f : 0;
p += 2;
const itemCount = version < 2 ? u16(b, p) : u32(b, p);
p += version < 2 ? 2 : 4;
for (let i = 0; i < itemCount && p < iloc.end; i++) {
const itemId = version < 2 ? u16(b, p) : u32(b, p);
p += version < 2 ? 2 : 4;
if (version === 1 || version === 2) p += 2; // construction_method
p += 2; // data_reference_index
const baseOffset = baseOffsetSize ? uint(b, p, baseOffsetSize) : 0;
p += baseOffsetSize;
const extentCount = u16(b, p);
p += 2;
const ranges: ByteRange[] = [];
for (let e = 0; e < extentCount; e++) {
if ((version === 1 || version === 2) && indexSize) p += indexSize;
const extentOffset = offsetSize ? uint(b, p, offsetSize) : 0;
p += offsetSize;
const extentLength = lengthSize ? uint(b, p, lengthSize) : 0;
p += lengthSize;
if (extentLength > 0) {
const start = baseOffset + extentOffset;
ranges.push({ start, end: start + extentLength });
}
}
if (ranges.length) out.set(itemId, ranges);
}
return out;
}
export function findIsobmffMetadataRanges(b: Uint8Array): ByteRange[] {
const top = readBoxes(b, 0, b.length);
if (!top.some((x) => x.type === 'ftyp')) throw new Error('not ISOBMFF (no ftyp)');
const meta = top.find((x) => x.type === 'meta');
if (!meta) return []; // no metadata box at all — nothing to strip
// `meta` is a FullBox: skip version + flags before its children.
const children = readBoxes(b, meta.contentStart + 4, meta.end);
const iinf = children.find((x) => x.type === 'iinf');
const iloc = children.find((x) => x.type === 'iloc');
if (!iinf || !iloc) return [];
const types = readItemTypes(b, iinf);
const locations = readItemLocations(b, iloc);
const ranges: ByteRange[] = [];
for (const [itemId, itemType] of types) {
// 'Exif' is EXIF; 'mime' items are XMP in every AVIF/HEIC writer we see.
if (itemType !== 'Exif' && itemType !== 'mime') continue;
for (const r of locations.get(itemId) ?? []) ranges.push(r);
}
return ranges;
}
// ── Entry point ─────────────────────────────────────────────────────────────
/**
* Byte ranges to blank out, or null when the format is not one we understand.
* Callers must treat null as "do not serve this original" — silently passing it
* through is what caused the leak in the first place.
*/
export function findMetadataRanges(b: Uint8Array, contentType: string): ByteRange[] | null {
const t = contentType.toLowerCase().split(';')[0].trim();
try {
if (t === 'image/jpeg') return findJpegMetadataRanges(b);
if (t === 'image/avif' || t === 'image/heic' || t === 'image/heif') {
return findIsobmffMetadataRanges(b);
}
} catch {
return null; // malformed or unexpected layout — refuse rather than leak
}
return null;
}
/** Overwrite `ranges` with zeroes. Mutates and returns `b`. */
export function blankRanges(b: Uint8Array, ranges: ByteRange[]): Uint8Array {
for (const { start, end } of ranges) {
const from = Math.max(0, Math.min(start, b.length));
const to = Math.max(0, Math.min(end, b.length));
if (to > from) b.fill(0, from, to);
}
return b;
}
/** Convenience for buffered callers and tests. */
export function stripMetadata(b: Uint8Array, contentType: string): Uint8Array | null {
const ranges = findMetadataRanges(b, contentType);
if (ranges === null) return null;
return blankRanges(b, ranges);
}

View File

@@ -128,12 +128,30 @@ export function assetCaption(
}
/**
* Compute the natural aspect ratio (width / height) from EXIF dimensions.
* EXIF orientations that rotate the frame a quarter turn.
*
* Most cameras expose the sensor in landscape whatever way the body is held,
* and record the rotation as a flag rather than rewriting the pixels. So a
* portrait frame arrives as 7008x4672 with orientation 6, and the browser turns
* it upright on display. Taking the stored dimensions at face value would call
* that photo landscape.
*
* 1-4 are upright or mirrored in place and leave the ratio alone; 5-8 all carry
* a 90 degree turn, so width and height swap.
*/
const ROTATED_ORIENTATIONS = new Set([5, 6, 7, 8]);
/**
* Compute the natural aspect ratio (width / height) from EXIF dimensions,
* as the image is displayed rather than as it is stored.
* Returns undefined if dimensions are not available.
*/
export function assetAspectRatio(asset: Pick<ImmichAsset, 'exifInfo'>): number | undefined {
const w = asset.exifInfo?.exifImageWidth;
const h = asset.exifInfo?.exifImageHeight;
if (w && h && h > 0) return w / h;
return undefined;
if (!w || !h || h <= 0) return undefined;
// Immich reports the flag as a string; anything unparseable is left upright.
const orientation = Number(asset.exifInfo?.orientation);
return ROTATED_ORIENTATIONS.has(orientation) ? h / w : w / h;
}

2144
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "immich-folio",
"version": "0.14.0",
"version": "0.15.0",
"license": "MIT",
"scripts": {
"dev": "next dev",
@@ -14,7 +14,8 @@
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:admin": "playwright test e2e/admin-smoke.spec.ts",
"doctor": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/doctor.mjs"
"doctor": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/doctor.mjs",
"audit:contrast": "node scripts/contrast-audit.mjs"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -22,21 +23,22 @@
"@dnd-kit/utilities": "^3.2.2",
"js-yaml": "^4.3.1",
"leaflet": "^1.9.4",
"next": "16.3.1",
"react": "19.2.8",
"react-dom": "19.2.8",
"next": "16.3.5",
"react": "19.3.0",
"react-dom": "19.3.0",
"thumbhash": "^0.1.1"
},
"devDependencies": {
"@playwright/test": "^1.62.1",
"@playwright/test": "^1.63.0",
"@types/js-yaml": "^4.0.9",
"@types/leaflet": "^1.9.22",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitest/coverage-v8": "^4.0.18",
"axe-core": "^4.13.0",
"eslint": "^9",
"eslint-config-next": "16.3.1",
"eslint-config-next": "16.3.5",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6",
"prettier": "^3.9.6",

675
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

140
scripts/contrast-audit.mjs Normal file
View File

@@ -0,0 +1,140 @@
/**
* Contrast audit — every preset, light and dark, in a real browser.
*
* Opens a running instance, switches `data-preset` and `data-theme` on <html>
* (all preset stylesheets are loaded globally, so no config change is needed)
* and runs axe-core's `color-contrast` rule on each page. Reading the CSS is
* not enough: an inline style or an `opacity` on top of a tuned token only
* shows up in the computed colours.
*
* Pages are discovered from the running site: the start page, every header
* nav link, the first album found on a subpage, and /about, /map, /impressum
* when they answer 200.
*
* Usage:
* npm run audit:contrast # http://localhost:3000
* npm run audit:contrast -- http://localhost:7211 # any running instance
* npm run audit:contrast -- --light # one theme only
*
* Analytics requests are blocked, so an audit does not count as visits.
* Exits 1 when any failure is found.
*/
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { chromium } from '@playwright/test';
const require = createRequire(import.meta.url);
const axeSource = readFileSync(require.resolve('axe-core/axe.min.js'), 'utf8');
// Keep in sync with the presets in lib/config/theme.ts.
const PRESETS = ['studio-modern', 'studio', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
const FIXED_PAGES = ['/about', '/map', '/impressum'];
const print = (line = '') => process.stdout.write(line + '\n');
const args = process.argv.slice(2);
const base = (args.find((a) => !a.startsWith('--')) ?? 'http://localhost:3000').replace(/\/$/, '');
const themes = args.includes('--light')
? ['light']
: args.includes('--dark')
? ['dark']
: ['light', 'dark'];
async function discoverPages(page) {
await page.goto(base + '/', { waitUntil: 'networkidle' });
const nav = await page.$$eval('a.header__nav-link', (as) =>
as.map((a) => a.getAttribute('href')),
);
const pages = new Set(['/', ...nav.filter((h) => h && h.startsWith('/'))]);
// One album is enough: album pages share their markup.
for (const href of nav) {
if (!href?.startsWith('/')) continue;
await page.goto(base + href, { waitUntil: 'networkidle' });
const album = await page
.$eval('a.subpage-grid__item', (a) => a.getAttribute('href'))
.catch(() => null);
if (album) {
pages.add(album);
break;
}
}
for (const path of FIXED_PAGES) {
const res = await page.goto(base + path).catch(() => null);
if (res?.status() === 200) pages.add(path);
}
return [...pages];
}
async function auditPage(page, path) {
const res = await page.goto(base + path, { waitUntil: 'networkidle' });
if (!res || res.status() >= 400)
return [{ path, error: res ? res.status() : 'navigation failed' }];
// Load the lazy covers, so axe sees images behind text where they exist.
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForTimeout(800);
await page.evaluate(() => window.scrollTo(0, 0));
await page.addScriptTag({ content: axeSource });
const found = [];
for (const theme of themes) {
for (const preset of PRESETS) {
await page.evaluate(
([p, t]) => {
document.documentElement.setAttribute('data-preset', p);
document.documentElement.setAttribute('data-theme', t);
},
[preset, theme],
);
await page.waitForTimeout(400); // let the theme transition settle
const nodes = await page.evaluate(async () => {
const result = await window.axe.run(document, {
runOnly: { type: 'rule', values: ['color-contrast'] },
resultTypes: ['violations'],
});
return result.violations.flatMap((v) =>
v.nodes.map((n) => ({
target: n.target.join(' '),
text: (document.querySelector(n.target[0])?.textContent ?? '').trim().slice(0, 40),
ratio: n.any[0]?.data?.contrastRatio,
fg: n.any[0]?.data?.fgColor,
bg: n.any[0]?.data?.bgColor,
})),
);
});
for (const n of nodes) found.push({ path, theme, preset, ...n });
}
}
return found;
}
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
await page.route('**/api/analytics/**', (route) => route.abort());
const pages = await discoverPages(page);
print(
`Auditing ${pages.length} pages × ${PRESETS.length} presets × ${themes.join('+')} on ${base}\n`,
);
const failures = [];
for (const path of pages) failures.push(...(await auditPage(page, path)));
await browser.close();
for (const f of failures) {
if (f.error) {
print(`ERROR ${f.path}: ${f.error}`);
continue;
}
print(
`${f.theme.padEnd(6)} ${f.preset.padEnd(14)} ${String(f.ratio).padEnd(5)} ${f.fg} on ${f.bg} ${f.path} ${f.target} "${f.text}"`,
);
}
const summary = themes
.map((t) => `${t}: ${failures.filter((f) => f.theme === t).length}`)
.join(', ');
print(`\n${failures.length} contrast failure(s) — ${summary}`);
process.exit(failures.length ? 1 : 0);