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) {
- + 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.

- + {TABS.map((tab) => ( + + ))}
{/* 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.

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 + +
+
+ {shown.map((gap) => ( + + {/* eslint-disable-next-line @next/next/no-img-element -- admin thumbnail proxy, not an optimisable image */} + + {gap.fileName} + + ))} + {hidden > 0 && ( + + )} +
+
+ ); + })} + +

+ 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 ( +
  • +
    +
    + {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…'} +

    +
    +
    + + +
    +
    + + {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 && ( +
    • +
      +
      +
    • + )} + {failedAltText && ( +
    • +
      +
      +
    • + )} +
    +
    + ); + })} + +
    +

    + Storage & 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) => ( -
    • -
    • - ))} -
    - )} -
    - -
    - - -
    -
    -
    - ); -} 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' && (
    @@ -918,6 +924,7 @@ function JournalEditor({ slug, onBack }: JournalEditorProps) { >
    update('lang', e.target.value)} > @@ -1209,12 +1200,13 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {

    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) {

    { 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({