From c3f92ca4ecf381150ec7f8e819b9eb7c5430cd9c Mon Sep 17 00:00:00 2001
From: Ralksta <16941050+ralksta@users.noreply.github.com>
Date: Sat, 19 Sep 2026 22:24:10 +0200
Subject: [PATCH] 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).
---
.github/workflows/ci.yml | 2 +-
CHANGELOG.md | 173 ++
README.md | 8 +-
app/[...path]/EssayView.tsx | 19 +-
app/[...path]/PhotoGrid.tsx | 8 +-
app/[...path]/SubpageGridView.tsx | 10 +-
app/[...path]/essay.css | 14 +-
app/admin/__tests__/findAlbumAddress.test.ts | 46 +
app/admin/admin.css | 1 +
app/admin/components/AdminDashboard.tsx | 30 +-
app/admin/components/AlbumPicker.tsx | 14 +-
app/admin/components/AssetPicker.tsx | 13 +-
app/admin/components/BackupManagerModal.tsx | 116 +-
app/admin/components/DiagnosticsView.tsx | 574 +++++
app/admin/components/DoctorModal.tsx | 131 -
app/admin/components/EssayBlockEditor.tsx | 1 +
app/admin/components/HelpView.tsx | 13 +-
app/admin/components/JournalStudio.tsx | 9 +-
app/admin/components/PageBuilder.tsx | 72 +-
app/admin/components/SettingsEditor.tsx | 91 +-
app/admin/components/journal-studio.css | 25 +-
.../components/page-builder/AlbumCard.tsx | 11 +-
.../components/page-builder/AlbumDrawer.tsx | 1 +
.../components/page-builder/SubpageDrawer.tsx | 11 +-
.../page-builder/SubpagePreview.tsx | 258 +-
.../page-builder/findAlbumAddress.ts | 30 +
app/admin/components/systemHealth.ts | 7 +
app/admin/diagnostics/page.tsx | 5 +
app/admin/settings/[section]/page.tsx | 15 +-
app/admin/settings/layout.tsx | 19 +
app/admin/settings/page.tsx | 5 +-
app/admin/styles/album-picker.css | 5 +-
app/admin/styles/asset-picker.css | 5 +-
app/admin/styles/base.css | 12 +-
app/admin/styles/diagnostics.css | 339 +++
app/admin/styles/drawer-live-preview.css | 97 +-
app/admin/styles/drawers.css | 12 +
app/admin/styles/essay-builder.css | 13 +-
app/admin/styles/help.css | 9 +-
app/admin/styles/page-builder.css | 22 +-
app/admin/styles/settings-editor.css | 66 +-
app/admin/styles/tokens.css | 15 +-
app/api/admin/__tests__/admin-guards.test.ts | 8 +
app/api/admin/alt-text/route.ts | 83 +
app/api/admin/backups/route.ts | 44 +-
app/api/image/[id]/route.ts | 77 +-
app/globals.css | 36 +-
app/impressum/impressum.css | 8 +-
app/journal/journal.css | 13 +-
app/themes/editorial.css | 1 -
app/tokens.css | 39 +-
components/PasswordGate.module.css | 9 +-
components/ProofingModal.tsx | 33 +-
docs/admin-panel.md | 18 +-
docs/deployment.md | 8 +-
hooks/useModalDialog.ts | 115 +
lib/__tests__/alt-text.test.ts | 135 ++
lib/__tests__/backup-restore.test.ts | 9 +
lib/__tests__/doctor.test.ts | 2 +
lib/__tests__/essay.test.ts | 41 +
lib/__tests__/journal-backup.test.ts | 144 ++
lib/__tests__/metadataStrip.test.ts | 177 ++
lib/__tests__/urls.test.ts | 52 +
lib/admin/alt-text.ts | 134 ++
lib/admin/doctor.ts | 7 +
lib/admin/journal-service.ts | 155 +-
lib/admin/yaml-service.ts | 5 +-
lib/immich.ts | 7 +
lib/metadataStrip.ts | 251 ++
lib/urls.ts | 24 +-
package-lock.json | 2144 ++++++++---------
package.json | 16 +-
pnpm-lock.yaml | 675 +++---
scripts/contrast-audit.mjs | 140 ++
74 files changed, 4791 insertions(+), 2146 deletions(-)
create mode 100644 app/admin/__tests__/findAlbumAddress.test.ts
create mode 100644 app/admin/components/DiagnosticsView.tsx
delete mode 100644 app/admin/components/DoctorModal.tsx
create mode 100644 app/admin/components/page-builder/findAlbumAddress.ts
create mode 100644 app/admin/diagnostics/page.tsx
create mode 100644 app/admin/settings/layout.tsx
create mode 100644 app/admin/styles/diagnostics.css
create mode 100644 app/api/admin/alt-text/route.ts
create mode 100644 hooks/useModalDialog.ts
create mode 100644 lib/__tests__/alt-text.test.ts
create mode 100644 lib/__tests__/journal-backup.test.ts
create mode 100644 lib/__tests__/metadataStrip.test.ts
create mode 100644 lib/admin/alt-text.ts
create mode 100644 lib/metadataStrip.ts
create mode 100644 scripts/contrast-audit.mjs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 94750a6..c28339c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d9444ad..d47777c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index ba804b1..4c49ab9 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/app/[...path]/EssayView.tsx b/app/[...path]/EssayView.tsx
index 642f126..c527d92 100644
--- a/app/[...path]/EssayView.tsx
+++ b/app/[...path]/EssayView.tsx
@@ -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 (
- {block.author && — {block.author} }
+ {block.author && (
+
+ )}
);
@@ -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',
diff --git a/app/[...path]/PhotoGrid.tsx b/app/[...path]/PhotoGrid.tsx
index 69613d6..3aa5023 100644
--- a/app/[...path]/PhotoGrid.tsx
+++ b/app/[...path]/PhotoGrid.tsx
@@ -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',
diff --git a/app/[...path]/SubpageGridView.tsx b/app/[...path]/SubpageGridView.tsx
index 102b49f..eea504e 100644
--- a/app/[...path]/SubpageGridView.tsx
+++ b/app/[...path]/SubpageGridView.tsx
@@ -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),
)}
>
-
+ {/* 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. */}
+
{album.albumThumbnailAssetId ? (
({
+ 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();
+ });
+});
diff --git a/app/admin/admin.css b/app/admin/admin.css
index 81e3c10..03f9507 100644
--- a/app/admin/admin.css
+++ b/app/admin/admin.css
@@ -31,3 +31,4 @@
@import './styles/essay-builder.css';
@import './styles/asset-order.css';
@import './styles/favicon.css';
+@import './styles/diagnostics.css';
diff --git a/app/admin/components/AdminDashboard.tsx b/app/admin/components/AdminDashboard.tsx
index 0a386f9..d2e4400 100644
--- a/app/admin/components/AdminDashboard.tsx
+++ b/app/admin/components/AdminDashboard.tsx
@@ -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(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).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) {
-
{
- setShowStatus(false);
- setShowDoctor(true);
- }}
+ onClick={() => setShowStatus(false)}
>
Diagnostics
-
+
{
@@ -300,14 +304,6 @@ export default function AdminDashboard({ onLogout, children }: Props) {
{children}
- {
- setShowDoctor(false);
- fetchDoctor();
- }}
- />
-
setShowBackupModal(false)}
diff --git a/app/admin/components/AlbumPicker.tsx b/app/admin/components/AlbumPicker.tsx
index 52d0c14..ebf0f31 100644
--- a/app/admin/components/AlbumPicker.tsx
+++ b/app/admin/components/AlbumPicker.tsx
@@ -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 (
-
e.stopPropagation()}>
+
e.stopPropagation()}
+ >
-
Select Album
+
Select Album
×
diff --git a/app/admin/components/AssetPicker.tsx b/app/admin/components/AssetPicker.tsx
index a5004c6..f152328 100644
--- a/app/admin/components/AssetPicker.tsx
+++ b/app/admin/components/AssetPicker.tsx
@@ -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
(albumId ? 'album' : 'favorites');
const [assets, setAssets] = useState([]);
const [loading, setLoading] = useState(true);
@@ -114,9 +116,16 @@ export default function AssetPicker({
return (
-
e.stopPropagation()}>
+
e.stopPropagation()}
+ >
-
{title || 'Select Hero Image'}
+
{title || 'Select Hero Image'}
×
diff --git a/app/admin/components/BackupManagerModal.tsx b/app/admin/components/BackupManagerModal.tsx
index 8a4ab07..50b4d24 100644
--- a/app/admin/components/BackupManagerModal.tsx
+++ b/app/admin/components/BackupManagerModal.tsx
@@ -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
;
+
+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('gallery');
const [showAllBackups, setShowAllBackups] = useState(false);
- const [backups, setBackups] = useState<{ gallery: BackupItem[]; settings: BackupItem[] }>({
- gallery: [],
- settings: [],
- });
+ const [backups, setBackups] = useState(EMPTY_LISTS);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [restoringFilename, setRestoringFilename] = useState(null);
- const [confirmFilename, setConfirmFilename] = useState(null);
+ const [confirmItem, setConfirmItem] = useState(null);
const [successMsg, setSuccessMsg] = useState(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 (
-
e.stopPropagation()}>
+
e.stopPropagation()}
+ >
-
Backup History & Restoration
+
Backup History & Restoration
- 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.
@@ -105,26 +129,19 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
{/* Tab Selection */}
- {
- setActiveTab('gallery');
- setConfirmFilename(null);
- setShowAllBackups(false);
- }}
- >
- Gallery Backups ({backups.gallery.length})
-
- {
- setActiveTab('settings');
- setConfirmFilename(null);
- setShowAllBackups(false);
- }}
- >
- Settings Backups ({backups.settings.length})
-
+ {TABS.map((tab) => (
+ {
+ setActiveTab(tab.target);
+ setConfirmItem(null);
+ setShowAllBackups(false);
+ }}
+ >
+ {tab.label} ({backups[tab.target].length})
+
+ ))}
{/* Status Messages */}
@@ -132,25 +149,25 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
{successMsg && {successMsg}
}
{/* Confirmation Modal Section */}
- {confirmFilename && (
+ {confirmItem && (
Confirm Restoration
- Are you sure you want to restore {confirmFilename}? A safety snapshot
- of your current state will be created automatically before reverting.
+ Are you sure you want to restore {confirmItem.filename}? A safety
+ snapshot of your current state will be created automatically before reverting.
setConfirmFilename(null)}
+ onClick={() => setConfirmItem(null)}
disabled={!!restoringFilename}
>
Cancel
handleRestore(confirmFilename)}
+ onClick={() => handleRestore(confirmItem)}
disabled={!!restoringFilename}
>
{restoringFilename ? 'Restoring...' : 'Yes, Restore Now'}
@@ -165,7 +182,9 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
{loading ? (
Loading backups...
) : currentList.length === 0 ? (
- No backups available yet for {activeTab}.yaml
+
+ No backups available yet for {TABS.find((t) => t.target === activeTab)?.file}
+
) : (
{visibleList.map((item) => {
@@ -182,7 +201,16 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
+ {item.slug &&
{item.slug} }
{formattedDate}
+ {item.isDeleted && (
+
+ Deleted entry
+
+ )}
{item.isPreRestore && (
setConfirmFilename(item.filename)}
- disabled={!!restoringFilename || confirmFilename === item.filename}
+ onClick={() => setConfirmItem(item)}
+ disabled={!!restoringFilename || confirmItem?.filename === item.filename}
>
Restore
diff --git a/app/admin/components/DiagnosticsView.tsx b/app/admin/components/DiagnosticsView.tsx
new file mode 100644
index 0000000..bf9d471
--- /dev/null
+++ b/app/admin/components/DiagnosticsView.tsx
@@ -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 = { ok: 'OK', warn: 'Check', error: 'Problem' };
+const LEVEL_RANK: Record = { 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 = {
+ '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(null);
+ const [findings, setFindings] = useState(null);
+ const [altText, setAltText] = useState(null);
+ const [altTextFailed, setAltTextFailed] = useState(false);
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [ranAt, setRanAt] = useState(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>(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) => (
+
+
+
+
+
+
+ {report.described} of {report.total} described ·{' '}
+ {Math.round((report.described / report.total) * 100)}%
+
+
+
+ {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 (
+
+
+
+ {album.albumName}
+ {album.path}
+
+
+ {album.missing.length} of {album.total} missing
+
+
+
+
+ );
+ })}
+
+
+ Add the description in Immich, then press Reload in the top bar.{' '}
+ {report.immichUrlSource === 'external-domain' ? (
+ <>
+ Photos open at {report.immichUrl}, Immich’s external domain.
+ >
+ ) : (
+ <>
+ Photos open at {report.immichUrl} — the address Folio reaches Immich at,
+ which your browser may not be able to open. Set External domain in
+ Immich (Administration → Settings → Server) and the links use that instead.
+ >
+ )}
+
+
+ );
+
+ /**
+ * 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 (
+
+ Open in Pages
+
+ );
+ }
+ if (id === 'alt-text') {
+ if (altText && !altText.captionsEnabled) {
+ return (
+
+ Open settings
+
+ );
+ }
+ return null; // The photo list toggle is rendered separately.
+ }
+ const fix = FIXES[id];
+ if (!fix) return null;
+ return fix.href.startsWith('/') ? (
+
+ {fix.label}
+
+ ) : (
+
+ {fix.label} ↗
+
+ );
+ };
+
+ const renderFinding = (f: DoctorFinding) => {
+ const isAltText = f.id === 'alt-text' && altText && altText.captionsEnabled;
+ const hasList = isAltText && altText.albums.length > 0;
+ return (
+
+
+
+
+
+ {f.level !== 'ok' && {LEVEL_LABEL[f.level]}: }
+ {f.title}
+
+ {f.detail}
+
+
+ {f.level !== 'ok' && renderFix(f)}
+ {hasList && (
+ setAltTextOpen(!altTextOpen)}
+ >
+ {altTextOpen ? 'Hide photos' : 'Show photos'}
+
+ )}
+
+
+ {hasList && altTextOpen && renderAltTextList(altText)}
+
+ );
+ };
+
+ 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 (
+
+
+
+
{headline}
+
+ {ranAt ? `Last run ${ranAt.toLocaleString()}` : 'Running the checks…'}
+
+
+
+
+ {loading ? 'Checking…' : 'Run again'}
+
+
+ {copied ? 'Copied' : 'Copy report'}
+
+
+
+
+ {error &&
{error}
}
+
+
+
+ Immich
+
+
+ {status ? (immichOk ? 'Connected' : 'Disconnected') : '—'}
+
+
+
+ Config
+
+
+ {configState === 'valid'
+ ? 'Valid'
+ : configState === 'invalid'
+ ? 'Invalid'
+ : configState === 'setup'
+ ? 'Not set up'
+ : '—'}
+
+
+
+ Backups
+ {status?.backups?.count ?? '—'}
+
+ {status?.backups?.lastBackup
+ ? `latest ${new Date(status.backups.lastBackup).toLocaleDateString()}`
+ : 'none yet'}
+
+
+
+
Version
+
{status?.update?.current ?? '—'}
+
+ {status?.update?.updateAvailable ? (
+
+ {status.update.latest} available
+
+ ) : status?.update?.current ? (
+ 'up to date'
+ ) : (
+ ''
+ )}
+
+
+
+
+ {loading && !findings ? (
+
+ ) : (
+ <>
+ {attention.length > 0 && (
+
+
+ Needs attention
+
+ {attention.map(renderFinding)}
+
+ )}
+
+ {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 (
+
+
+ {group.title}
+
+
+ {ok.map(renderFinding)}
+ {pendingAltText && (
+
+
+
+
+ Checking alt text…
+
+ Reading every published album; large libraries take a moment.
+
+
+
+
+ )}
+ {failedAltText && (
+
+
+
+
+ Alt text could not be checked
+
+ The albums did not load. If Immich is disconnected, that is the reason —
+ try again once it is back.
+
+
+
+
+ )}
+
+
+ );
+ })}
+
+
+
+ Storage & cache
+
+
+
+
+
+
+
+ {status?.backups?.count ?? 0} config backups
+
+
+ Written before every admin save, ten per file.
+
+
+
+ setShowBackups(true)}>
+ Manage backups
+
+
+
+
+
+
+
+
+
+ In-memory cache holds {status?.cache?.size ?? 0} items
+
+
+ Album lists and photo metadata from Immich. Clearing it makes the next page
+ views slower, nothing else.
+
+
+
+
+ {clearing ? 'Clearing…' : 'Clear cache'}
+
+
+
+
+
+
+ >
+ )}
+
+
setShowBackups(false)}
+ onRestoreSuccess={fetchStatus}
+ />
+
+ );
+}
diff --git a/app/admin/components/DoctorModal.tsx b/app/admin/components/DoctorModal.tsx
deleted file mode 100644
index a5cf13e..0000000
--- a/app/admin/components/DoctorModal.tsx
+++ /dev/null
@@ -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 = {
- 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([]);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(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 (
-
-
e.stopPropagation()}>
-
-
-
Diagnostics
-
- {loading
- ? 'Checking…'
- : problems === 0
- ? 'Nothing needs your attention.'
- : `${problems} of ${findings.length} checks want a look.`}
-
-
-
-
-
-
-
-
- {error &&
{error}
}
-
- {loading && !findings.length ? (
-
- ) : (
-
- {findings.map((f) => (
-
-
-
- {f.title}
- {f.detail}
-
-
- ))}
-
- )}
-
-
-
-
- {loading ? 'Checking…' : 'Run again'}
-
-
- {copied ? 'Copied' : 'Copy report'}
-
-
-
-
- );
-}
diff --git a/app/admin/components/EssayBlockEditor.tsx b/app/admin/components/EssayBlockEditor.tsx
index 669d07d..3f8c3f1 100644
--- a/app/admin/components/EssayBlockEditor.tsx
+++ b/app/admin/components/EssayBlockEditor.tsx
@@ -218,6 +218,7 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
{block.type === 'heading' && (
handleUpdateBlock(idx, { ...block, level: Number(e.target.value) })
diff --git a/app/admin/components/HelpView.tsx b/app/admin/components/HelpView.tsx
index d571935..747d025 100644
--- a/app/admin/components/HelpView.tsx
+++ b/app/admin/components/HelpView.tsx
@@ -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 (
@@ -80,13 +77,11 @@ export default function HelpView() {
- setShowDoctor(true)}>
- Run diagnostics
-
+
+ Open diagnostics
+
-
- setShowDoctor(false)} />
);
}
diff --git a/app/admin/components/JournalStudio.tsx b/app/admin/components/JournalStudio.tsx
index 6d51e54..51b70e6 100644
--- a/app/admin/components/JournalStudio.tsx
+++ b/app/admin/components/JournalStudio.tsx
@@ -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' && (
@@ -918,6 +924,7 @@ function JournalEditor({ slug, onBack }: JournalEditorProps) {
>
diff --git a/app/admin/components/PageBuilder.tsx b/app/admin/components/PageBuilder.tsx
index 64c921e..e8fc018 100644
--- a/app/admin/components/PageBuilder.tsx
+++ b/app/admin/components/PageBuilder.tsx
@@ -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(
null,
);
+ /** Album a `?album=` link pointed at, marked in its subpage sheet for a moment. */
+ const [linkedAlbumId, setLinkedAlbumId] = useState(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(
+ `.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=` 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): 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}
>
-
+
{filteredSubpages.map(({ sp, index }) => (
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}
/>
)}
diff --git a/app/admin/components/SettingsEditor.tsx b/app/admin/components/SettingsEditor.tsx
index 5f806cd..8d7c583 100644
--- a/app/admin/components/SettingsEditor.tsx
+++ b/app/admin/components/SettingsEditor.tsx
@@ -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 (
- {/* The About panel edits about.md, not settings.yaml, so it drives the bar itself. */}
- {activeSection === 'about' ? (
-
- ) : (
-
- )}
+ {/* One bar for both files, on every section: an About edit stays visible
+ from Theme, and a settings edit from About. */}
+
Live Sync (No Docker restart required)
@@ -946,6 +936,7 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
Language
update('lang', e.target.value)}
>
@@ -1209,12 +1200,13 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
Visitor Default Mode
update('mode', e.target.value)}
>
Dark
Light
- Follow the visitor's system
+ Follow the visitor's system
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) {
Position
update('watermark.position', e.target.value)}
>
diff --git a/app/admin/components/journal-studio.css b/app/admin/components/journal-studio.css
index 3909551..d7d5d3a 100644
--- a/app/admin/components/journal-studio.css
+++ b/app/admin/components/journal-studio.css
@@ -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);
}
diff --git a/app/admin/components/page-builder/AlbumCard.tsx b/app/admin/components/page-builder/AlbumCard.tsx
index 1bee525..90b106b 100644
--- a/app/admin/components/page-builder/AlbumCard.tsx
+++ b/app/admin/components/page-builder/AlbumCard.tsx
@@ -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}
/>
);
@@ -58,6 +61,8 @@ interface AlbumCardProps {
onRemove: () => void;
onEdit: () => void;
dragListeners?: Record
;
+ /** 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 (
-
+
{dragListeners && (
diff --git a/app/admin/components/page-builder/AlbumDrawer.tsx b/app/admin/components/page-builder/AlbumDrawer.tsx
index 100501a..494095e 100644
--- a/app/admin/components/page-builder/AlbumDrawer.tsx
+++ b/app/admin/components/page-builder/AlbumDrawer.tsx
@@ -106,6 +106,7 @@ export default function AlbumDrawer({
Layout override (Experimental)
{
const layout = e.target.value || undefined;
diff --git a/app/admin/components/page-builder/SubpageDrawer.tsx b/app/admin/components/page-builder/SubpageDrawer.tsx
index 1e3357a..3dd42ea 100644
--- a/app/admin/components/page-builder/SubpageDrawer.tsx
+++ b/app/admin/components/page-builder/SubpageDrawer.tsx
@@ -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[];
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 (
onClose()}>
@@ -141,7 +147,7 @@ export default function SubpageDrawer({
{drawerMode === 'preview' ? (
-
+
) : (
@@ -300,6 +306,7 @@ export default function SubpageDrawer({
Page Layout Style
{
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({
diff --git a/app/admin/components/page-builder/SubpagePreview.tsx b/app/admin/components/page-builder/SubpagePreview.tsx
index e15991e..5a1c9c3 100644
--- a/app/admin/components/page-builder/SubpagePreview.tsx
+++ b/app/admin/components/page-builder/SubpagePreview.tsx
@@ -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 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(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[]) => (
+
+ {albums.map((album, i) => (
+
+
+ {album.thumb ? (
+ // A plain 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
+
+ ) : (
+
+ )}
+ {t.common.photos(album.assetCount)}
+
+ {album.name}
+ {t.common.photos(album.assetCount)}
+
+
+
+ {pad2(i + 1)}
+ {album.name}
+
+ {t.common.photos(album.assetCount)}
+
+
+
+ ))}
+
+ );
+
return (
-
-
-
{sp.title || sp.name || 'Untitled Page'}
- {sp.subtitle &&
{sp.subtitle}
}
-
+
+
+ Rendered with the site’s saved theme preset at desktop width. Unsaved edits in this
+ drawer show up here; theme changes show up once saved in Settings.
+
+
+ {/* inert: a picture of the page, not a second copy of its links. */}
+
+
+
+
+
+ {index !== undefined && (
+
+ {t.subpage.collectionKicker(pad2(index))}
+
+ )}
+ {title &&
{title} }
+ {sp.subtitle &&
{sp.subtitle}
}
+
+
+ {t.common.albums(allAlbums.length)} · {t.common.photos(totalPhotos)}
+
+
- {sp.sections && sp.sections.length > 0 ? (
- sp.sections.map((sec, sIdx) => (
-
-
{sec.title || 'Untitled Section'}
- {sec.description &&
{sec.description}
}
-
- {sec.albums.map((alb, aIdx) => {
- const immichAlb = immichAlbums.find((a) => a.id === alb.id);
- const thumb = alb.heroImage || immichAlb?.thumbnailAssetId;
-
- return (
-
-
- {thumb ? (
-
- ) : (
-
-
-
- )}
-
-
- {alb.title || immichAlb?.albumName || alb.id}
+ {sections.length > 0 && (
+
+ {sections.map((sec, i) => (
+
+
+ {pad2(i + 1)}
+ {sec.title}
+
-
- );
- })}
+ ))}
+
+ )}
+
+ {sections.length > 0
+ ? sections.map((sec, i) => (
+
+
+ {sec.title}
+ {sec.description && (
+ {sec.description}
+ )}
+
+
+ {grid(sec.albums.map(toPreviewAlbum))}
+
+ ))
+ : grid(allAlbums)}
- ))
- ) : (
-
- {sp.albums.map((alb, aIdx) => {
- const immichAlb = immichAlbums.find((a) => a.id === alb.id);
- const thumb = alb.heroImage || immichAlb?.thumbnailAssetId;
-
- return (
-
-
- {thumb ? (
-
- ) : (
-
-
-
- )}
-
-
- {alb.title || immichAlb?.albumName || alb.id}
-
-
- );
- })}
- )}
+
);
}
diff --git a/app/admin/components/page-builder/findAlbumAddress.ts b/app/admin/components/page-builder/findAlbumAddress.ts
new file mode 100644
index 0000000..b0584da
--- /dev/null
+++ b/app/admin/components/page-builder/findAlbumAddress.ts
@@ -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=
` 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;
+}
diff --git a/app/admin/components/systemHealth.ts b/app/admin/components/systemHealth.ts
index 9dbf70d..1fba8c3 100644
--- a/app/admin/components/systemHealth.ts
+++ b/app/admin/components/systemHealth.ts
@@ -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';
diff --git a/app/admin/diagnostics/page.tsx b/app/admin/diagnostics/page.tsx
new file mode 100644
index 0000000..fd1475f
--- /dev/null
+++ b/app/admin/diagnostics/page.tsx
@@ -0,0 +1,5 @@
+import DiagnosticsView from '../components/DiagnosticsView';
+
+export default function AdminDiagnosticsPage() {
+ return ;
+}
diff --git a/app/admin/settings/[section]/page.tsx b/app/admin/settings/[section]/page.tsx
index 03bf433..e426c47 100644
--- a/app/admin/settings/[section]/page.tsx
+++ b/app/admin/settings/[section]/page.tsx
@@ -1,10 +1,7 @@
-import SettingsEditor from '../../components/SettingsEditor';
-
-export default async function AdminSettingsSectionPage({
- params,
-}: {
- params: Promise<{ section: string }>;
-}) {
- const { section } = await params;
- return ;
+/**
+ * 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;
}
diff --git a/app/admin/settings/layout.tsx b/app/admin/settings/layout.tsx
new file mode 100644
index 0000000..3bb9113
--- /dev/null
+++ b/app/admin/settings/layout.tsx
@@ -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 (
+ <>
+
+ {children}
+ >
+ );
+}
diff --git a/app/admin/settings/page.tsx b/app/admin/settings/page.tsx
index 5431d73..03402bb 100644
--- a/app/admin/settings/page.tsx
+++ b/app/admin/settings/page.tsx
@@ -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 ;
+ return null;
}
diff --git a/app/admin/styles/album-picker.css b/app/admin/styles/album-picker.css
index 367c3d6..97fb64e 100644
--- a/app/admin/styles/album-picker.css
+++ b/app/admin/styles/album-picker.css
@@ -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);
}
diff --git a/app/admin/styles/asset-picker.css b/app/admin/styles/asset-picker.css
index 6477d6a..2c19648 100644
--- a/app/admin/styles/asset-picker.css
+++ b/app/admin/styles/asset-picker.css
@@ -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);
}
diff --git a/app/admin/styles/base.css b/app/admin/styles/base.css
index 71c4174..813418b 100644
--- a/app/admin/styles/base.css
+++ b/app/admin/styles/base.css
@@ -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);
}
diff --git a/app/admin/styles/diagnostics.css b/app/admin/styles/diagnostics.css
new file mode 100644
index 0000000..c9aa82f
--- /dev/null
+++ b/app/admin/styles/diagnostics.css
@@ -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;
+ }
+}
diff --git a/app/admin/styles/drawer-live-preview.css b/app/admin/styles/drawer-live-preview.css
index 43857df..52fe3ae 100644
--- a/app/admin/styles/drawer-live-preview.css
+++ b/app/admin/styles/drawer-live-preview.css
@@ -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 paints it. */
+.subpage-preview__canvas {
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ font-family: var(--font-sans);
+ line-height: 1.6;
}
diff --git a/app/admin/styles/drawers.css b/app/admin/styles/drawers.css
index 9e9ea0c..d739dc0 100644
--- a/app/admin/styles/drawers.css
+++ b/app/admin/styles/drawers.css
@@ -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;
diff --git a/app/admin/styles/essay-builder.css b/app/admin/styles/essay-builder.css
index fb3813f..b02ac38 100644
--- a/app/admin/styles/essay-builder.css
+++ b/app/admin/styles/essay-builder.css
@@ -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;
}
diff --git a/app/admin/styles/help.css b/app/admin/styles/help.css
index 11f6a17..2b30320 100644
--- a/app/admin/styles/help.css
+++ b/app/admin/styles/help.css
@@ -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);
}
diff --git a/app/admin/styles/page-builder.css b/app/admin/styles/page-builder.css
index 0dc0ddb..8e53619 100644
--- a/app/admin/styles/page-builder.css
+++ b/app/admin/styles/page-builder.css
@@ -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=`). */
+.album-tile--linked {
+ outline: 2px solid var(--admin-warning);
+ outline-offset: 3px;
+}
diff --git a/app/admin/styles/settings-editor.css b/app/admin/styles/settings-editor.css
index 9c5d8c5..a3b4adf 100644
--- a/app/admin/styles/settings-editor.css
+++ b/app/admin/styles/settings-editor.css
@@ -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 {
diff --git a/app/admin/styles/tokens.css b/app/admin/styles/tokens.css
index 3a162d1..5479cbb 100644
--- a/app/admin/styles/tokens.css
+++ b/app/admin/styles/tokens.css
@@ -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;
diff --git a/app/api/admin/__tests__/admin-guards.test.ts b/app/api/admin/__tests__/admin-guards.test.ts
index 0dd1043..f497aad 100644
--- a/app/api/admin/__tests__/admin-guards.test.ts
+++ b/app/api/admin/__tests__/admin-guards.test.ts
@@ -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',
diff --git a/app/api/admin/alt-text/route.ts b/app/api/admin/alt-text/route.ts
new file mode 100644
index 0000000..5509385
--- /dev/null
+++ b/app/api/admin/alt-text/route.ts
@@ -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' } },
+ );
+}
diff --git a/app/api/admin/backups/route.ts b/app/api/admin/backups/route.ts
index 2eefba7..a6b8e1f 100644
--- a/app/api/admin/backups/route.ts
+++ b/app/api/admin/backups/route.ts
@@ -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 `.md..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();
diff --git a/app/api/image/[id]/route.ts b/app/api/image/[id]/route.ts
index 5e2c349..276d3c4 100644
--- a/app/api/image/[id]/route.ts
+++ b/app/api/image/[id]/route.ts
@@ -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 = {
'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 });
}
diff --git a/app/globals.css b/app/globals.css
index d8870ba..0526030 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -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 {
diff --git a/app/impressum/impressum.css b/app/impressum/impressum.css
index 5a042af..7955e89 100644
--- a/app/impressum/impressum.css
+++ b/app/impressum/impressum.css
@@ -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);
}
diff --git a/app/journal/journal.css b/app/journal/journal.css
index e84aeab..13844ba 100644
--- a/app/journal/journal.css
+++ b/app/journal/journal.css
@@ -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;
}
diff --git a/app/themes/editorial.css b/app/themes/editorial.css
index ae9fcd2..0c15740 100644
--- a/app/themes/editorial.css
+++ b/app/themes/editorial.css
@@ -34,7 +34,6 @@
text-transform: none;
font-style: italic;
margin-bottom: 48px;
- opacity: 0.7;
}
[data-preset='editorial'] .hero__nav-link {
diff --git a/app/tokens.css b/app/tokens.css
index 79b4910..b2b22ce 100644
--- a/app/tokens.css
+++ b/app/tokens.css
@@ -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;
diff --git a/components/PasswordGate.module.css b/components/PasswordGate.module.css
index 2653be7..75bfc85 100644
--- a/components/PasswordGate.module.css
+++ b/components/PasswordGate.module.css
@@ -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);
}
diff --git a/components/ProofingModal.tsx b/components/ProofingModal.tsx
index c963d12..f888ec1 100644
--- a/components/ProofingModal.tsx
+++ b/components/ProofingModal.tsx
@@ -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. */}
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',
}}
>
-