From 3193c500e4990661c98d4ba81891add2465da7a6 Mon Sep 17 00:00:00 2001 From: Ralksta <16941050+ralksta@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:43:38 +0200 Subject: [PATCH] release: v0.18.0 (#725) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release v0.18.0 — merges `dev` into `main`. ## Thanks - [@lancetm714](https://github.com/lancetm714) for the accent-contrast fix (#680 → #724). - [@RichKidsDev](https://github.com/RichKidsDev) for reporting the outdated Impressum (#697). ### Security - **Journal drafts and password-protected entries no longer render publicly as a subpage essay** ([#684](https://github.com/ralksta/immich-folio/pull/684), [GHSA-987w-cpwm-c9g9](https://github.com/ralksta/immich-folio/security/advisories/GHSA-987w-cpwm-c9g9), medium). A subpage's `essayFile` read the journal entry straight from disk, skipping the draft and password rules `/journal/` applies. It now resolves through the same rules: a draft reads as missing for visitors, and a protected entry shows its password gate, sharing the `lb_auth_journal_` cookie. - **Immich descriptions are escaped in generated subpage essays** ([#684](https://github.com/ralksta/immich-folio/pull/684), [GHSA-8ppx-39jv-q5xm](https://github.com/ralksta/immich-folio/security/advisories/GHSA-8ppx-39jv-q5xm), low). The essay built from a subpage's albums (`layout: essay` without markdown) put each asset's Immich description into the page as raw HTML. Captions are now HTML-escaped, and they go through `assetCaption()`, so `exif.caption: false` is respected there as well. Tracked in [#689](https://github.com/ralksta/immich-folio/issues/689). - **Admin drafts no longer keep a typed password, and the contact form's email check runs in linear time** ([#727](https://github.com/ralksta/immich-folio/pull/727)). The admin's session drafts wrote a password as typed to `sessionStorage`; a draft holding a plaintext password is now not kept (the unsaved-changes guard still warns). The email pattern could backtrack polynomially on a crafted domain; its labels now exclude the dot and the length is checked first. Both found by CodeQL before release. ### Added - **A built-in contact form with an inbox in the admin** ([#703](https://github.com/ralksta/immich-folio/pull/703), closes [#702](https://github.com/ralksta/immich-folio/issues/702)). `/contact` needs neither SMTP nor a form service: each message is one file in `content/messages/` (mode `0600`), read under the new _Messages_ tab, where _Reply by email_ opens the owner's mail client with the message quoted. Messages older than `contact.retentionDays` (default 90) are deleted. An optional ntfy push goes to `contact.notifyUrl` or `CONTACT_NOTIFY_URL`, and since [#709](https://github.com/ralksta/immich-folio/pull/709) it carries a fixed "new message" line and nothing about the sender — no name, no address, no text. Spam is kept out without a captcha: an off-screen honeypot, a minimum fill time, three submissions a minute per IP and a cap of 500 stored messages. With the Impressum on and no `legal.contactUrl`, the Impressum links the form as its second contact channel. A new doctor check warns when the form is on but nobody is notified. - **A privacy policy page, next to a list of what the site processes** ([#708](https://github.com/ralksta/immich-folio/pull/708)). `/privacy` renders `content/privacy.md`, edited under Settings → Legal, and the footer links it once the file has text; `privacy.enabled: false` hides it. Folio writes no legal text. Beside the editor it lists what _this_ installation does, read off the configuration — the map, statistics, contact form, password cookies, CDN — and marks every entry that involves a third party. _Insert headings_ adds the matching section headings and nothing else. The doctor warns while the Impressum is on and there is no policy. - **French, Spanish, Italian and Dutch interface translations** ([#681](https://github.com/ralksta/immich-folio/pull/681)), selected with `settings.yaml: lang` as before. The admin panel stays English. - **CDN mode for photos and videos** ([#681](https://github.com/ralksta/immich-folio/pull/681)). With `CDN_URL` set, image and video URLs point at a pull CDN whose origin is this server, so repeat views reach neither the server nor Immich. A site password switches the mode off, since a CDN would hand out cached photos without checking the unlock cookie. The CDN origin is added to the CSP, and a doctor check covers the password and `TRUSTED_PROXY_HOPS` cases. See `docs/deployment.md#cdn-mode`. - **A reworked admin panel** ([#712](https://github.com/ralksta/immich-folio/pull/712), [#713](https://github.com/ralksta/immich-folio/pull/713), [#714](https://github.com/ralksta/immich-folio/pull/714), [#716](https://github.com/ralksta/immich-folio/pull/716), [#717](https://github.com/ralksta/immich-folio/pull/717), [#718](https://github.com/ralksta/immich-folio/pull/718), [#719](https://github.com/ralksta/immich-folio/pull/719), [#721](https://github.com/ralksta/immich-folio/pull/721)): - **A sidebar instead of the header row**, grouped by task, with an unread count on Messages and a dot on Diagnostics while Immich is down. It collapses to an icon rail, with names shown on hover and focus, and remembers that per browser. - **`/admin` is an overview**: what needs attention (unread messages, doctor warnings), published pages, journal entries and recent views. - **The site's own visual language** — the preset's fonts, hairline borders, the square marker — and one page header on every screen. The accent is kept for the current place and the primary action; switches are now pills reading ON or OFF, in green rather than the accent, so Studio Modern's red no longer makes an enabled setting look like an error. - **The page builder is a structure list and a panel.** Hero, subpages and standalone albums sit in one list on the left, subpages drag-sorted in place; the selected entry is edited on the right instead of in an overlay. - **Dialogs only where a change is permanent.** Deleting a message or a journal entry, and resetting the favicon (which used to delete the file without asking), go through one confirm dialog in the admin's own look. Removals in the page builder, which only touch unsaved state, happen at once and offer _Undo_. No `window.confirm()` is left. - **Feature switches are grouped by meaning** as one-line rows under Settings → General; page transitions and scroll-to-top moved to Theme → Look & motion. No setting keys changed. ### Changed - **Fonts and the Leaflet stylesheet are served from the site itself** ([#700](https://github.com/ralksta/immich-folio/pull/700)). Every page used to link `fonts.googleapis.com`, which sends each visitor's IP address to Google. The server now fetches a theme's Google fonts once, rewrites the stylesheet and caches the files in `content/.fonts/`; custom fonts in `theme.fonts` keep working. If Google cannot be reached before a font is cached, the page falls back to the theme's system fonts. `leaflet.css` comes from the npm package instead of unpkg. The CSP drops `fonts.googleapis.com`, `fonts.gstatic.com` and `unpkg.com`. - **Email addresses are kept out of the served HTML** ([#705](https://github.com/ralksta/immich-folio/pull/705)). The Impressum and footer render the address only after hydration, from an encoded form, so harvesters reading raw HTML find nothing. Without JavaScript the Impressum shows `name [at] example [dot] de`. This defeats bulk harvesting, not a person with a browser. - **The Impressum cites § 5 DDG and links its contacts** ([#698](https://github.com/ralksta/immich-folio/pull/698), reported in [#697](https://github.com/ralksta/immich-folio/issues/697) by [@RichKidsDev](https://github.com/RichKidsDev)). The TMG was replaced by the DDG in May 2024; `legal.heading` replaces the line for other laws (e.g. § 5 ECG in Austria). `legal.contactUrl` and `legal.contactLabel` add a contact form as the second channel, so a phone number stays optional. Email, phone and contact URL are now links, and the "Created with the Impressum generator by eRecht24" line, shown on every site whether true or not, is gone. Settings → Legal gains fields for heading, contact URL, VAT ID and tax number, and a doctor check flags missing details. - **Missing pages answer `404`** ([#704](https://github.com/ralksta/immich-folio/pull/704)). Every missing page used to answer `200`, because streaming had started before `notFound()` ran. `/contact`, `/impressum`, `/map` and `/privacy` while switched off, and `/journal/` without an entry, now return a real 404 with the same not-found page. Album and subpage slugs still depend on Immich and stay soft 404s. - **Passwords set in the admin are stored as scrypt hashes** ([#706](https://github.com/ralksta/immich-folio/pull/706), closes [#690](https://github.com/ralksta/immich-folio/issues/690)). Subpage, album, journal-entry and site passwords were written as typed. The save routes now hash them; a stored hash reads _Protected_ with _Change_ and _Remove_ and is never put into an input. Visitors who unlocked a page stay signed in across unrelated saves. Existing plaintext passwords are hashed the next time their file is saved in the admin. - **One notification surface in the admin** ([#707](https://github.com/ralksta/immich-folio/pull/707)) replaces every `alert()`: errors stay until dismissed, successes clear themselves, and screen readers announce both. ### Fixed - **Map tiles load again** ([#701](https://github.com/ralksta/immich-folio/pull/701)). CARTO started answering every tile with an "API KEY REQUIRED" image, leaving `/map` and journal map blocks as a grey grid. Tiles now come from OpenStreetMap; the dark look is a CSS filter, so the map now follows the colour mode. - **The page builder no longer drops a subpage's `proofing:` flag on save** ([#711](https://github.com/ralksta/immich-folio/pull/711)). A page switched off for proofing by hand reverted after any unrelated edit. - **Unsaved edits in Settings and About survive switching admin tabs, and a backup restore is no longer overwritten by the next save** ([#687](https://github.com/ralksta/immich-folio/pull/687)). Editors that were open during a restore now reload and offer unsaved edits back as a conflict. Also: "Restore my changes" after a conflict found nothing, removing an album left its drawer showing the next album, and the journal saved (rotating a backup) without changes. - **Accent-backed buttons stay readable with a light or malformed accent** ([#724](https://github.com/ralksta/immich-folio/pull/724), supersedes [#680](https://github.com/ralksta/immich-folio/pull/680)) — contributed by [@lancetm714](https://github.com/lancetm714). A white accent made the proofing dialog's _Download selected (.zip)_ button white on white. `theme.accent` is now accepted as hex only and normalised to `#rrggbb`, anything else falls back to the preset, and the proofing dialog and filter pills use the mode-aware `--on-accent` text colour from #688. - **Every preset's accent is visible in both colour modes** ([#688](https://github.com/ralksta/immich-folio/pull/688)). Presets had one accent for both modes; minimal's dark mode drew black buttons on black. Presets now carry a light and a dark accent, and text on accent-filled buttons picks black or white for contrast. A custom accent stays the owner's choice in both modes. - **Visitor-side inconsistencies** ([#685](https://github.com/ralksta/immich-folio/pull/685)): after visiting `/impressum`, every subpage header turned centred and uppercase; back links announced "Back to Back to …"; the hero EXIF chip ignored `exif.location`/`camera`/`settings`; videos in journal entries opened as stills; the setup screen named a non-existent `.env.example`. - **An essay's lightbox walks only the photos the story shows** ([#686](https://github.com/ralksta/immich-folio/pull/686)), not every photo of the subpage's albums, and the proofing bar in essays shows the selection count instead of a filter button that did nothing. - **The page panel scrolls with the mouse wheel** ([#720](https://github.com/ralksta/immich-folio/pull/720)). - **The journal editor's top bar wraps on phones** ([#723](https://github.com/ralksta/immich-folio/pull/723), closes [#675](https://github.com/ralksta/immich-folio/issues/675)), so the title stays editable and Save stays on screen. - **Every admin form field has a name for assistive technology** ([#715](https://github.com/ralksta/immich-folio/pull/715)). Only two inputs had a linked label; 47 are linked now, and clicking a label focuses its field. ### Internal - **`SettingsEditor` and `PageBuilder` are split up** ([#710](https://github.com/ralksta/immich-folio/pull/710), [#711](https://github.com/ralksta/immich-folio/pull/711)). The 2,200-line settings editor is a shell with one file per section; the page builder's YAML handling and edits are pure functions under test. Both were checked side by side against `dev` for identical output. - **`useAdminFetch()`** ([#707](https://github.com/ralksta/immich-folio/pull/707)) gives Analytics, Messages, Backups and the journal list one loading and error pattern. - README brought up to v0.17.0 ([#678](https://github.com/ralksta/immich-folio/pull/678), [#679](https://github.com/ralksta/immich-folio/pull/679)). ### Upgrade notes **`content/` gains two directories the app writes to:** `content/.fonts/` for the cached fonts and `content/messages/` for contact-form messages. Both are created on demand, so a writable `content/` is enough, as before. **The server needs outbound HTTPS to Google once per font** to fill the font cache. Until then pages fall back to the theme's system fonts. Visitors' browsers no longer contact Google. **A custom CSP or reverse-proxy allowlist** must now allow `tile.openstreetmap.org` for images instead of `*.basemaps.cartocdn.com`, and no longer needs `fonts.googleapis.com`, `fonts.gstatic.com` or `unpkg.com`. The built-in CSP is updated. **`theme.accent` must be hex** (`#rgb`, `#rrggbb` or `#rrggbbaa`). Anything else, including a value without `#`, falls back to the preset's accent. **Plaintext passwords in `gallery.yaml`, `settings.yaml` or journal entries** are hashed the next time that file is saved in the admin; one save clears the doctor's warning. **New, optional settings:** `contact.*` (the form is off by default), `privacy.enabled`, `legal.heading`, `legal.contactUrl`, `legal.contactLabel`, and the `CDN_URL` and `CONTACT_NOTIFY_URL` environment variables. See `content/settings.yaml.example` and `.env.local.example`. In the example file, `vatId` and `taxId` had swapped sample values; check yours if you copied them. No `IMAGE_CACHE_VERSION` bump is needed this time. --------- Signed-off-by: dependabot[bot] Co-authored-by: ralksta Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 Co-authored-by: Ralf Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com> Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com> Co-authored-by: Lance Martin --- .env.local.example | 8 + .gitignore | 5 + CHANGELOG.md | 267 +++ README.md | 29 +- app/[...path]/EssayView.tsx | 58 +- app/[...path]/PhotoGrid.tsx | 2 +- app/[...path]/page.tsx | 27 +- app/admin/AdminShell.tsx | 10 +- app/admin/__tests__/ConfirmDialog.test.tsx | 62 + app/admin/__tests__/Notifications.test.tsx | 74 + app/admin/__tests__/SaveBar.test.tsx | 34 +- app/admin/__tests__/contentRestored.test.tsx | 35 + app/admin/__tests__/gallery-ops.test.ts | 112 + app/admin/__tests__/gallery-yaml.test.ts | 162 ++ app/admin/__tests__/useAdminFetch.test.tsx | 80 + app/admin/__tests__/useDraft.test.tsx | 44 +- app/admin/admin.css | 3 + app/admin/components/AdminDashboard.tsx | 295 ++- app/admin/components/AdminLoadState.tsx | 42 + app/admin/components/AdminOverview.tsx | 229 ++ app/admin/components/AnalyticsView.tsx | 76 +- app/admin/components/BackupManagerModal.tsx | 59 +- app/admin/components/ConfirmDialog.tsx | 115 + app/admin/components/DiagnosticsView.tsx | 54 +- app/admin/components/HelpView.tsx | 14 +- app/admin/components/MessagesView.tsx | 192 ++ app/admin/components/Notifications.tsx | 108 + app/admin/components/PageBuilder.tsx | 1159 ++++------ app/admin/components/PageHeader.tsx | 29 + app/admin/components/PrivacyEditor.tsx | 168 ++ app/admin/components/SaveBar.tsx | 19 +- app/admin/components/SettingsEditor.tsx | 1905 ++--------------- .../__tests__/AdminDashboard-reload.test.tsx | 44 +- app/admin/components/contentRestored.ts | 40 + .../components/fields/GridOverrideFields.tsx | 11 +- app/admin/components/fields/OptionGrid.tsx | 10 +- app/admin/components/fields/PasswordField.tsx | 120 ++ .../components/journal/JournalEditor.tsx | 35 +- .../components/journal/JournalStudio.tsx | 116 +- .../components/journal/StorySettingsModal.tsx | 17 +- .../components/journal/journal-studio.css | 36 + .../components/page-builder/AlbumDrawer.tsx | 37 +- .../components/page-builder/SortableTiles.tsx | 213 ++ .../components/page-builder/SubpageDrawer.tsx | 868 ++++---- .../components/page-builder/galleryOps.ts | 175 ++ .../components/page-builder/galleryYaml.ts | 130 ++ app/admin/components/page-builder/types.ts | 5 + .../components/settings/AboutSection.tsx | 287 +++ .../components/settings/FooterSection.tsx | 118 + .../components/settings/GeneralSection.tsx | 311 +++ app/admin/components/settings/GridSection.tsx | 204 ++ .../components/settings/LegalSection.tsx | 255 +++ .../components/settings/SecuritySection.tsx | 139 ++ app/admin/components/settings/SeoSection.tsx | 141 ++ .../components/settings/ThemeSection.tsx | 495 +++++ app/admin/components/settings/fields.tsx | 154 ++ app/admin/components/settings/types.ts | 95 + app/admin/components/useAdminFetch.ts | 85 + app/admin/components/useDraft.ts | 41 +- app/admin/messages/page.tsx | 5 + app/admin/page.tsx | 16 +- app/admin/pages/page.tsx | 12 +- app/admin/styles/messages.css | 138 ++ app/admin/styles/notifications.css | 100 + app/admin/styles/redesign.css | 1249 +++++++++++ app/admin/styles/settings-editor.css | 68 + app/admin/styles/subpage-sheet.css | 50 + app/api/__tests__/site-gate.test.ts | 30 + app/api/admin/__tests__/admin-guards.test.ts | 40 +- app/api/admin/__tests__/doctor-guards.test.ts | 44 + .../admin/__tests__/settings-write.test.ts | 20 + app/api/admin/backups/route.ts | 4 +- app/api/admin/doctor/route.ts | 32 + app/api/admin/gallery/route.ts | 13 +- app/api/admin/journal/[slug]/route.ts | 13 +- app/api/admin/messages/route.ts | 44 + app/api/admin/privacy/route.ts | 78 + app/api/admin/settings/route.ts | 13 +- app/api/contact/route.ts | 71 + app/api/fonts/css/route.ts | 50 + app/api/fonts/file/[name]/route.ts | 31 + app/contact/contact.css | 112 + app/contact/page.tsx | 43 + app/globals.css | 2 +- app/impressum/impressum.css | 24 +- app/impressum/page.tsx | 46 +- app/journal/[slug]/page.tsx | 2 + app/layout.tsx | 15 +- app/leaflet.css | 15 +- app/map/page.tsx | 12 +- app/page.tsx | 28 +- app/privacy/page.tsx | 61 + app/privacy/privacy.css | 43 + app/tokens.css | 12 +- components/BackLink.tsx | 6 +- components/ContactForm.tsx | 133 ++ components/DevToolbar.tsx | 11 +- components/EmailLink.tsx | 46 + components/Footer.tsx | 28 +- components/LeafletMap.tsx | 33 +- components/PasswordGate.module.css | 2 +- components/ProofingModal.tsx | 2 +- components/SetupScreen.tsx | 11 +- content/settings.yaml.example | 29 +- docs/admin-panel.md | 23 +- docs/admin-ux-concept.md | 69 + docs/deployment.md | 64 +- docs/gallery-config.md | 51 +- docs/journal.md | 7 +- docs/theming.md | 6 +- e2e/admin-smoke.spec.ts | 5 +- lib/__tests__/cdn.test.ts | 110 + lib/__tests__/config.test.ts | 67 + lib/__tests__/contact.test.ts | 175 ++ lib/__tests__/doctor.test.ts | 139 ++ lib/__tests__/emailObfuscation.test.ts | 39 + lib/__tests__/essay-sequence.test.ts | 46 + lib/__tests__/essay-source.test.ts | 102 + lib/__tests__/fonts.test.ts | 124 ++ lib/__tests__/i18n.test.ts | 155 +- lib/__tests__/password-hashing.test.ts | 86 + lib/__tests__/preset-contrast.test.ts | 97 + lib/__tests__/privacy.test.ts | 89 + lib/__tests__/proxy.test.ts | 98 +- lib/__tests__/siteUrl.test.ts | 6 + lib/__tests__/theme-scalars.test.ts | 43 +- lib/admin/doctor.ts | 198 +- lib/admin/passwordHashing.ts | 120 ++ lib/admin/yaml-service.ts | 3 +- lib/cdn.ts | 69 + lib/config/index.ts | 85 +- lib/config/schema.ts | 41 + lib/config/settingsSchema.ts | 7 + lib/config/theme.ts | 98 +- lib/contact.ts | 233 ++ lib/emailObfuscation.ts | 44 + lib/env.ts | 35 + lib/essaySequence.ts | 44 + lib/essaySource.ts | 53 + lib/fonts.ts | 206 ++ lib/i18n/index.ts | 14 +- lib/i18n/locales/de.ts | 27 +- lib/i18n/locales/en.ts | 27 +- lib/i18n/locales/es.ts | 245 +++ lib/i18n/locales/fr.ts | 246 +++ lib/i18n/locales/it.ts | 245 +++ lib/i18n/locales/nl.ts | 246 +++ lib/markdownBlocks.ts | 62 + lib/privacy.ts | 161 ++ lib/siteUrl.ts | 4 + lib/urls.ts | 11 +- package-lock.json | 4 +- package.json | 2 +- proxy.ts | 71 +- scripts/doctor.mts | 30 + 155 files changed, 13188 insertions(+), 3539 deletions(-) create mode 100644 app/admin/__tests__/ConfirmDialog.test.tsx create mode 100644 app/admin/__tests__/Notifications.test.tsx create mode 100644 app/admin/__tests__/contentRestored.test.tsx create mode 100644 app/admin/__tests__/gallery-ops.test.ts create mode 100644 app/admin/__tests__/gallery-yaml.test.ts create mode 100644 app/admin/__tests__/useAdminFetch.test.tsx create mode 100644 app/admin/components/AdminLoadState.tsx create mode 100644 app/admin/components/AdminOverview.tsx create mode 100644 app/admin/components/ConfirmDialog.tsx create mode 100644 app/admin/components/MessagesView.tsx create mode 100644 app/admin/components/Notifications.tsx create mode 100644 app/admin/components/PageHeader.tsx create mode 100644 app/admin/components/PrivacyEditor.tsx create mode 100644 app/admin/components/contentRestored.ts create mode 100644 app/admin/components/fields/PasswordField.tsx create mode 100644 app/admin/components/page-builder/SortableTiles.tsx create mode 100644 app/admin/components/page-builder/galleryOps.ts create mode 100644 app/admin/components/page-builder/galleryYaml.ts create mode 100644 app/admin/components/settings/AboutSection.tsx create mode 100644 app/admin/components/settings/FooterSection.tsx create mode 100644 app/admin/components/settings/GeneralSection.tsx create mode 100644 app/admin/components/settings/GridSection.tsx create mode 100644 app/admin/components/settings/LegalSection.tsx create mode 100644 app/admin/components/settings/SecuritySection.tsx create mode 100644 app/admin/components/settings/SeoSection.tsx create mode 100644 app/admin/components/settings/ThemeSection.tsx create mode 100644 app/admin/components/settings/fields.tsx create mode 100644 app/admin/components/settings/types.ts create mode 100644 app/admin/components/useAdminFetch.ts create mode 100644 app/admin/messages/page.tsx create mode 100644 app/admin/styles/messages.css create mode 100644 app/admin/styles/notifications.css create mode 100644 app/admin/styles/redesign.css create mode 100644 app/api/admin/messages/route.ts create mode 100644 app/api/admin/privacy/route.ts create mode 100644 app/api/contact/route.ts create mode 100644 app/api/fonts/css/route.ts create mode 100644 app/api/fonts/file/[name]/route.ts create mode 100644 app/contact/contact.css create mode 100644 app/contact/page.tsx create mode 100644 app/privacy/page.tsx create mode 100644 app/privacy/privacy.css create mode 100644 components/ContactForm.tsx create mode 100644 components/EmailLink.tsx create mode 100644 docs/admin-ux-concept.md create mode 100644 lib/__tests__/cdn.test.ts create mode 100644 lib/__tests__/contact.test.ts create mode 100644 lib/__tests__/emailObfuscation.test.ts create mode 100644 lib/__tests__/essay-sequence.test.ts create mode 100644 lib/__tests__/essay-source.test.ts create mode 100644 lib/__tests__/fonts.test.ts create mode 100644 lib/__tests__/password-hashing.test.ts create mode 100644 lib/__tests__/preset-contrast.test.ts create mode 100644 lib/__tests__/privacy.test.ts create mode 100644 lib/admin/passwordHashing.ts create mode 100644 lib/cdn.ts create mode 100644 lib/contact.ts create mode 100644 lib/emailObfuscation.ts create mode 100644 lib/essaySequence.ts create mode 100644 lib/essaySource.ts create mode 100644 lib/fonts.ts create mode 100644 lib/i18n/locales/es.ts create mode 100644 lib/i18n/locales/fr.ts create mode 100644 lib/i18n/locales/it.ts create mode 100644 lib/i18n/locales/nl.ts create mode 100644 lib/markdownBlocks.ts create mode 100644 lib/privacy.ts diff --git a/.env.local.example b/.env.local.example index 2d7160f..273808d 100644 --- a/.env.local.example +++ b/.env.local.example @@ -34,6 +34,14 @@ STALE_MAX_AGE=86400 # refetch. Leave it unset in normal operation. # IMAGE_CACHE_VERSION=1 +# CDN mode: serve photos and videos through a pull CDN (default: off). +# Point a CDN (Cloudflare, Bunny, CloudFront, ...) at this server as its origin, +# cache /api/image/* and /api/video/* including the query string, and put its +# URL here. Pages, EXIF, the map and downloads keep coming from this server. +# Ignored while a site password is set, so the password keeps covering photos. +# Also set TRUSTED_PROXY_HOPS to count the CDN, see docs/deployment.md#cdn-mode. +# CDN_URL=https://cdn.example.com + # How long to wait for a response from Immich, in milliseconds # (integer, minimum 1000, default: 15000 = 15 seconds). # Bounds the wait for a *response* only — downloading a large photo or a long diff --git a/.gitignore b/.gitignore index 7caad44..e22c502 100644 --- a/.gitignore +++ b/.gitignore @@ -68,6 +68,7 @@ docker-compose.override.yml content/settings.yaml content/gallery.yaml content/about.md +content/privacy.md # Journal entries reference Immich asset UUIDs, which are specific to one # server — committing an entry would publish ids from the author's instance. # The .example alongside them stays tracked as a starting point. @@ -86,6 +87,10 @@ content/*.screenshot-bak content/install.json # One-time setup token, written on first access and removed once install completes content/.setup-token +# Contact form messages (lib/contact.ts), personal data of visitors +content/messages/ +# Google Fonts cached by /api/fonts (lib/fonts.ts) +content/.fonts/ # ── Internal Dev Docs ──────────────────────────────── docs/dev/ diff --git a/CHANGELOG.md b/CHANGELOG.md index afeb9bc..50ba7c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,273 @@ 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.18.0] — 2026-09-27 + +### Security + +- **Journal drafts and password-protected entries no longer render publicly + as a subpage essay** + ([#684](https://github.com/ralksta/immich-folio/pull/684), + [GHSA-987w-cpwm-c9g9](https://github.com/ralksta/immich-folio/security/advisories/GHSA-987w-cpwm-c9g9), + medium). A subpage's `essayFile` read the journal entry straight from disk, + skipping the draft and password rules `/journal/` applies. It now + resolves through the same rules: a draft reads as missing for visitors, and + a protected entry shows its password gate, sharing the + `lb_auth_journal_` cookie. + +- **Immich descriptions are escaped in generated subpage essays** + ([#684](https://github.com/ralksta/immich-folio/pull/684), + [GHSA-8ppx-39jv-q5xm](https://github.com/ralksta/immich-folio/security/advisories/GHSA-8ppx-39jv-q5xm), + low). The essay built from a subpage's albums (`layout: essay` without + markdown) put each asset's Immich description into the page as raw HTML. + Captions are now HTML-escaped, and they go through `assetCaption()`, so + `exif.caption: false` is respected there as well. Tracked in + [#689](https://github.com/ralksta/immich-folio/issues/689). + +- **Admin drafts no longer keep a typed password, and the contact form's + email check runs in linear time** + ([#727](https://github.com/ralksta/immich-folio/pull/727)). The admin's + session drafts wrote a password as typed to `sessionStorage`; a draft + holding a plaintext password is now not kept (the unsaved-changes guard + still warns). The email pattern could backtrack polynomially on a crafted + domain; its labels now exclude the dot and the length is checked first. + Both found by CodeQL before release. + +### Added + +- **A built-in contact form with an inbox in the admin** + ([#703](https://github.com/ralksta/immich-folio/pull/703), closes + [#702](https://github.com/ralksta/immich-folio/issues/702)). `/contact` + needs neither SMTP nor a form service: each message is one file in + `content/messages/` (mode `0600`), read under the new _Messages_ tab, where + _Reply by email_ opens the owner's mail client with the message quoted. + Messages older than `contact.retentionDays` (default 90) are deleted. An + optional ntfy push goes to `contact.notifyUrl` or `CONTACT_NOTIFY_URL`, and + since [#709](https://github.com/ralksta/immich-folio/pull/709) it carries a + fixed "new message" line and nothing about the sender — no name, no + address, no text. Spam is kept out without a captcha: an off-screen + honeypot, a minimum fill time, three submissions a minute per IP and a cap + of 500 stored messages. With the Impressum on and no `legal.contactUrl`, + the Impressum links the form as its second contact channel. A new doctor + check warns when the form is on but nobody is notified. + +- **A privacy policy page, next to a list of what the site processes** + ([#708](https://github.com/ralksta/immich-folio/pull/708)). `/privacy` + renders `content/privacy.md`, edited under Settings → Legal, and the footer + links it once the file has text; `privacy.enabled: false` hides it. Folio + writes no legal text. Beside the editor it lists what _this_ installation + does, read off the configuration — the map, statistics, contact form, + password cookies, CDN — and marks every entry that involves a third party. + _Insert headings_ adds the matching section headings and nothing else. The + doctor warns while the Impressum is on and there is no policy. + +- **French, Spanish, Italian and Dutch interface translations** + ([#681](https://github.com/ralksta/immich-folio/pull/681)), selected with + `settings.yaml: lang` as before. The admin panel stays English. + +- **CDN mode for photos and videos** + ([#681](https://github.com/ralksta/immich-folio/pull/681)). With `CDN_URL` + set, image and video URLs point at a pull CDN whose origin is this server, + so repeat views reach neither the server nor Immich. A site password + switches the mode off, since a CDN would hand out cached photos without + checking the unlock cookie. The CDN origin is added to the CSP, and a + doctor check covers the password and `TRUSTED_PROXY_HOPS` cases. See + `docs/deployment.md#cdn-mode`. + +- **A reworked admin panel** + ([#712](https://github.com/ralksta/immich-folio/pull/712), + [#713](https://github.com/ralksta/immich-folio/pull/713), + [#714](https://github.com/ralksta/immich-folio/pull/714), + [#716](https://github.com/ralksta/immich-folio/pull/716), + [#717](https://github.com/ralksta/immich-folio/pull/717), + [#718](https://github.com/ralksta/immich-folio/pull/718), + [#719](https://github.com/ralksta/immich-folio/pull/719), + [#721](https://github.com/ralksta/immich-folio/pull/721)): + - **A sidebar instead of the header row**, grouped by task, with an unread + count on Messages and a dot on Diagnostics while Immich is down. It + collapses to an icon rail, with names shown on hover and focus, and + remembers that per browser. + - **`/admin` is an overview**: what needs attention (unread messages, + doctor warnings), published pages, journal entries and recent views. + - **The site's own visual language** — the preset's fonts, hairline + borders, the square marker — and one page header on every screen. The + accent is kept for the current place and the primary action; switches + are now pills reading ON or OFF, in green rather than the accent, so + Studio Modern's red no longer makes an enabled setting look like an + error. + - **The page builder is a structure list and a panel.** Hero, subpages and + standalone albums sit in one list on the left, subpages drag-sorted in + place; the selected entry is edited on the right instead of in an + overlay. + - **Dialogs only where a change is permanent.** Deleting a message or a + journal entry, and resetting the favicon (which used to delete the file + without asking), go through one confirm dialog in the admin's own look. + Removals in the page builder, which only touch unsaved state, happen at + once and offer _Undo_. No `window.confirm()` is left. + - **Feature switches are grouped by meaning** as one-line rows under + Settings → General; page transitions and scroll-to-top moved to Theme → + Look & motion. No setting keys changed. + +### Changed + +- **Fonts and the Leaflet stylesheet are served from the site itself** + ([#700](https://github.com/ralksta/immich-folio/pull/700)). Every page used + to link `fonts.googleapis.com`, which sends each visitor's IP address to + Google. The server now fetches a theme's Google fonts once, rewrites the + stylesheet and caches the files in `content/.fonts/`; custom fonts in + `theme.fonts` keep working. If Google cannot be reached before a font is + cached, the page falls back to the theme's system fonts. `leaflet.css` + comes from the npm package instead of unpkg. The CSP drops + `fonts.googleapis.com`, `fonts.gstatic.com` and `unpkg.com`. + +- **Email addresses are kept out of the served HTML** + ([#705](https://github.com/ralksta/immich-folio/pull/705)). The Impressum + and footer render the address only after hydration, from an encoded form, + so harvesters reading raw HTML find nothing. Without JavaScript the + Impressum shows `name [at] example [dot] de`. This defeats bulk harvesting, + not a person with a browser. + +- **The Impressum cites § 5 DDG and links its contacts** + ([#698](https://github.com/ralksta/immich-folio/pull/698), reported in + [#697](https://github.com/ralksta/immich-folio/issues/697) by + [@RichKidsDev](https://github.com/RichKidsDev)). The TMG was replaced by the + DDG in May 2024; `legal.heading` replaces the line for other laws (e.g. + § 5 ECG in Austria). `legal.contactUrl` and `legal.contactLabel` add a + contact form as the second channel, so a phone number stays optional. + Email, phone and contact URL are now links, and the "Created with the + Impressum generator by eRecht24" line, shown on every site whether true or + not, is gone. Settings → Legal gains fields for heading, contact URL, VAT + ID and tax number, and a doctor check flags missing details. + +- **Missing pages answer `404`** + ([#704](https://github.com/ralksta/immich-folio/pull/704)). Every missing + page used to answer `200`, because streaming had started before + `notFound()` ran. `/contact`, `/impressum`, `/map` and `/privacy` while + switched off, and `/journal/` without an entry, now return a real + 404 with the same not-found page. Album and subpage slugs still depend on + Immich and stay soft 404s. + +- **Passwords set in the admin are stored as scrypt hashes** + ([#706](https://github.com/ralksta/immich-folio/pull/706), closes + [#690](https://github.com/ralksta/immich-folio/issues/690)). Subpage, + album, journal-entry and site passwords were written as typed. The save + routes now hash them; a stored hash reads _Protected_ with _Change_ and + _Remove_ and is never put into an input. Visitors who unlocked a page stay + signed in across unrelated saves. Existing plaintext passwords are hashed + the next time their file is saved in the admin. + +- **One notification surface in the admin** + ([#707](https://github.com/ralksta/immich-folio/pull/707)) replaces every + `alert()`: errors stay until dismissed, successes clear themselves, and + screen readers announce both. + +### Fixed + +- **Map tiles load again** + ([#701](https://github.com/ralksta/immich-folio/pull/701)). CARTO started + answering every tile with an "API KEY REQUIRED" image, leaving `/map` and + journal map blocks as a grey grid. Tiles now come from OpenStreetMap; the + dark look is a CSS filter, so the map now follows the colour mode. + +- **The page builder no longer drops a subpage's `proofing:` flag on save** + ([#711](https://github.com/ralksta/immich-folio/pull/711)). A page switched + off for proofing by hand reverted after any unrelated edit. + +- **Unsaved edits in Settings and About survive switching admin tabs, and a + backup restore is no longer overwritten by the next save** + ([#687](https://github.com/ralksta/immich-folio/pull/687)). Editors that + were open during a restore now reload and offer unsaved edits back as a + conflict. Also: "Restore my changes" after a conflict found nothing, + removing an album left its drawer showing the next album, and the journal + saved (rotating a backup) without changes. + +- **Accent-backed buttons stay readable with a light or malformed accent** + ([#724](https://github.com/ralksta/immich-folio/pull/724), supersedes + [#680](https://github.com/ralksta/immich-folio/pull/680)) — contributed by + [@lancetm714](https://github.com/lancetm714). A white accent made the + proofing dialog's _Download selected (.zip)_ button white on white. + `theme.accent` is now accepted as hex only and normalised to `#rrggbb`, + anything else falls back to the preset, and the proofing dialog and filter + pills use the mode-aware `--on-accent` text colour from #688. + +- **Every preset's accent is visible in both colour modes** + ([#688](https://github.com/ralksta/immich-folio/pull/688)). Presets had one + accent for both modes; minimal's dark mode drew black buttons on black. + Presets now carry a light and a dark accent, and text on accent-filled + buttons picks black or white for contrast. A custom accent stays the + owner's choice in both modes. + +- **Visitor-side inconsistencies** + ([#685](https://github.com/ralksta/immich-folio/pull/685)): after visiting + `/impressum`, every subpage header turned centred and uppercase; back links + announced "Back to Back to …"; the hero EXIF chip ignored + `exif.location`/`camera`/`settings`; videos in journal entries opened as + stills; the setup screen named a non-existent `.env.example`. + +- **An essay's lightbox walks only the photos the story shows** + ([#686](https://github.com/ralksta/immich-folio/pull/686)), not every photo + of the subpage's albums, and the proofing bar in essays shows the selection + count instead of a filter button that did nothing. + +- **The page panel scrolls with the mouse wheel** + ([#720](https://github.com/ralksta/immich-folio/pull/720)). + +- **The journal editor's top bar wraps on phones** + ([#723](https://github.com/ralksta/immich-folio/pull/723), closes + [#675](https://github.com/ralksta/immich-folio/issues/675)), so the title + stays editable and Save stays on screen. + +- **Every admin form field has a name for assistive technology** + ([#715](https://github.com/ralksta/immich-folio/pull/715)). Only two inputs + had a linked label; 47 are linked now, and clicking a label focuses its + field. + +### Internal + +- **`SettingsEditor` and `PageBuilder` are split up** + ([#710](https://github.com/ralksta/immich-folio/pull/710), + [#711](https://github.com/ralksta/immich-folio/pull/711)). The 2,200-line + settings editor is a shell with one file per section; the page builder's + YAML handling and edits are pure functions under test. Both were checked + side by side against `dev` for identical output. +- **`useAdminFetch()`** ([#707](https://github.com/ralksta/immich-folio/pull/707)) + gives Analytics, Messages, Backups and the journal list one loading and + error pattern. +- README brought up to v0.17.0 + ([#678](https://github.com/ralksta/immich-folio/pull/678), + [#679](https://github.com/ralksta/immich-folio/pull/679)). + +### Upgrade notes + +**`content/` gains two directories the app writes to:** `content/.fonts/` +for the cached fonts and `content/messages/` for contact-form messages. Both +are created on demand, so a writable `content/` is enough, as before. + +**The server needs outbound HTTPS to Google once per font** to fill the font +cache. Until then pages fall back to the theme's system fonts. Visitors' +browsers no longer contact Google. + +**A custom CSP or reverse-proxy allowlist** must now allow +`tile.openstreetmap.org` for images instead of `*.basemaps.cartocdn.com`, +and no longer needs `fonts.googleapis.com`, `fonts.gstatic.com` or +`unpkg.com`. The built-in CSP is updated. + +**`theme.accent` must be hex** (`#rgb`, `#rrggbb` or `#rrggbbaa`). Anything +else, including a value without `#`, falls back to the preset's accent. + +**Plaintext passwords in `gallery.yaml`, `settings.yaml` or journal entries** +are hashed the next time that file is saved in the admin; one save clears the +doctor's warning. + +**New, optional settings:** `contact.*` (the form is off by default), +`privacy.enabled`, `legal.heading`, `legal.contactUrl`, `legal.contactLabel`, +and the `CDN_URL` and `CONTACT_NOTIFY_URL` environment variables. See +`content/settings.yaml.example` and `.env.local.example`. In the example file, +`vatId` and `taxId` had swapped sample values; check yours if you copied +them. + +No `IMAGE_CACHE_VERSION` bump is needed this time. + ## [0.17.0] — 2026-09-23 ### Added diff --git a/README.md b/README.md index ef14ec0..e4c8ba5 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ A self-hosted portfolio powered by [Immich](https://immich.app). It acts as a **secure reverse proxy** between your visitors and your private Immich instance: your Immich server stays on your local network, completely invisible to the outside world, while your albums are published as a gallery you control. +**Latest: v0.18.0** — built-in contact form, privacy page and a reworked admin panel. → [What's New](#whats-new) + ## Contents - [Features](#features) @@ -67,6 +69,7 @@ A self-hosted portfolio powered by [Immich](https://immich.app). It acts as a ** - **Client proofing** — clients favorite photos and export the selection; picks are encoded in the URL, nothing is stored server-side - **Originals delivery** — per-album opt-in lets visitors download a whole album or their proofing selection as a ZIP of the originals - **Lightbox watermark** — configurable overlay on fullscreen images +- **Six interface languages** — English, German, French, Spanish, Italian and Dutch for everything visitors see - **Privacy-friendly analytics** — cookieless view counts, no third parties, can be switched off - **Dynamic OG images** — auto-generated social preview images per album @@ -114,6 +117,7 @@ A self-hosted portfolio powered by [Immich](https://immich.app). It acts as a ** - Health check endpoint at `GET /api/health` - In-memory caching with configurable TTL +- Optional CDN mode — photos and videos served through a pull CDN, so repeat views never reach your server ([Deployment Guide](docs/deployment.md#cdn-mode)) - Standalone Docker image — multi-stage, non-root, ~150 MB - Dependencies kept current via Dependabot (npm + GitHub Actions, weekly) @@ -219,6 +223,8 @@ AUTH_SECRET=long-random-string # required in production TRUSTED_PROXY_HOPS=1 # reverse proxies in front, default: 0 ADMIN_PASSWORD=your-secure-password # enables /admin panel WEBHOOK_SECRET=long-random-string # enables POST /api/webhook cache invalidation +CDN_URL=https://cdn.example.com # serve photos and videos through a pull CDN +CONTACT_NOTIFY_URL=https://ntfy.sh/x # push for new contact form messages, overrides settings.yaml ``` > Login and setup endpoints have their own, much lower limits that `RATE_LIMIT_RPM` does not raise — see [Rate Limiting](docs/gallery-config.md#rate-limiting). @@ -336,15 +342,15 @@ own password, separate from any album passwords, and writes straight to ## What's New -v0.17.0 — originals as a ZIP, and a richer journal: +v0.18.0 — a contact form, a privacy page and a reworked admin: -- **Download originals as a ZIP** — a whole album, or just the photos a client picked in proofing, as one archive of the originals. Opt-in per album; contributed by [@lancetm714](https://github.com/lancetm714) -- **Journal templates** — start a new entry from eight structures, from a wedding to a hiking trip -- **New journal blocks** — photo grids, facts lists, a map with the pins you choose, and a slice of an album; reorder blocks by dragging → [Journal guide](docs/journal.md) -- **Unsaved admin edits survive navigation** — switching tabs or reloading no longer throws your changes away -- **Plain-HTTP installs** — admin login and gallery passwords now work at `http://host:7211` +- **Built-in contact form** — no SMTP or form service: messages land in a new admin inbox, with an optional ntfy push that carries nothing about the sender +- **Privacy page and fewer third parties** — `/privacy` with a list of what your installation actually processes; fonts and map styles are served from your own server, and email addresses stay out of the page source +- **A reworked admin panel** — a collapsible sidebar, an overview page, the page builder as a structure list with Undo, and ON/OFF switches grouped by meaning +- **Four more languages and a CDN mode** — French, Spanish, Italian and Dutch, and `CDN_URL` to serve photos through a pull CDN +- **Two security fixes** — draft and password-protected journal entries used as subpage essays, and unescaped Immich descriptions ([advisories](https://github.com/ralksta/immich-folio/security/advisories)) -**Upgrade note:** visitors' proofing favourites from before v0.17.0 are not carried over. Nothing else to migrate. +**Upgrade note:** a custom CSP or proxy allowlist must now allow `tile.openstreetmap.org` for map tiles, and `theme.accent` must be a hex colour. Nothing else to migrate. Security fixes ship in normal releases, so **running the latest release is the recommended baseline**. @@ -373,7 +379,14 @@ easier to live with. readable in light mode. Also brought links to journal quote attributions. In v0.17.0, built the ZIP download: a whole album, or just the photos a client picked, as one archive of the originals — streamed, so even a large shoot - arrives complete. + arrives complete. In v0.18.0, kept accent-coloured buttons readable when the + accent is light, and made a mistyped accent fall back to the theme instead of + turning every button invisible. +- **[@RichKidsDev](https://github.com/RichKidsDev)** — noticed that the + Impressum still cited the TMG, which the DDG replaced in 2024. That report + turned into a proper overhaul in v0.18.0: the right law by default, a heading + you can change for other countries, clickable contacts and a contact form as + a second channel. - **[@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 diff --git a/app/[...path]/EssayView.tsx b/app/[...path]/EssayView.tsx index 8a003e4..5a3ec6a 100644 --- a/app/[...path]/EssayView.tsx +++ b/app/[...path]/EssayView.tsx @@ -9,6 +9,7 @@ import { FadeIn } from '@/components/FadeIn'; import { LeafletMap, escapeHtml } from '@/components/LeafletMap'; import type { ParsedEssay, EssayBlock } from '@/lib/essay'; import { renderInlineMarkdown } from '@/lib/essay'; +import { essayPhotoSequence } from '@/lib/essaySequence'; import type { PhotoItem } from './PhotoGrid'; import './essay.css'; import { useDictionary } from '@/components/I18nProvider'; @@ -60,19 +61,35 @@ function EssayViewContent({ ? assetMap.get(essay.frontmatter.coverAssetId) : undefined; + // The lightbox walks only the photos this essay shows, in reading order — + // not every photo of the subpage's albums (`assets`). + const sequence = useMemo( + () => + essayPhotoSequence( + essay.blocks, + essay.frontmatter.coverAssetId, + (key) => assetMap.get(key)?.item, + ), + [essay.blocks, essay.frontmatter.coverAssetId, assetMap], + ); + const openPhoto = (item: PhotoItem) => { + const at = sequence.findIndex((p) => p.id === item.id); + if (at !== -1) setLightboxIndex(at); + }; + /** * One photo tile of a pair or a grid: sized by its real ratio (see * .essay-pair-grid), opens the lightbox, carries the proofing heart. The pair * used to spell this out a second time next to the photo case; a third copy * for the grid is where that stops. */ - const renderTile = ({ item, index }: { item: PhotoItem; index: number }, key: number) => { + const renderTile = ({ item }: { item: PhotoItem }, key: number) => { const isFav = proofing ? proofing.isFavorite(item.id) : false; return (
setLightboxIndex(index)} + onClick={() => openPhoto(item)} style={{ ...(item.dominantColor ? { backgroundColor: item.dominantColor } : {}), flexGrow: item.aspectRatio ?? 1.5, @@ -166,7 +183,7 @@ function EssayViewContent({ case 'photo': { const resolved = assetMap.get(block.assetId); if (!resolved) return null; - const { item, index } = resolved; + const { item } = resolved; const isFav = proofing ? proofing.isFavorite(item.id) : false; return ( @@ -174,7 +191,7 @@ function EssayViewContent({
setLightboxIndex(index)} + onClick={() => openPhoto(item)} style={{ ...(item.dominantColor ? { backgroundColor: item.dominantColor } : {}), ...(item.aspectRatio ? { aspectRatio: `${item.aspectRatio}` } : {}), @@ -388,7 +405,7 @@ function EssayViewContent({
setLightboxIndex(coverAsset.index)} + onClick={() => openPhoto(coverAsset.item)} style={{ ...(coverAsset.item.dominantColor ? { backgroundColor: coverAsset.item.dominantColor } @@ -434,26 +451,11 @@ function EssayViewContent({ backdropFilter: 'blur(8px)', }} > - + {/* A count, not the album grid's filter toggle: a story's layout is + authored, so there is nothing for "show favourites" to narrow. */} + + {t.proofing.selected(proofing.favorites.size)} + + ); +} + +function setup() { + const results: boolean[] = []; + render( + + results.push(ok)} /> + , + ); + fireEvent.click(screen.getByText('go')); + return results; +} + +describe('ConfirmDialog', () => { + it('asks in an alertdialog named by its title', () => { + setup(); + const dialog = screen.getByRole('alertdialog', { name: 'Delete it?' }); + expect(dialog.textContent).toContain('Gone for good.'); + }); + + it('resolves true on confirm and closes', async () => { + const results = setup(); + fireEvent.click(screen.getByRole('button', { name: 'Delete' })); + await waitFor(() => expect(results).toEqual([true])); + expect(screen.queryByRole('alertdialog')).toBeNull(); + }); + + it('resolves false on cancel, and on Escape', async () => { + const results = setup(); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(results).toEqual([false])); + fireEvent.click(screen.getByText('go')); + fireEvent.keyDown(document, { key: 'Escape' }); + await waitFor(() => expect(results).toEqual([false, false])); + }); +}); diff --git a/app/admin/__tests__/Notifications.test.tsx b/app/admin/__tests__/Notifications.test.tsx new file mode 100644 index 0000000..dda20f7 --- /dev/null +++ b/app/admin/__tests__/Notifications.test.tsx @@ -0,0 +1,74 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { render, screen, cleanup, fireEvent, act } from '@testing-library/react'; +import { NotificationProvider, useNotify } from '../components/Notifications'; + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +function Trigger({ kind, message }: { kind: 'success' | 'error'; message: string }) { + const notify = useNotify(); + return ; +} + +/** #600: what replaced alert() in the admin. */ +describe('admin notifications', () => { + it('announces an error assertively and keeps it until dismissed', () => { + vi.useFakeTimers(); + render( + + + , + ); + fireEvent.click(screen.getByText('go')); + + expect(screen.getByRole('alert').textContent).toContain('Save failed'); + act(() => vi.advanceTimersByTime(60_000)); + expect(screen.queryByText('Save failed')).not.toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })); + expect(screen.queryByText('Save failed')).toBeNull(); + }); + + it('announces a success politely and lets it go on its own', () => { + vi.useFakeTimers(); + render( + + + , + ); + fireEvent.click(screen.getByText('go')); + + expect(screen.getByRole('status').textContent).toContain('Saved'); + act(() => vi.advanceTimersByTime(5000)); + expect(screen.queryByText('Saved')).toBeNull(); + }); + + it('does nothing outside the provider instead of throwing', () => { + render(); + expect(() => fireEvent.click(screen.getByText('go'))).not.toThrow(); + }); + + it('runs an action once and closes the notification', () => { + let runs = 0; + function WithAction() { + const notify = useNotify(); + return ( + + ); + } + render( + + + , + ); + fireEvent.click(screen.getByText('go')); + fireEvent.click(screen.getByRole('button', { name: 'Undo' })); + expect(runs).toBe(1); + expect(screen.queryByText('Removed')).toBeNull(); + }); +}); diff --git a/app/admin/__tests__/SaveBar.test.tsx b/app/admin/__tests__/SaveBar.test.tsx index 24973c2..3ef1cc9 100644 --- a/app/admin/__tests__/SaveBar.test.tsx +++ b/app/admin/__tests__/SaveBar.test.tsx @@ -1,17 +1,16 @@ // @vitest-environment jsdom import { describe, it, expect, vi, afterEach } from 'vitest'; import { render, screen, cleanup, fireEvent } from '@testing-library/react'; -import SaveBar from '../components/SaveBar'; +import SaveBar, { type SaveStatus } from '../components/SaveBar'; /** * The first component test in the project, and deliberately a small one: it is * here to show the shape (`// @vitest-environment jsdom` on the first line, * render, assert, cleanup) as much as to cover SaveBar. * - * SaveBar earns it though. It decides whether an outcome reads as success or - * failure by testing whether the message happens to start with "Error" — #600 - * replaces that with a typed state, and this pins the behaviour so the swap can - * be verified rather than eyeballed. + * SaveBar earns it though. It used to decide whether an outcome read as + * success or failure by testing whether the message happened to start with + * "Error"; #600 replaced that with a typed state, pinned below. */ afterEach(cleanup); @@ -19,7 +18,7 @@ afterEach(cleanup); const props = { dirty: false, saving: false, - saveMessage: '', + status: null as SaveStatus, onSave: () => {}, label: 'Save', }; @@ -40,7 +39,9 @@ describe('SaveBar', () => { }); it('only offers saving when there is something to save', () => { - const { rerender } = render(); + const { rerender } = render( + , + ); expect(screen.getByRole('button', { name: 'Save' })).toHaveProperty('disabled', true); rerender(); @@ -63,18 +64,21 @@ describe('SaveBar', () => { expect(onSave).toHaveBeenCalledOnce(); }); - it('tells a failure from a success by the message prefix', () => { - // Pinned as it is, not as it should be: the prefix check is the thing #600 - // replaces. A message that fails without starting with "Error" is styled as - // a success today, which is precisely the argument for a typed state. - const { rerender } = render(); + it('styles the outcome by its kind, not by its wording', () => { + // #600: the bar used to call a message an error when it started with + // "Error", so this third message was shown as a success. + const { rerender } = render( + , + ); expect(screen.getByText('Error: Failed to save').className).toContain('error'); - rerender(); + rerender(); expect(screen.getByText('Saved!').className).toContain('success'); - rerender(); - expect(screen.getByText('Could not reach the server').className).toContain('success'); + rerender( + , + ); + expect(screen.getByText('Could not reach the server').className).toContain('error'); }); it('offers the preview link only where it belongs', () => { diff --git a/app/admin/__tests__/contentRestored.test.tsx b/app/admin/__tests__/contentRestored.test.tsx new file mode 100644 index 0000000..065a5dd --- /dev/null +++ b/app/admin/__tests__/contentRestored.test.tsx @@ -0,0 +1,35 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { renderHook, act, cleanup } from '@testing-library/react'; +import { reportContentRestored, useContentRestored } from '../components/contentRestored'; + +afterEach(() => cleanup()); + +describe('useContentRestored', () => { + it('hands each restore to the mounted editors', () => { + const handler = vi.fn(); + renderHook(() => useContentRestored(handler)); + act(() => reportContentRestored({ target: 'journal', slug: 'iceland' })); + expect(handler).toHaveBeenCalledWith({ target: 'journal', slug: 'iceland' }); + }); + + it('calls the latest handler, not the one from the first render', () => { + const first = vi.fn(); + const second = vi.fn(); + const { rerender } = renderHook(({ fn }) => useContentRestored(fn), { + initialProps: { fn: first }, + }); + rerender({ fn: second }); + act(() => reportContentRestored({ target: 'gallery' })); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledOnce(); + }); + + it('stops listening once the editor unmounts', () => { + const handler = vi.fn(); + const { unmount } = renderHook(() => useContentRestored(handler)); + unmount(); + act(() => reportContentRestored({ target: 'settings' })); + expect(handler).not.toHaveBeenCalled(); + }); +}); diff --git a/app/admin/__tests__/gallery-ops.test.ts b/app/admin/__tests__/gallery-ops.test.ts new file mode 100644 index 0000000..b98874d --- /dev/null +++ b/app/admin/__tests__/gallery-ops.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from 'vitest'; +import * as ops from '../components/page-builder/galleryOps'; +import type { GalleryState } from '../components/page-builder/galleryYaml'; + +/** #608: the page builder's edits, out of the component and under test. */ + +const state = (): GalleryState => ({ + hero: ['h1', 'h2', 'h3'], + albums: [{ id: 'a1' }, { id: 'a2' }], + subpages: [ + { name: 'Japan', albums: [{ id: 'j1' }, { id: 'j2' }] }, + { + name: 'Trips', + enabled: false, + albums: [], + sections: [{ title: 'North', albums: [{ id: 'n1' }] }], + }, + { name: 'Korea', albums: [{ id: 'k1' }] }, + ], +}); + +describe('album lists by address', () => { + it('adds to the standalone list, a subpage and a section', () => { + let g = ops.addAlbum(state(), { type: 'standalone' }, { id: 'x' }); + expect(g.albums.map((a) => a.id)).toEqual(['a1', 'a2', 'x']); + g = ops.addAlbum(g, { type: 'subpage', subpageIndex: 0 }, { id: 'y' }); + expect(g.subpages[0].albums.map((a) => a.id)).toEqual(['j1', 'j2', 'y']); + g = ops.addAlbum(g, { type: 'section', subpageIndex: 1, sectionIndex: 0 }, { id: 'z' }); + expect(g.subpages[1].sections![0].albums.map((a) => a.id)).toEqual(['n1', 'z']); + }); + + it('updates, removes and moves one entry and leaves its neighbours alone', () => { + const at = { type: 'subpage' as const, subpageIndex: 0 }; + let g = ops.updateAlbum(state(), at, 1, { title: 'Tokyo', sort: 'manual' }); + expect(g.subpages[0].albums).toEqual([ + { id: 'j1' }, + { id: 'j2', title: 'Tokyo', sort: 'manual' }, + ]); + g = ops.moveAlbum(g, at, 1, 0); + expect(g.subpages[0].albums.map((a) => a.id)).toEqual(['j2', 'j1']); + g = ops.removeAlbum(g, at, 0); + expect(g.subpages[0].albums.map((a) => a.id)).toEqual(['j1']); + }); + + it('never mutates the state it was given', () => { + const before = state(); + const copy = JSON.parse(JSON.stringify(before)); + ops.removeAlbum(before, { type: 'section', subpageIndex: 1, sectionIndex: 0 }, 0); + ops.updateSubpage(before, 0, { title: 'x' }); + ops.moveHero(before, 0, 2); + expect(before).toEqual(copy); + }); + + it('leaves the state as it is for an address that no longer exists', () => { + const g = state(); + expect(ops.addAlbum(g, { type: 'subpage', subpageIndex: 9 }, { id: 'x' })).toBe(g); + expect(ops.removeAlbum(g, { type: 'section', subpageIndex: 0, sectionIndex: 0 }, 0)).toBe(g); + expect(ops.albumsAt(g, { type: 'section', subpageIndex: 1, sectionIndex: 5 })).toBeUndefined(); + }); +}); + +describe('hero, subpages and sections', () => { + it('adds, moves and removes hero photos', () => { + let g = ops.addHero(state(), 'h4'); + g = ops.moveHero(g, 3, 0); + expect(ops.removeHero(g, 1).hero).toEqual(['h4', 'h2', 'h3']); + }); + + it('names a new subpage after its position and starts it empty', () => { + const g = ops.addSubpage(state()); + expect(g.subpages[3]).toEqual({ name: 'New Page 4', albums: [], sections: undefined }); + }); + + it('adds, updates and removes sections', () => { + let g = ops.addSection(state(), 0); + expect(g.subpages[0].sections).toEqual([{ title: 'New Section', albums: [] }]); + g = ops.updateSection(g, 0, 0, { title: 'Tokyo', description: 'Night' }); + expect(g.subpages[0].sections![0]).toEqual({ + title: 'Tokyo', + description: 'Night', + albums: [], + }); + expect(ops.removeSection(g, 0, 0).subpages[0].sections).toEqual([]); + }); +}); + +describe('reading', () => { + it('numbers subpages over the enabled ones only', () => { + const g = state(); + expect(ops.enabledPosition(g, 0)).toBe(1); + expect(ops.enabledPosition(g, 1)).toBeUndefined(); + expect(ops.enabledPosition(g, 2)).toBe(2); + }); + + it('collects every album in use, sections included', () => { + expect([...ops.usedAlbumIds(state())].sort()).toEqual(['a1', 'a2', 'j1', 'j2', 'k1', 'n1']); + }); + + /* + * The open subpage sheet follows its page through a drag: the page itself, + * or a neighbour moving across it, shifts its index. + */ + it.each([ + [null, 0, 2, null], + [1, 1, 2, 2], + [1, 0, 2, 0], + [1, 2, 0, 2], + [0, 1, 2, 0], + ])('keeps sheet %s on its page when %s moves to %s', (open, from, to, expected) => { + expect(ops.followMovedIndex(open, from, to)).toBe(expected); + }); +}); diff --git a/app/admin/__tests__/gallery-yaml.test.ts b/app/admin/__tests__/gallery-yaml.test.ts new file mode 100644 index 0000000..95f402d --- /dev/null +++ b/app/admin/__tests__/gallery-yaml.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect } from 'vitest'; +import { parseGalleryYaml, serializeGallery } from '../components/page-builder/galleryYaml'; +import type { GalleryYaml } from '@/lib/config/schema'; + +/** + * #608: gallery.yaml ⇄ the page builder's model. This is what decides what the + * builder writes on save, and until it left PageBuilder.tsx nothing tested it. + * Album entries themselves are covered in album-entries-round-trip.test.ts. + */ + +const A = '11111111-1111-1111-1111-111111111111'; +const B = '22222222-2222-2222-2222-222222222222'; + +/** Load and save once, the way the builder does without any edit in between. */ +const roundTrip = (yaml: Record) => serializeGallery(parseGalleryYaml(yaml)); + +describe('gallery.yaml round trip', () => { + it('keeps hero photos, standalone albums and a plain subpage', () => { + const yaml = { + hero: ['h1', 'h2'], + albums: [A], + subpages: [{ name: 'Japan', albums: [B] }], + }; + expect(roundTrip(yaml)).toEqual(yaml); + }); + + it('turns a single hero string into a list', () => { + expect(roundTrip({ hero: 'h1' })).toEqual({ hero: ['h1'] }); + }); + + it('leaves empty groups out instead of writing empty lists', () => { + expect(roundTrip({ hero: [], albums: [], subpages: [] })).toEqual({}); + }); + + it('keeps every subpage field the builder knows', () => { + const sp = { + name: 'Clients', + title: 'For clients', + subtitle: 'Handover', + password: 'scrypt:aa:bb', + enabled: false, + hidden: true, + essayText: 'Text', + essayFile: 'story', + grid: { columns: 4, layout: 'justified' }, + coverGrid: { columns: 2, gap: 8 }, + location: 'city', + albums: [A], + }; + expect(roundTrip({ subpages: [sp] })).toEqual({ subpages: [sp] }); + }); + + it('writes enabled and hidden only when they differ from the default', () => { + const out = roundTrip({ subpages: [{ name: 'P', enabled: true, hidden: false, albums: [A] }] }); + expect(out).toEqual({ subpages: [{ name: 'P', albums: [A] }] }); + }); + + it('keeps sections, and page-level albums next to them only when there are any', () => { + const withSections = { + subpages: [ + { + name: 'Trips', + sections: [ + { title: 'North', description: 'Cold', albums: [A] }, + { title: 'South', albums: [B] }, + ], + }, + ], + }; + expect(roundTrip(withSections)).toEqual(withSections); + + const both = { + subpages: [{ name: 'Trips', albums: [B], sections: [{ title: 'North', albums: [A] }] }], + }; + expect(roundTrip(both)).toEqual({ + subpages: [{ name: 'Trips', sections: [{ title: 'North', albums: [A] }], albums: [B] }], + }); + }); + + it('converts the older name → albums map into the list the builder writes', () => { + expect( + roundTrip({ subpages: { Japan: [A], Korea: { title: 'South Korea', albums: [B] } } }), + ).toEqual({ + subpages: [ + { name: 'Japan', albums: [A] }, + { name: 'Korea', title: 'South Korea', albums: [B] }, + ], + }); + }); + + /* + * #523 split `grid` into photo grid and cover grid. A pre-#523 page keeps + * its cover columns and gap by being seeded from `grid` on load, so the + * first save writes the split out explicitly. + */ + it('seeds the cover grid from a pre-#523 grid', () => { + const out = roundTrip({ + subpages: [{ name: 'Old', grid: { columns: 3, gap: 10, layout: 'masonry' }, albums: [A] }], + }); + expect(out).toEqual({ + subpages: [ + { + name: 'Old', + grid: { columns: 3, gap: 10, layout: 'masonry' }, + coverGrid: { columns: 3, gap: 10 }, + albums: [A], + }, + ], + }); + }); + + it('is stable: a second round trip changes nothing', () => { + const once = roundTrip({ + hero: 'h1', + subpages: { Japan: { grid: { columns: 2 }, albums: [A] } }, + }); + expect(roundTrip(once)).toEqual(once); + }); +}); + +/** + * The builder rewrites gallery.yaml wholesale on every save, so a subpage key + * it does not carry is deleted, not ignored. `proofing` was lost this way: a + * page switched off for client proofing by hand came back on after any + * unrelated save in the admin. Same lesson as #614 for album entries. + * + * The type annotation is the enforcement: every key the schema allows on a + * subpage has to be listed here, so the next one added and forgotten in + * galleryYaml.ts fails this test instead of vanishing from someone's file. + */ +type SubpageYaml = Extract, unknown[]>[number]; + +const everySubpageKey: Required = { + name: 'Clients', + title: 'For clients', + subtitle: 'Handover', + albums: [A], + sections: [{ title: 'North', description: 'Cold', albums: [B] }], + password: 'scrypt:aa:bb', + proofing: false, + essayFile: 'story', + essayText: 'Text', + enabled: false, + hidden: true, + location: 'city', + grid: { columns: 4, gap: 6, aspectRatio: '3/2', layout: 'justified' }, + coverGrid: { columns: 2, gap: 8, aspectRatio: '1', layout: 'masonry' }, +}; + +describe('subpage keys', () => { + it('survives a round trip with every key the schema allows', () => { + expect(roundTrip({ subpages: [everySubpageKey] })).toEqual({ subpages: [everySubpageKey] }); + }); + + it('keeps proofing in either direction, and writes nothing when unset', () => { + const on = { name: 'P', proofing: true, albums: [A] }; + expect(roundTrip({ subpages: [on] })).toEqual({ subpages: [on] }); + expect(roundTrip({ subpages: [{ name: 'P', albums: [A] }] })).toEqual({ + subpages: [{ name: 'P', albums: [A] }], + }); + }); +}); diff --git a/app/admin/__tests__/useAdminFetch.test.tsx b/app/admin/__tests__/useAdminFetch.test.tsx new file mode 100644 index 0000000..0f0418b --- /dev/null +++ b/app/admin/__tests__/useAdminFetch.test.tsx @@ -0,0 +1,80 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { renderHook, waitFor, act, cleanup } from '@testing-library/react'; +import { useAdminFetch } from '../components/useAdminFetch'; +import { SESSION_EXPIRED_EVENT } from '../components/sessionExpiry'; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +const respond = (status: number, body: unknown) => + vi.fn(async () => new Response(JSON.stringify(body), { status })); + +/** + * #609: every admin screen used to hand-roll this, with three error + * conventions between them, one of them a silent catch. These pin the one + * convention that replaced them. + */ +describe('useAdminFetch', () => { + it('loads data and clears loading', async () => { + vi.stubGlobal('fetch', respond(200, { n: 1 })); + const { result } = renderHook(() => useAdminFetch<{ n: number }>('/api/admin/x')); + expect(result.current.loading).toBe(true); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.data).toEqual({ n: 1 }); + expect(result.current.error).toBeNull(); + }); + + it('turns an HTTP failure into a message, with the server error when there is one', async () => { + vi.stubGlobal('fetch', respond(500, { error: 'disk full' })); + const { result } = renderHook(() => useAdminFetch('/api/admin/x')); + await waitFor(() => expect(result.current.error).toBe('Could not load (HTTP 500): disk full.')); + expect(result.current.data).toBeNull(); + }); + + it('reports a network failure instead of swallowing it', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new TypeError('network'); + }), + ); + const { result } = renderHook(() => useAdminFetch('/api/admin/x')); + await waitFor(() => expect(result.current.error).toBe('Could not reach the server.')); + }); + + it('raises the session-expired event on a 401', async () => { + vi.stubGlobal('fetch', respond(401, {})); + const expired = vi.fn(); + window.addEventListener(SESSION_EXPIRED_EVENT, expired); + const { result } = renderHook(() => useAdminFetch('/api/admin/x')); + await waitFor(() => expect(result.current.error).toContain('session expired')); + expect(expired).toHaveBeenCalledOnce(); + window.removeEventListener(SESSION_EXPIRED_EVENT, expired); + }); + + it('fetches nothing for a null url, and again on reload', async () => { + const fetchMock = respond(200, { n: 1 }); + vi.stubGlobal('fetch', fetchMock); + const { result, rerender } = renderHook(({ url }) => useAdminFetch(url), { + initialProps: { url: null as string | null }, + }); + expect(result.current.loading).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + + rerender({ url: '/api/admin/x' }); + await waitFor(() => expect(result.current.data).toEqual({ n: 1 })); + act(() => result.current.reload()); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + }); + + it('applies a local edit without a round trip', async () => { + vi.stubGlobal('fetch', respond(200, { items: [1, 2] })); + const { result } = renderHook(() => useAdminFetch<{ items: number[] }>('/api/admin/x')); + await waitFor(() => expect(result.current.data).not.toBeNull()); + act(() => result.current.mutate((d) => ({ items: d.items.filter((i) => i !== 1) }))); + expect(result.current.data).toEqual({ items: [2] }); + }); +}); diff --git a/app/admin/__tests__/useDraft.test.tsx b/app/admin/__tests__/useDraft.test.tsx index 5aceecd..0c94ee8 100644 --- a/app/admin/__tests__/useDraft.test.tsx +++ b/app/admin/__tests__/useDraft.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { describe, it, expect, afterEach, vi } from 'vitest'; import { renderHook, act, render, screen, fireEvent, cleanup } from '@testing-library/react'; -import { useDraft, readDraft } from '../components/useDraft'; +import { useDraft, readDraft, writeDraft, holdsPlaintextPassword } from '../components/useDraft'; import DraftNotice from '../components/DraftNotice'; /** @@ -70,6 +70,25 @@ describe('useDraft', () => { expect(second.result.current.status).toBe('restored'); }); + it('keeps a held-back draft while the editor shows the server state', () => { + const first = mount(); + act(() => void first.result.current.load('server-v1')); + first.rerender({ value: 'my edit', dirty: true }); + first.unmount(); + + // The editor applies the server state after a conflict and is not dirty. + // That render used to clear the draft "Restore my changes" then reads. + const second = mount(); + act(() => void second.result.current.load('server-v2')); + second.rerender({ value: 'server v2', dirty: false }); + + let taken: string | null = null; + act(() => { + taken = second.result.current.takeConflicting(); + }); + expect(taken).toBe('my edit'); + }); + it('forgets the draft once saved, and bases new edits on the saved state', () => { const first = mount(); act(() => void first.result.current.load('server-v1')); @@ -155,3 +174,26 @@ describe('DraftNotice', () => { expect(h.onDiscard).toHaveBeenCalled(); }); }); + +describe('drafts never keep a typed password', () => { + it('detects plaintext passwords in settings, gallery and journal drafts', () => { + expect(holdsPlaintextPassword({ sitePassword: 'hunter2' })).toBe(true); + expect(holdsPlaintextPassword({ subpages: [{ slug: 'x', password: 'pw' }] })).toBe(true); + expect(holdsPlaintextPassword('---\ntitle: A\npassword: pw\n---\nBody')).toBe(true); + }); + + it('lets hashes and empty values through', () => { + expect(holdsPlaintextPassword({ sitePassword: 'scrypt:aa:bb' })).toBe(false); + expect(holdsPlaintextPassword({ sitePassword: '' })).toBe(false); + expect(holdsPlaintextPassword('---\npassword: scrypt:aa:bb\n---\n')).toBe(false); + expect(holdsPlaintextPassword('No frontmatter, password: pw')).toBe(false); + }); + + it('drops the draft instead of writing the password to sessionStorage', () => { + writeDraft('settings', { base: 'b', value: { title: 'x' } }); + expect(readDraft('settings')).not.toBeNull(); + writeDraft('settings', { base: 'b', value: { title: 'x', sitePassword: 'hunter2' } }); + expect(readDraft('settings')).toBeNull(); + expect(JSON.stringify(window.sessionStorage)).not.toContain('hunter2'); + }); +}); diff --git a/app/admin/admin.css b/app/admin/admin.css index 03f9507..2800223 100644 --- a/app/admin/admin.css +++ b/app/admin/admin.css @@ -32,3 +32,6 @@ @import './styles/asset-order.css'; @import './styles/favicon.css'; @import './styles/diagnostics.css'; +@import './styles/messages.css'; +@import './styles/notifications.css'; +@import './styles/redesign.css'; diff --git a/app/admin/components/AdminDashboard.tsx b/app/admin/components/AdminDashboard.tsx index 79401f2..1f51912 100644 --- a/app/admin/components/AdminDashboard.tsx +++ b/app/admin/components/AdminDashboard.tsx @@ -8,6 +8,8 @@ import type { DoctorLevel } from '@/lib/admin/doctor'; import { DOCTOR_LEVEL_EVENT, systemHealth } from './systemHealth'; import { reportIfSessionExpired } from './sessionExpiry'; import * as Icons from './Icons'; +import { useNotify } from './Notifications'; +import { adminGet } from './useAdminFetch'; interface Props { onLogout: () => void; @@ -15,16 +17,100 @@ interface Props { children: ReactNode; } -const TABS = [ - { label: 'Pages', href: '/admin/pages', match: /^\/admin\/pages$/ }, - { label: 'Journal', href: '/admin/journal', match: /^\/admin\/journal/ }, - { label: 'Settings', href: '/admin/settings', match: /^\/admin\/settings/ }, - { label: 'Analytics', href: '/admin/analytics', match: /^\/admin\/analytics/ }, - { label: 'Diagnostics', href: '/admin/diagnostics', match: /^\/admin\/diagnostics/ }, - { label: 'Help', href: '/admin/help', match: /^\/admin\/help/ }, +/** + * The sidebar, grouped by what the owner is doing (docs/admin-ux-concept.md). + * `match` decides the active entry; Overview only matches /admin itself. + */ +const NAV: { + group?: string; + items: { + label: string; + href: string; + match: RegExp; + icon: ReactNode; + badge?: 'messages' | 'health'; + }[]; +}[] = [ + { + items: [ + { + label: 'Overview', + href: '/admin', + match: /^\/admin\/?$/, + icon: , + }, + ], + }, + { + group: 'Content', + items: [ + { + label: 'Pages', + href: '/admin/pages', + match: /^\/admin\/pages$/, + icon: , + }, + { + label: 'Journal', + href: '/admin/journal', + match: /^\/admin\/journal/, + icon: , + }, + ], + }, + { + group: 'Visitors', + items: [ + { + label: 'Messages', + href: '/admin/messages', + match: /^\/admin\/messages/, + icon: , + badge: 'messages', + }, + { + label: 'Analytics', + href: '/admin/analytics', + match: /^\/admin\/analytics/, + icon: , + }, + ], + }, + { + group: 'Site', + items: [ + { + label: 'Settings', + href: '/admin/settings', + match: /^\/admin\/settings/, + icon: , + }, + ], + }, + { + group: 'System', + items: [ + { + label: 'Diagnostics', + href: '/admin/diagnostics', + match: /^\/admin\/diagnostics/, + icon: , + badge: 'health', + }, + { + label: 'Help', + href: '/admin/help', + match: /^\/admin\/help/, + icon: , + }, + ], + }, ]; +const SIDEBAR_KEY = 'folio_admin_sidebar'; + export default function AdminDashboard({ onLogout, children }: Props) { + const notify = useNotify(); const pathname = usePathname(); const [saving, setSaving] = useState(false); @@ -40,6 +126,29 @@ export default function AdminDashboard({ onLogout, children }: Props) { // an expired session or a 500 rendered exactly like a real outage — every // indicator flipped to its alarming value at once (#341). const [statusError, setStatusError] = useState(false); + /** + * Icons-only sidebar, remembered per browser. The dashboard only renders + * after the client-side session check, so reading storage in the initial + * state cannot mismatch a server render. + */ + const [collapsed, setCollapsed] = useState(() => { + try { + return window.localStorage.getItem(SIDEBAR_KEY) === 'collapsed'; + } catch { + return false; + } + }); + const toggleCollapsed = () => + setCollapsed((c) => { + try { + window.localStorage.setItem(SIDEBAR_KEY, c ? 'expanded' : 'collapsed'); + } catch { + // Private mode or blocked storage: the toggle still works for this visit. + } + return !c; + }); + /** Unread contact messages, for the count next to Messages. */ + const [unread, setUnread] = useState(0); async function handleLogout() { await fetch('/api/admin/auth', { method: 'DELETE' }); @@ -54,7 +163,7 @@ export default function AdminDashboard({ onLogout, children }: Props) { // result was never checked at all (#596). if (!res.ok) { if (!reportIfSessionExpired(res)) { - alert(`Reload failed (HTTP ${res.status}).`); + notify('error', `Reload failed (HTTP ${res.status}).`); } return; } @@ -112,6 +221,18 @@ export default function AdminDashboard({ onLogout, children }: Props) { fetchDoctor(); }, [fetchDoctor]); + // Re-counted on every navigation: reading a message in the inbox and moving + // on should clear the count without a reload. + useEffect(() => { + let live = true; + adminGet<{ messages: { read: boolean }[] }>('/api/admin/messages').then((r) => { + if (live && r.data) setUnread(r.data.messages.filter((m) => !m.read).length); + }); + return () => { + live = false; + }; + }, [pathname]); + // The diagnostics page runs the doctor itself; take its answer rather than // asking Immich the same three questions again. useEffect(() => { @@ -146,36 +267,81 @@ export default function AdminDashboard({ onLogout, children }: Props) { const missingCredentials = status?.setup?.credentials === 'missing'; return ( -
-
-
-

Immich Folio

- -
-
+
+
+ -
{children}
+
{children}
void; + /** With data already on screen, a reload shows no spinner over it. */ + hasData?: boolean; +}) { + if (error) { + return ( +
+

{error}

+ +
+ ); + } + if (loading && !hasData) { + return ( +
+
+
+ ); + } + return null; +} diff --git a/app/admin/components/AdminOverview.tsx b/app/admin/components/AdminOverview.tsx new file mode 100644 index 0000000..919f0de --- /dev/null +++ b/app/admin/components/AdminOverview.tsx @@ -0,0 +1,229 @@ +'use client'; + +/** + * /admin: the first screen after signing in (docs/admin-ux-concept.md). + * + * Says what needs attention before it is asked for (unread messages, doctor + * findings), then the site at a glance. Everything on it links to the screen + * where it is acted on. It only reads the existing admin endpoints; a failed + * one leaves its part out rather than failing the page. + */ + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import * as Icons from './Icons'; +import { adminGet } from './useAdminFetch'; +import type { DoctorFinding, DoctorLevel } from '@/lib/admin/doctor'; +import { DOCTOR_LEVEL_EVENT } from './systemHealth'; + +interface OverviewData { + gallery: { hero?: unknown; subpages?: Array<{ enabled?: boolean }> } | null; + journal: Array<{ frontmatter: { draft?: boolean } }> | null; + messages: Array<{ read: boolean; name: string }> | null; + analytics: { days?: Record } | null; + doctor: { level: DoctorLevel; findings: DoctorFinding[] } | null; +} + +/** Where a doctor finding is fixed; mirrors FIXES in DiagnosticsView. */ +const FIX_ROUTE: Record = { + legal: '/admin/settings/legal', + contact: '/admin/settings/legal', + privacy: '/admin/settings/legal', + passwords: '/admin/settings/security', + 'album-ids': '/admin/pages', + 'albums-shared': '/admin/pages', +}; + +function viewsLastDays( + days: Record | undefined, + n: number, +): number { + if (!days) return 0; + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - (n - 1)); + const from = cutoff.toISOString().slice(0, 10); + return Object.entries(days) + .filter(([date]) => date >= from) + .reduce((sum, [, d]) => sum + (d.pageviews || 0), 0); +} + +function greeting(): string { + const h = new Date().getHours(); + if (h < 5) return 'Good night'; + if (h < 12) return 'Good morning'; + if (h < 18) return 'Good afternoon'; + return 'Good evening'; +} + +export default function AdminOverview() { + const [data, setData] = useState(null); + + useEffect(() => { + let live = true; + Promise.all([ + adminGet<{ gallery: OverviewData['gallery'] }>('/api/admin/gallery'), + adminGet<{ entries: NonNullable }>('/api/admin/journal'), + adminGet<{ messages: NonNullable }>('/api/admin/messages'), + adminGet>('/api/admin/analytics'), + adminGet>('/api/admin/doctor'), + ]).then(([gallery, journal, messages, analytics, doctor]) => { + if (!live) return; + setData({ + gallery: gallery.data?.gallery ?? null, + journal: journal.data?.entries ?? null, + messages: messages.data?.messages ?? null, + analytics: analytics.data ?? null, + doctor: doctor.data ?? null, + }); + // The sidebar badge takes the level from here instead of asking again. + if (doctor.data?.level) { + window.dispatchEvent(new CustomEvent(DOCTOR_LEVEL_EVENT, { detail: doctor.data.level })); + } + }); + return () => { + live = false; + }; + }, []); + + const subpages = data?.gallery?.subpages ?? []; + const published = subpages.filter((sp) => sp.enabled !== false).length; + const heroRaw = data?.gallery?.hero; + const heroCount = Array.isArray(heroRaw) ? heroRaw.length : heroRaw ? 1 : 0; + const entries = data?.journal ?? []; + const drafts = entries.filter((e) => e.frontmatter.draft).length; + const unread = (data?.messages ?? []).filter((m) => !m.read); + const views7 = viewsLastDays(data?.analytics?.days, 7); + const findings = (data?.doctor?.findings ?? []).filter((f) => f.level !== 'ok'); + + const attention: Array<{ + tone: 'accent' | 'warn' | 'error'; + title: string; + detail?: string; + href: string; + }> = []; + if (unread.length) { + attention.push({ + tone: 'accent', + title: `${unread.length} unread message${unread.length === 1 ? '' : 's'}`, + detail: `From ${[...new Set(unread.map((m) => m.name))].slice(0, 3).join(', ')}`, + href: '/admin/messages', + }); + } + for (const f of findings) { + attention.push({ + tone: f.level === 'error' ? 'error' : 'warn', + title: f.title, + detail: f.detail, + href: FIX_ROUTE[f.id] ?? '/admin/diagnostics', + }); + } + + return ( +
+
+
+

Overview

+

{greeting()}.

+

+ {data + ? attention.length + ? `${attention.length} thing${attention.length === 1 ? '' : 's'} could use a look.` + : 'Everything is in order.' + : 'Checking the site…'} +

+
+
+ + View site + + + Edit pages + +
+
+ +
+

+ Needs attention +

+ {!data ? ( +
+ ) : attention.length ? ( +
+ {attention.map((a, i) => ( + +
+ ) : ( +

Nothing waiting. New messages and problems show up here.

+ )} +
+ +
+

+ At a glance +

+
+ + Pages + {data ? published : '–'} + + {subpages.length - published > 0 + ? `${subpages.length - published} switched off` + : 'all published'} + + + + Hero photos + {data ? heroCount : '–'} + on the home page + + + Journal + {data ? entries.length : '–'} + + {drafts ? `${drafts} draft${drafts === 1 ? '' : 's'}` : 'no drafts'} + + + + Views · 7 days + {data ? views7.toLocaleString() : '–'} + cookieless count + +
+
+ +
+

+ Quick actions +

+
+ + New journal entry + + + Messages + + + Theme + + + Diagnostics + +
+
+
+ ); +} diff --git a/app/admin/components/AnalyticsView.tsx b/app/admin/components/AnalyticsView.tsx index 4b6aae6..27c11f6 100644 --- a/app/admin/components/AnalyticsView.tsx +++ b/app/admin/components/AnalyticsView.tsx @@ -1,7 +1,9 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; import * as Icons from './Icons'; +import { useAdminFetch } from './useAdminFetch'; +import AdminLoadState from './AdminLoadState'; +import PageHeader from './PageHeader'; interface AnalyticsData { trackingEnabled?: boolean; @@ -22,47 +24,17 @@ interface AnalyticsData { } export default function AnalyticsView() { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const analytics = useAdminFetch('/api/admin/analytics'); + const data = analytics.data; - const fetchAnalytics = useCallback(async () => { - setLoading(true); - setError(null); - try { - const res = await fetch('/api/admin/analytics'); - if (!res.ok) throw new Error('Failed to fetch analytics'); - const json = await res.json(); - setData(json); - } catch (err: any) { - setError(err.message || 'Error loading analytics'); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - fetchAnalytics(); - }, [fetchAnalytics]); - - if (loading) { + if (!data || analytics.error) { return ( -
-
-
- ); - } - - if (error || !data) { - return ( -
-

- {error || 'No analytics data'} -

- -
+ ); } @@ -80,20 +52,16 @@ export default function AnalyticsView() { return (
- {/* Header */} -
-
-

- Visitor Insights & Analytics -

-

- Privacy-first aggregate traffic stats. No personal data collected. -

-
- -
+ + Refresh + + } + /> {data.trackingEnabled === false && (
; -const EMPTY_LISTS: BackupLists = { gallery: [], settings: [], about: [], journal: [] }; +const EMPTY_LISTS: BackupLists = { + gallery: [], + settings: [], + about: [], + privacy: [], + journal: [], +}; export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }: Props) { const [activeTab, setActiveTab] = useState('gallery'); const [showAllBackups, setShowAllBackups] = useState(false); - const [backups, setBackups] = useState(EMPTY_LISTS); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + const list = useAdminFetch<{ backups: Partial }>( + isOpen ? '/api/admin/backups' : null, + ); + const backups: BackupLists = { ...EMPTY_LISTS, ...list.data?.backups }; + /** A failed restore; a failed load is list.error. */ + const [restoreError, setRestoreError] = useState(null); + const error = list.error ?? restoreError; + const loading = list.loading; + const reloadList = list.reload; const [restoringFilename, setRestoringFilename] = useState(null); const [confirmItem, setConfirmItem] = useState(null); const [successMsg, setSuccessMsg] = useState(null); - const fetchBackups = useCallback(async () => { - setLoading(true); - setError(null); - try { - const res = await fetch('/api/admin/backups'); - if (!res.ok) { - throw new Error('Failed to fetch backup history'); - } - const data = await res.json(); - setBackups({ ...EMPTY_LISTS, ...data.backups }); - } catch (err: any) { - setError(err.message || 'Error loading backups'); - } finally { - setLoading(false); - } - }, []); - useEffect(() => { if (isOpen) { - fetchBackups(); + // Each opening shows the backups as they are now, not as they were + // the last time the modal was open. + reloadList(); + setRestoreError(null); setConfirmItem(null); setSuccessMsg(null); setShowAllBackups(false); } - }, [isOpen, fetchBackups]); + }, [isOpen, reloadList]); useScrollLock(isOpen); /* Before the early `return null`: hooks must not run conditionally. */ @@ -68,7 +69,7 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess } async function handleRestore(item: BackupItem) { const filename = item.filename; setRestoringFilename(filename); - setError(null); + setRestoreError(null); setSuccessMsg(null); try { @@ -90,10 +91,12 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess } : `Backup restored successfully! (${filename})`, ); setConfirmItem(null); - await fetchBackups(); + // Open editors still hold the pre-restore state; they reload on this. + reportContentRestored({ target: item.target, slug: item.slug }); + reloadList(); onRestoreSuccess(); - } catch (err: any) { - setError(err.message || 'Error restoring backup'); + } catch (err) { + setRestoreError(err instanceof Error ? err.message : 'Error restoring backup'); } finally { setRestoringFilename(null); } diff --git a/app/admin/components/ConfirmDialog.tsx b/app/admin/components/ConfirmDialog.tsx new file mode 100644 index 0000000..757d08b --- /dev/null +++ b/app/admin/components/ConfirmDialog.tsx @@ -0,0 +1,115 @@ +'use client'; + +/** + * One confirmation for the admin (UX stage 3, #694 §3), replacing the + * browser's `confirm()`, which cannot be styled, blocks the tab and reads the + * same for "discard a draft" and "delete a file". + * + * Policy: ask only before something that is permanent or takes effect at + * once (deleting a message, a journal entry, the uploaded favicon). Removals + * of unsaved state are undone by not saving and do not need a dialog. + * + * const confirm = useConfirm(); + * if (!(await confirm({ title: 'Delete message?', confirmLabel: 'Delete', danger: true }))) return; + */ + +import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react'; +import { useModalDialog } from '@/hooks/useModalDialog'; + +export interface ConfirmOptions { + title: string; + /** What happens, in one or two sentences. */ + message?: ReactNode; + confirmLabel?: string; + cancelLabel?: string; + /** Destructive: the confirm button uses the error colour. */ + danger?: boolean; +} + +type Confirm = (options: ConfirmOptions) => Promise; + +/** Outside the provider (a component tested on its own) it falls back to window.confirm. */ +const ConfirmContext = createContext(async (o) => + typeof window === 'undefined' ? false : window.confirm(o.title), +); + +export function useConfirm(): Confirm { + return useContext(ConfirmContext); +} + +export function ConfirmProvider({ children }: { children: ReactNode }) { + const [pending, setPending] = useState(null); + const resolver = useRef<((ok: boolean) => void) | null>(null); + + const confirm = useCallback( + (options) => + new Promise((resolve) => { + resolver.current?.(false); + resolver.current = resolve; + setPending(options); + }), + [], + ); + + const settle = useCallback((ok: boolean) => { + resolver.current?.(ok); + resolver.current = null; + setPending(null); + }, []); + + return ( + + {children} + {pending && } + + ); +} + +function Dialog({ + options, + onSettle, +}: { + options: ConfirmOptions; + onSettle: (ok: boolean) => void; +}) { + // Esc cancels, focus is trapped and returned (the same hook the modals use). + const cardRef = useModalDialog(() => onSettle(false)); + return ( +
onSettle(false)}> +
e.stopPropagation()} + > +

+ {options.title} +

+ {options.message && ( +

+ {options.message} +

+ )} +
+ + +
+
+
+ ); +} diff --git a/app/admin/components/DiagnosticsView.tsx b/app/admin/components/DiagnosticsView.tsx index bf9d471..8650411 100644 --- a/app/admin/components/DiagnosticsView.tsx +++ b/app/admin/components/DiagnosticsView.tsx @@ -7,6 +7,7 @@ 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'; +import PageHeader from './PageHeader'; /** * Diagnostics as a page (/admin/diagnostics). @@ -40,8 +41,12 @@ type AltTextData = AltTextReport & { /** 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'] }, + { id: 'security', title: 'Security', checks: ['auth-secret', 'passwords', 'proxy-hops', 'cdn'] }, + { + id: 'content', + title: 'Content', + checks: ['albums-shared', 'content-writable', 'alt-text', 'legal', 'contact', 'privacy'], + }, ]; const LEVEL_LABEL: Record = { ok: 'OK', warn: 'Check', error: 'Problem' }; @@ -56,9 +61,13 @@ const FIXES: Record = { href: `${DOCS}/README.md#environment-variables-envlocal`, }, 'proxy-hops': { label: 'How to set it', href: `${DOCS}/docs/gallery-config.md#trusted-proxies` }, + cdn: { label: 'CDN setup', href: `${DOCS}/docs/deployment.md#cdn-mode` }, passwords: { label: 'Open settings', href: '/admin/settings/security' }, 'album-ids': { label: 'Open pages', href: '/admin/pages' }, 'albums-shared': { label: 'Open pages', href: '/admin/pages' }, + legal: { label: 'Open settings', href: '/admin/settings/legal' }, + contact: { label: 'Open settings', href: '/admin/settings/legal' }, + privacy: { label: 'Open settings', href: '/admin/settings/legal' }, 'content-writable': { label: 'How to fix', href: `${DOCS}/docs/admin-panel.md#docker-usage` }, 'immich-api': { label: 'API key permissions', @@ -371,27 +380,26 @@ export default function DiagnosticsView() { return (
-
-
-

{headline}

-

- {ranAt ? `Last run ${ranAt.toLocaleString()}` : 'Running the checks…'} -

-
-
- - -
-
+ + + + + } + /> {error &&
{error}
} diff --git a/app/admin/components/HelpView.tsx b/app/admin/components/HelpView.tsx index 39d06ba..0e2251d 100644 --- a/app/admin/components/HelpView.tsx +++ b/app/admin/components/HelpView.tsx @@ -3,6 +3,7 @@ import * as Icons from './Icons'; import { en } from '@/lib/i18n/locales/en'; import { LIGHTBOX_SHORTCUTS, shortcutDisplayKeys } from '@/lib/lightboxShortcuts'; +import PageHeader from './PageHeader'; /** * The admin help section. @@ -19,14 +20,11 @@ import { LIGHTBOX_SHORTCUTS, shortcutDisplayKeys } from '@/lib/lightboxShortcuts export default function HelpView() { return (
-
-

- Help -

-

- What your portfolio can do that has no button of its own. -

-
+
diff --git a/app/admin/components/MessagesView.tsx b/app/admin/components/MessagesView.tsx new file mode 100644 index 0000000..dbb50c4 --- /dev/null +++ b/app/admin/components/MessagesView.tsx @@ -0,0 +1,192 @@ +'use client'; + +/** + * The contact form inbox at /admin/messages (#702). Messages live in + * content/messages/ (lib/contact.ts); replying happens in the owner's own + * mail client through a mailto: link, since Folio sends no mail. + */ + +import { useState } from 'react'; +import Link from 'next/link'; +import * as Icons from './Icons'; +import { reportIfSessionExpired } from './sessionExpiry'; +import { useAdminFetch } from './useAdminFetch'; +import AdminLoadState from './AdminLoadState'; +import type { ContactMessage } from '@/lib/contact'; +import { useNotify } from './Notifications'; +import PageHeader from './PageHeader'; +import { useConfirm } from './ConfirmDialog'; + +interface InboxData { + enabled: boolean; + notifyConfigured: boolean; + retentionDays: number; + messages: ContactMessage[]; +} + +function replyHref(m: ContactMessage): string { + const subject = encodeURIComponent('Re: your message'); + const quoted = m.message + .split('\n') + .map((line) => `> ${line}`) + .join('\n'); + const body = encodeURIComponent(`\n\n${m.name} wrote:\n${quoted}`); + // Encoded so a ? or & in the address cannot start the query, with the @ + // put back: not every mail client decodes %40 in the address part. + const to = encodeURIComponent(m.email).replace(/%40/g, '@'); + return `mailto:${to}?subject=${subject}&body=${body}`; +} + +export default function MessagesView() { + const confirm = useConfirm(); + const notify = useNotify(); + const inbox = useAdminFetch('/api/admin/messages'); + const data = inbox.data; + const [open, setOpen] = useState(null); + + async function markRead(m: ContactMessage, read: boolean) { + const res = await fetch('/api/admin/messages', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: m.id, read }), + }); + if (!res.ok) { + reportIfSessionExpired(res); + return; + } + inbox.mutate((d) => ({ + ...d, + messages: d.messages.map((x) => (x.id === m.id ? { ...x, read } : x)), + })); + } + + function toggle(m: ContactMessage) { + const opening = open !== m.id; + setOpen(opening ? m.id : null); + if (opening && !m.read) markRead(m, true); + } + + async function remove(m: ContactMessage) { + const ok = await confirm({ + title: `Delete the message from ${m.name}?`, + message: 'It is removed from this server and cannot be restored.', + confirmLabel: 'Delete', + danger: true, + }); + if (!ok) return; + const res = await fetch(`/api/admin/messages?id=${encodeURIComponent(m.id)}`, { + method: 'DELETE', + }); + if (!res.ok) { + if (!reportIfSessionExpired(res)) notify('error', `Delete failed (HTTP ${res.status}).`); + return; + } + inbox.mutate((d) => ({ ...d, messages: d.messages.filter((x) => x.id !== m.id) })); + } + + if (!data || inbox.error) { + return ( + + ); + } + + const unread = data.messages.filter((m) => !m.read).length; + + return ( +
+ + Messages + {unread > 0 && {unread} unread} + + } + description={`Sent through the contact form at /contact. Stored on this server and deleted after ${data.retentionDays} days.`} + actions={ + + } + /> + + {!data.enabled && ( +
+ The contact form is switched off, so no new messages arrive.{' '} + Turn it on in Settings → Legal. +
+ )} + {data.enabled && !data.notifyConfigured && ( +
+ No notification is set up, so new messages only show up here.{' '} + Add an ntfy URL in Settings → Legal. +
+ )} + + {data.messages.length === 0 ? ( +
No messages.
+ ) : ( +
    + {data.messages.map((m) => { + const expanded = open === m.id; + return ( +
  • + + {expanded && ( +
    +

    + {m.name} <{m.email}> +

    +

    {m.message}

    +
    + + Reply by email + + + +
    +
    + )} +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/app/admin/components/Notifications.tsx b/app/admin/components/Notifications.tsx new file mode 100644 index 0000000..bd829b4 --- /dev/null +++ b/app/admin/components/Notifications.tsx @@ -0,0 +1,108 @@ +'use client'; + +/** + * The admin's one place to say that something happened (#600). + * + * Replaces `alert()`, which blocked the page, could not be styled and said + * nothing to a screen reader beyond the dialog itself. A notification here has + * an explicit kind rather than one guessed from its wording, can be dismissed, + * and is announced: errors through an assertive live region, successes through + * a polite one. Successes leave on their own after a few seconds; errors stay + * until dismissed, because an error nobody saw did not get reported. + */ + +import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react'; +import { IconX } from './Icons'; + +export type NotificationKind = 'success' | 'error'; + +/** One button on a notification, e.g. Undo after a removal. */ +export interface NotificationAction { + label: string; + run: () => void; +} + +interface Notification { + id: number; + kind: NotificationKind; + message: string; + action?: NotificationAction; +} + +type Notify = (kind: NotificationKind, message: string, action?: NotificationAction) => void; + +/** + * Outside the provider (a component rendered on its own in a test) notifying + * does nothing rather than throwing. + */ +const NotifyContext = createContext(() => {}); + +export function useNotify(): Notify { + return useContext(NotifyContext); +} + +const SUCCESS_MS = 4000; +const ACTION_MS = 8000; + +export function NotificationProvider({ children }: { children: ReactNode }) { + const [items, setItems] = useState([]); + const nextId = useRef(1); + + const dismiss = useCallback((id: number) => { + setItems((list) => list.filter((n) => n.id !== id)); + }, []); + + const notify = useCallback( + (kind, message, action) => { + const id = nextId.current++; + setItems((list) => [...list, { id, kind, message, action }]); + // Longer with an action: there has to be time to reach the button. + if (kind === 'success') setTimeout(() => dismiss(id), action ? ACTION_MS : SUCCESS_MS); + }, + [dismiss], + ); + + const render = (kind: NotificationKind) => + items + .filter((n) => n.kind === kind) + .map((n) => ( +
+ {n.message} + {n.action && ( + + )} + +
+ )); + + return ( + + {children} +
+ {/* Two regions, because politeness belongs to the region, not the message. */} +
+ {render('error')} +
+
+ {render('success')} +
+
+
+ ); +} diff --git a/app/admin/components/PageBuilder.tsx b/app/admin/components/PageBuilder.tsx index 6ed78fd..b9e39bb 100644 --- a/app/admin/components/PageBuilder.tsx +++ b/app/admin/components/PageBuilder.tsx @@ -11,25 +11,24 @@ import { DragEndEvent, } from '@dnd-kit/core'; import { - arrayMove, SortableContext, sortableKeyboardCoordinates, - useSortable, verticalListSortingStrategy, horizontalListSortingStrategy, } from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; import AlbumPicker from './AlbumPicker'; +import { useContentRestored } from './contentRestored'; import AssetPicker from './AssetPicker'; import AssetOrderEditor from './AssetOrderEditor'; -import SaveBar from './SaveBar'; +import SaveBar, { type SaveStatus } from './SaveBar'; import AlbumDrawer from './page-builder/AlbumDrawer'; import { SortableAlbumCard } from './page-builder/AlbumCard'; import SubpageDrawer from './page-builder/SubpageDrawer'; +import { SortableHeroTile, SortableSubpageRow } from './page-builder/SortableTiles'; import { findAlbumAddress } from './page-builder/findAlbumAddress'; -import { parseAlbumEntries, serializeAlbumEntries } from './page-builder/albumEntries'; +import { parseGalleryYaml, serializeGallery, type GalleryState } from './page-builder/galleryYaml'; +import * as ops from './page-builder/galleryOps'; import { - seedCoverGrid, type ActiveEditAlbumAddress, type AlbumEntry, type HeroPickerTarget, @@ -44,164 +43,17 @@ import { useUnsavedGuard } from './useUnsavedGuard'; import { useDraft } from './useDraft'; import DraftNotice from './DraftNotice'; import { reportIfSessionExpired } from './sessionExpiry'; -import { - IconCamera, - IconFolder, - IconGripVertical, - IconHome, - IconLock, - IconPencil, - IconPlus, - IconSearch, - IconX, -} from './Icons'; - -// ── Types ────────────────────────────────────────────────────── - -interface GalleryState { - hero: string[]; - albums: AlbumEntry[]; - subpages: Subpage[]; -} - -// ── Sortable Hero Tile ───────────────────────────────────────── - -function SortableHeroTile({ - id, - index, - onRemove, -}: { - id: string; - index: number; - onRemove: () => void; -}) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id: `hero-${index}`, - }); - - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - zIndex: isDragging ? 10 : undefined, - }; - - return ( -
-
- -
- - - {index + 1} -
- ); -} - -// ── Sortable Subpage Tile ────────────────────────────────────── - -function SortableSubpageTile({ - sp, - spIndex, - isActive, - onClick, - getFirstThumb, -}: { - sp: Subpage; - spIndex: number; - isActive: boolean; - onClick: () => void; - getFirstThumb: (sp: Subpage) => string | null; -}) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id: `subpage-${spIndex}`, - }); - - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - zIndex: isDragging ? 10 : undefined, - }; - - const totalAlbums = - sp.albums.length + (sp.sections?.reduce((sum, sec) => sum + sec.albums.length, 0) || 0); - const firstThumb = getFirstThumb(sp); - const slug = sp.name - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, ''); - - return ( -
-
- -
- - {sp.enabled === false && ( - - Disabled - - )} - - {sp.hidden === true && sp.enabled !== false && ( - - Unlisted - - )} - - {sp.password && ( - - Password - - )} - -
- {firstThumb ? ( - - ) : ( -
- -
- )} -
- - Edit Page - -
-
-
-
- {sp.title || sp.name} - /{slug} -
- - {totalAlbums} album{totalAlbums !== 1 ? 's' : ''} - -
-
- ); -} - -// ── Component ────────────────────────────────────────────────── +import { IconCamera, IconHome, IconPlus, IconSearch } from './Icons'; +import { useNotify } from './Notifications'; export default function PageBuilder() { const [gallery, setGallery] = useState({ hero: [], albums: [], subpages: [] }); + const notify = useNotify(); + /** The state as last rendered, for Undo to check nothing changed since. */ + const galleryRef = useRef(gallery); + useEffect(() => { + galleryRef.current = gallery; + }, [gallery]); const [immichAlbums, setImmichAlbums] = useState([]); const [loading, setLoading] = useState(true); /** @@ -212,8 +64,10 @@ export default function PageBuilder() { const [loadError, setLoadError] = useState(null); const [saving, setSaving] = useState(false); const [dirty, setDirty] = useState(false); - const [saveMessage, setSaveMessage] = useState(''); + const [saveStatus, setSaveStatus] = useState(null); const [expandedSubpage, setExpandedSubpage] = useState(null); + /** What the panel shows while no subpage is selected (UX stage 4). */ + const [overview, setOverview] = useState<'hero' | 'albums'>('hero'); const [drawerMode, setDrawerMode] = useState<'edit' | 'preview'>('edit'); const [pickerTarget, setPickerTarget] = useState(null); const [heroPickerTarget, setHeroPickerTarget] = useState(null); @@ -228,7 +82,7 @@ export default function PageBuilder() { // 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 // a single condition avoids two locks racing over the same inline style. - useScrollLock(editingAlbumAddress !== null || expandedSubpage !== null); + useScrollLock(editingAlbumAddress !== null); // DnD sensors const sensors = useSensors( @@ -242,6 +96,11 @@ export default function PageBuilder() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // A restored gallery.yaml replaces what this editor loaded. + useContentRestored(({ target }) => { + if (target === 'gallery') loadData(); + }); + // ── Keyboard shortcut: ⌘+S / Ctrl+S ───────────────────────── useEffect(() => { function handleKeyDown(e: KeyboardEvent) { @@ -257,22 +116,6 @@ export default function PageBuilder() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [dirty, saving, gallery]); - // ── Escape closes the subpage sheet — only when it is topmost ─ - useEffect(() => { - if (expandedSubpage === null) return; - function handleEscape(e: KeyboardEvent) { - if (e.key !== 'Escape') return; - // Listbox preventDefaults its own Escape but does not stopPropagation — - // respect that so closing a popup never also closes the sheet. - if (e.defaultPrevented) return; - // A higher layer (album editor, pickers, order editor) owns the key. - if (editingAlbumAddress || pickerTarget || heroPickerTarget || orderEditorTarget) return; - setExpandedSubpage(null); - } - window.addEventListener('keydown', handleEscape); - return () => window.removeEventListener('keydown', handleEscape); - }, [expandedSubpage, editingAlbumAddress, pickerTarget, heroPickerTarget, orderEditorTarget]); - useUnsavedGuard(dirty); // Unsaved edits survive leaving the builder — a tab link, back, Reload, @@ -322,7 +165,9 @@ export default function PageBuilder() { serverState.current = parsed; const restored = draft.load(JSON.stringify(parsed)); setGallery(restored ?? parsed); - if (restored) setDirty(true); + // Not merely "set when restored": on a reload after a backup restore the + // editor may have been dirty, and now shows the server state. + setDirty(restored !== null); openAlbumFromLink(restored ?? parsed); // A failed album list is survivable — it only empties the picker, and @@ -368,71 +213,11 @@ export default function PageBuilder() { } } - function parseGalleryYaml(raw: Record): GalleryState { - const hero = Array.isArray(raw.hero) ? raw.hero : raw.hero ? [raw.hero as string] : []; - const albums = parseAlbumEntries( - raw.albums as Array> | undefined, - ); - - let subpages: Subpage[] = []; - if (Array.isArray(raw.subpages)) { - subpages = (raw.subpages as Array>).map((sp) => ({ - name: (sp.name as string) || '', - title: sp.title as string | undefined, - subtitle: sp.subtitle as string | undefined, - password: sp.password as string | undefined, - enabled: sp.enabled !== false, - hidden: sp.hidden === true, - location: sp.location as string | undefined, - essayText: sp.essayText as string | undefined, - essayFile: sp.essayFile as string | undefined, - albums: parseAlbumEntries(sp.albums as Array> | undefined), - sections: sp.sections - ? (sp.sections as Array>).map((sec) => ({ - title: (sec.title as string) || '', - description: sec.description as string | undefined, - albums: parseAlbumEntries(sec.albums as Array>), - })) - : undefined, - grid: sp.grid as Subpage['grid'], - coverGrid: - (sp.coverGrid as Subpage['coverGrid']) ?? seedCoverGrid(sp.grid as Subpage['grid']), - })); - } else if (raw.subpages && typeof raw.subpages === 'object') { - subpages = Object.entries(raw.subpages as Record).map(([name, value]) => { - if (Array.isArray(value)) { - return { name, albums: parseAlbumEntries(value), sections: undefined, enabled: true }; - } - const sp = value as Record; - return { - name, - title: sp.title as string | undefined, - subtitle: sp.subtitle as string | undefined, - password: sp.password as string | undefined, - enabled: sp.enabled !== false, - hidden: sp.hidden === true, - location: sp.location as string | undefined, - essayText: sp.essayText as string | undefined, - essayFile: sp.essayFile as string | undefined, - albums: parseAlbumEntries( - sp.albums as Array> | undefined, - ), - sections: undefined, - grid: sp.grid as Subpage['grid'], - coverGrid: - (sp.coverGrid as Subpage['coverGrid']) ?? seedCoverGrid(sp.grid as Subpage['grid']), - }; - }); - } - - return { hero, albums, subpages }; - } - function discardDraft() { draft.discard(); if (serverState.current) setGallery(serverState.current); setDirty(false); - setSaveMessage(''); + setSaveStatus(null); } function restoreConflictingDraft() { @@ -444,7 +229,7 @@ export default function PageBuilder() { const markDirty = useCallback(() => { setDirty(true); - setSaveMessage(''); + setSaveStatus(null); }, []); // ── Save ────────────────────────────────────────────────────── @@ -453,49 +238,9 @@ export default function PageBuilder() { if (loadError) return; setSaving(true); - setSaveMessage(''); + setSaveStatus(null); - const yamlData: Record = {}; - - if (gallery.hero.length > 0) { - yamlData.hero = gallery.hero; - } - if (gallery.albums.length > 0) { - yamlData.albums = serializeAlbumEntries(gallery.albums); - } - if (gallery.subpages.length > 0) { - yamlData.subpages = gallery.subpages.map((sp) => { - const entry: Record = { name: sp.name }; - if (sp.title) entry.title = sp.title; - if (sp.subtitle) entry.subtitle = sp.subtitle; - if (sp.password) entry.password = sp.password; - if (sp.enabled === false) entry.enabled = false; - if (sp.hidden === true) entry.hidden = true; - if (sp.essayText) entry.essayText = sp.essayText; - if (sp.essayFile) entry.essayFile = sp.essayFile; - if (sp.grid) entry.grid = sp.grid; - if (sp.coverGrid) entry.coverGrid = sp.coverGrid; - if (sp.location) entry.location = sp.location; - - if (sp.sections && sp.sections.length > 0) { - entry.sections = sp.sections.map((sec) => { - const s: Record = { - title: sec.title, - albums: serializeAlbumEntries(sec.albums), - }; - if (sec.description) s.description = sec.description; - return s; - }); - if (sp.albums.length > 0) { - entry.albums = serializeAlbumEntries(sp.albums); - } - } else { - entry.albums = serializeAlbumEntries(sp.albums); - } - - return entry; - }); - } + const yamlData = serializeGallery(gallery); try { const res = await fetch('/api/admin/gallery', { @@ -507,72 +252,54 @@ export default function PageBuilder() { if (res.ok) { const data = await res.json(); // Fingerprint what the next load will see, the way it will see it: the - // file is exactly `yamlData`, read back through the same parser. The - // editor's own state can differ in shape (an `undefined` here, a - // default there) and would make every later draft look outdated. - const asLoaded = parseGalleryYaml(JSON.parse(JSON.stringify(yamlData))); + // file as written, read back through the same parser. The editor's own + // state can differ in shape (an `undefined` here, a default there) and + // would make every later draft look outdated. + // + // "As written" is the server's copy, not `yamlData`: passwords are + // hashed on the way to disk (#690). Fingerprinting the plaintext would + // make the next draft look like a conflicting edit from elsewhere. + const written = (data.gallery ?? yamlData) as Record; + const asLoaded = parseGalleryYaml(JSON.parse(JSON.stringify(written))); serverState.current = asLoaded; draft.saved(JSON.stringify(asLoaded)); + // Show the hashes, so a new password reads "Protected" right away. + if (JSON.stringify(written) !== JSON.stringify(yamlData)) setGallery(asLoaded); setDirty(false); - setSaveMessage(data.message || 'Saved successfully!'); - setTimeout(() => setSaveMessage(''), 5000); + setSaveStatus({ kind: 'success', message: data.message || 'Saved successfully!' }); + setTimeout(() => setSaveStatus(null), 5000); } else if (!reportIfSessionExpired(res)) { const err = await res.json(); - setSaveMessage(`Error: ${err.error}`); + setSaveStatus({ kind: 'error', message: `Error: ${err.error}` }); } } catch { - setSaveMessage('Error: Failed to save'); + setSaveStatus({ kind: 'error', message: 'Error: Failed to save' }); } finally { setSaving(false); } } - // ── Hero Picker ────────────────────────────────────────────── - function handleHeroSelect(assetId: string) { - setGallery((g) => ({ ...g, hero: [...g.hero, assetId] })); - setHeroPickerTarget(null); + /** Apply one edit from galleryOps and mark the form dirty. */ + function edit(op: (g: GalleryState) => GalleryState) { + setGallery(op); markDirty(); } - // ── Album Picker Handlers ──────────────────────────────────── + // ── Pickers ────────────────────────────────────────────────── + function handleHeroSelect(assetId: string) { + edit((g) => ops.addHero(g, assetId)); + setHeroPickerTarget(null); + } + function handlePickAlbum(albumId: string) { if (!pickerTarget) return; - - const entry: AlbumEntry = { id: albumId }; - - if (pickerTarget.type === 'standalone') { - setGallery((g) => ({ ...g, albums: [...g.albums, entry] })); - } else if (pickerTarget.type === 'subpage' && pickerTarget.subpageIndex != null) { - setGallery((g) => { - const subpages = [...g.subpages]; - const sp = { ...subpages[pickerTarget.subpageIndex!] }; - sp.albums = [...sp.albums, entry]; - subpages[pickerTarget.subpageIndex!] = sp; - return { ...g, subpages }; - }); - } else if ( - pickerTarget.type === 'section' && - pickerTarget.subpageIndex != null && - pickerTarget.sectionIndex != null - ) { - setGallery((g) => { - const subpages = [...g.subpages]; - const sp = { ...subpages[pickerTarget.subpageIndex!] }; - const sections = [...(sp.sections || [])]; - const sec = { ...sections[pickerTarget.sectionIndex!] }; - sec.albums = [...sec.albums, entry]; - sections[pickerTarget.sectionIndex!] = sec; - sp.sections = sections; - subpages[pickerTarget.subpageIndex!] = sp; - return { ...g, subpages }; - }); - } - + const target = pickerTarget; + edit((g) => ops.addAlbum(g, target, { id: albumId })); setPickerTarget(null); - markDirty(); } // ── Drag & Drop Handlers ───────────────────────────────────── + // dnd-kit reports item ids; these map them back to positions in the list. function handleHeroDragEnd(event: DragEndEvent) { const { active, over } = event; if (!over || active.id === over.id) return; @@ -580,10 +307,7 @@ export default function PageBuilder() { const oldIndex = gallery.hero.findIndex((_, i) => `hero-${i}` === active.id); const newIndex = gallery.hero.findIndex((_, i) => `hero-${i}` === over.id); - if (oldIndex !== -1 && newIndex !== -1) { - setGallery((g) => ({ ...g, hero: arrayMove(g.hero, oldIndex, newIndex) })); - markDirty(); - } + if (oldIndex !== -1 && newIndex !== -1) edit((g) => ops.moveHero(g, oldIndex, newIndex)); } function handleAlbumDragEnd(event: DragEndEvent) { @@ -594,8 +318,7 @@ export default function PageBuilder() { const newIndex = gallery.albums.findIndex((a, i) => `album-${a.id}-${i}` === over.id); if (oldIndex !== -1 && newIndex !== -1) { - setGallery((g) => ({ ...g, albums: arrayMove(g.albums, oldIndex, newIndex) })); - markDirty(); + edit((g) => ops.moveAlbum(g, { type: 'standalone' }, oldIndex, newIndex)); } } @@ -607,15 +330,8 @@ export default function PageBuilder() { const newIndex = gallery.subpages.findIndex((_, i) => `subpage-${i}` === over.id); if (oldIndex !== -1 && newIndex !== -1) { - setGallery((g) => ({ ...g, subpages: arrayMove(g.subpages, oldIndex, newIndex) })); - if (expandedSubpage === oldIndex) setExpandedSubpage(newIndex); - else if (expandedSubpage !== null) { - if (oldIndex < expandedSubpage && newIndex >= expandedSubpage) - setExpandedSubpage(expandedSubpage - 1); - else if (oldIndex > expandedSubpage && newIndex <= expandedSubpage) - setExpandedSubpage(expandedSubpage + 1); - } - markDirty(); + edit((g) => ops.moveSubpage(g, oldIndex, newIndex)); + setExpandedSubpage(ops.followMovedIndex(expandedSubpage, oldIndex, newIndex)); } } @@ -629,142 +345,72 @@ export default function PageBuilder() { const newIndex = sp.albums.findIndex((a, i) => `album-${a.id}-${i}` === over.id); if (oldIndex !== -1 && newIndex !== -1) { - setGallery((g) => { - const subpages = [...g.subpages]; - const sp2 = { ...subpages[spIndex] }; - sp2.albums = arrayMove(sp2.albums, oldIndex, newIndex); - subpages[spIndex] = sp2; - return { ...g, subpages }; - }); - markDirty(); + edit((g) => + ops.moveAlbum(g, { type: 'subpage', subpageIndex: spIndex }, oldIndex, newIndex), + ); } }; } - // ── Subpage Management ──────────────────────────────────────── - function addSubpage() { - setGallery((g) => ({ - ...g, - subpages: [ - ...g.subpages, - { name: `New Page ${g.subpages.length + 1}`, albums: [], sections: undefined }, - ], - })); - markDirty(); - } + // ── Subpages and sections ──────────────────────────────────── + const addSubpage = () => edit(ops.addSubpage); - /** 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; - } + const removeSubpage = (index: number) => + removeWithUndo('Page removed.', (g) => ops.removeSubpage(g, index)); - function removeSubpage(index: number) { - if (!confirm('Remove this subpage?')) return; - setGallery((g) => ({ - ...g, - subpages: g.subpages.filter((_, i) => i !== index), - })); - markDirty(); - } + const updateSubpage = (index: number, updates: Partial) => + edit((g) => ops.updateSubpage(g, index, updates)); - function updateSubpage(index: number, updates: Partial) { - setGallery((g) => { - const subpages = [...g.subpages]; - subpages[index] = { ...subpages[index], ...updates }; - return { ...g, subpages }; + const addSection = (subpageIndex: number) => edit((g) => ops.addSection(g, subpageIndex)); + + const removeSection = (subpageIndex: number, sectionIndex: number) => + edit((g) => ops.removeSection(g, subpageIndex, sectionIndex)); + + const updateSection = (subpageIndex: number, sectionIndex: number, updates: Partial
) => + edit((g) => ops.updateSection(g, subpageIndex, sectionIndex, updates)); + + // ── Removal ────────────────────────────────────────────────── + // Removing only changes unsaved state, so it happens at once and offers Undo + // instead of asking first (#694 §3). It used to be a confirm() before every + // click, which is how the builder ended up asking about things nobody could + // lose: nothing is written until Save. + function removeWithUndo(message: string, op: (g: GalleryState) => GalleryState) { + const before = gallery; + const after = op(before); + if (after === before) return; + edit(() => after); + notify('success', message, { + label: 'Undo', + run: () => { + // Only while nothing else changed since: restoring `before` on top of + // later edits would silently throw those away. + if (galleryRef.current !== after) { + notify( + 'error', + 'Could not undo: the page structure changed since. Nothing is saved yet.', + ); + return; + } + edit(() => before); + }, }); - markDirty(); } - // ── Section Management ──────────────────────────────────────── - function addSection(subpageIndex: number) { - setGallery((g) => { - const subpages = [...g.subpages]; - const sp = { ...subpages[subpageIndex] }; - sp.sections = [...(sp.sections || []), { title: 'New Section', albums: [] }]; - subpages[subpageIndex] = sp; - return { ...g, subpages }; - }); - markDirty(); - } + const removeStandaloneAlbum = (index: number) => + removeWithUndo('Album removed.', (g) => ops.removeAlbum(g, { type: 'standalone' }, index)); - function removeSection(subpageIndex: number, sectionIndex: number) { - setGallery((g) => { - const subpages = [...g.subpages]; - const sp = { ...subpages[subpageIndex] }; - sp.sections = (sp.sections || []).filter((_, i) => i !== sectionIndex); - subpages[subpageIndex] = sp; - return { ...g, subpages }; - }); - markDirty(); - } + const removeSubpageAlbum = (subpageIndex: number, albumIndex: number) => + removeWithUndo('Album removed from the page.', (g) => + ops.removeAlbum(g, { type: 'subpage', subpageIndex }, albumIndex), + ); - function updateSection(subpageIndex: number, sectionIndex: number, updates: Partial
) { - setGallery((g) => { - const subpages = [...g.subpages]; - const sp = { ...subpages[subpageIndex] }; - const sections = [...(sp.sections || [])]; - sections[sectionIndex] = { ...sections[sectionIndex], ...updates }; - sp.sections = sections; - subpages[subpageIndex] = sp; - return { ...g, subpages }; - }); - markDirty(); - } + const removeSectionAlbum = (subpageIndex: number, sectionIndex: number, albumIndex: number) => + removeWithUndo('Album removed from the section.', (g) => + ops.removeAlbum(g, { type: 'section', subpageIndex, sectionIndex }, albumIndex), + ); - // ── Album Removal ──────────────────────────────────────────── - // Confirmed like removeSubpage: an album entry carries its grid overrides, - // cover asset and manual assetOrder with it, there is no undo stack in the - // builder, and the only escape from an accidental click used to be - // reloading the page — which discards every other unsaved edit too (#597). - function removeStandaloneAlbum(index: number) { - if (!confirm('Remove this album from the gallery?')) return; - setGallery((g) => ({ - ...g, - albums: g.albums.filter((_, i) => i !== index), - })); - markDirty(); - } - - function removeSubpageAlbum(subpageIndex: number, albumIndex: number) { - if (!confirm('Remove this album from the subpage?')) return; - setGallery((g) => { - const subpages = [...g.subpages]; - const sp = { ...subpages[subpageIndex] }; - sp.albums = sp.albums.filter((_, i) => i !== albumIndex); - subpages[subpageIndex] = sp; - return { ...g, subpages }; - }); - markDirty(); - } - - function removeSectionAlbum(subpageIndex: number, sectionIndex: number, albumIndex: number) { - if (!confirm('Remove this album from the section?')) return; - setGallery((g) => { - const subpages = [...g.subpages]; - const sp = { ...subpages[subpageIndex] }; - const sections = [...(sp.sections || [])]; - const sec = { ...sections[sectionIndex] }; - sec.albums = sec.albums.filter((_, i) => i !== albumIndex); - sections[sectionIndex] = sec; - sp.sections = sections; - subpages[subpageIndex] = sp; - return { ...g, subpages }; - }); - markDirty(); - } - - // ── Hero Management ────────────────────────────────────────── - function removeHero(index: number) { - if (!confirm('Remove this photo from the home page hero?')) return; - setGallery((g) => ({ - ...g, - hero: g.hero.filter((_, i) => i !== index), - })); - markDirty(); - } + const removeHero = (index: number) => + removeWithUndo('Hero photo removed.', (g) => ops.removeHero(g, index)); // ── Helpers ────────────────────────────────────────────────── function getAlbumName(id: string): string { @@ -798,6 +444,28 @@ export default function PageBuilder() { return null; } + /** The album an open drawer edits, with the callbacks the drawer needs. */ + function getEditingAlbumInfo(addr: ActiveEditAlbumAddress) { + const album = ops.albumsAt(gallery, addr)?.[addr.albumIndex]; + if (!album) return null; + return { + album, + name: getAlbumName(album.id), + count: getAlbumCount(album.id), + thumbnailId: getAlbumThumbnailId(album.id), + onUpdate: (updates: Partial) => + edit((g) => ops.updateAlbum(g, addr, addr.albumIndex, updates)), + onRemove: () => { + if (addr.type === 'standalone') removeStandaloneAlbum(addr.albumIndex); + else if (addr.type === 'subpage') removeSubpageAlbum(addr.subpageIndex!, addr.albumIndex); + else removeSectionAlbum(addr.subpageIndex!, addr.sectionIndex!, addr.albumIndex); + // The drawer is addressed by index and would otherwise show whichever + // album slid into this slot. + setEditingAlbumAddress(null); + }, + }; + } + // ── Render ─────────────────────────────────────────────────── if (loading) { return ( @@ -875,7 +543,7 @@ export default function PageBuilder() { - {/* Search Bar */} -
-
- - - - setSearchQuery(e.target.value)} - /> - {searchQuery && ( +
+ {/* Structure: everything on the site, always visible (UX stage 4). */} + + + {/* The selected entry, edited in place instead of in an overlay. */} +
+ {expandedSubpage !== null && gallery.subpages[expandedSubpage] ? ( + <> + { + setExpandedSubpage(null); + setLinkedAlbumId(null); + }} + updateSubpage={updateSubpage} + removeSubpage={removeSubpage} + addSection={addSection} + removeSection={removeSection} + updateSection={updateSection} + removeSubpageAlbum={removeSubpageAlbum} + removeSectionAlbum={removeSectionAlbum} + onAlbumDragEnd={handleSubpageAlbumDragEnd(expandedSubpage)} + onPickAlbum={setPickerTarget} + onEditAlbum={setEditingAlbumAddress} + onPickHero={setHeroPickerTarget} + getAlbumName={getAlbumName} + getAlbumCount={getAlbumCount} + getAlbumThumbnailId={getAlbumThumbnailId} + highlightedAlbumId={linkedAlbumId} + inline + /> + + ) : overview === 'albums' ? ( + <> + {/* Standalone Albums */} +
+
+

+ + Standalone Albums +

+ +
+ + { + const originalIndex = gallery.albums.findIndex((x) => x.id === a.id); + return `album-${a.id}-${originalIndex}`; + })} + strategy={verticalListSortingStrategy} + > +
+ {filteredAlbums.length === 0 && ( +

+ {searchQuery + ? 'No matching standalone albums found.' + : 'No standalone albums. These show directly on the homepage.'} +

+ )} + {filteredAlbums.map((album) => { + const originalIndex = gallery.albums.findIndex((a) => a.id === album.id); + return ( + removeStandaloneAlbum(originalIndex)} + onEdit={() => + setEditingAlbumAddress({ + type: 'standalone', + albumIndex: originalIndex, + }) + } + /> + ); + })} +
+
+
+
+ + ) : ( + <> + {/* Hero Section */} +
+
+

+ Homepage Hero +

+ +
+ + `hero-${i}`)} + strategy={horizontalListSortingStrategy} + > +
+ {gallery.hero.length === 0 && ( +

+ No hero images configured. Add photos to show a hero carousel on the + homepage. +

+ )} + {gallery.hero.map((id, i) => ( + removeHero(i)} + /> + ))} +
+
+
+
+ )}
- {/* Hero Section */} -
-
-

- Homepage Hero -

- -
- - `hero-${i}`)} - strategy={horizontalListSortingStrategy} - > -
- {gallery.hero.length === 0 && ( -

- No hero images configured. Add photos to show a hero carousel on the homepage. -

- )} - {gallery.hero.map((id, i) => ( - removeHero(i)} - /> - ))} -
-
-
-
- - {/* Standalone Albums */} -
-
-

- - Standalone Albums -

- -
- - { - const originalIndex = gallery.albums.findIndex((x) => x.id === a.id); - return `album-${a.id}-${originalIndex}`; - })} - strategy={verticalListSortingStrategy} - > -
- {filteredAlbums.length === 0 && ( -

- {searchQuery - ? 'No matching standalone albums found.' - : 'No standalone albums. These show directly on the homepage.'} -

- )} - {filteredAlbums.map((album) => { - const originalIndex = gallery.albums.findIndex((a) => a.id === album.id); - return ( - removeStandaloneAlbum(originalIndex)} - onEdit={() => - setEditingAlbumAddress({ type: 'standalone', albumIndex: originalIndex }) - } - /> - ); - })} -
-
-
-
- - {/* Subpages */} -
-
-

- - Subpages -

- -
- - {gallery.subpages.length === 0 && ( -

- No subpages. Create one to group albums under a custom URL path. -

- )} - - {gallery.subpages.length > 0 && filteredSubpages.length === 0 && ( -

No matching subpages found.

- )} - - {/* Collapsed overview grid with DnD */} - - `subpage-${index}`)} - strategy={horizontalListSortingStrategy} - > -
- {filteredSubpages.map(({ sp, index }) => ( - setExpandedSubpage(expandedSubpage === index ? null : index)} - getFirstThumb={getFirstSubpageThumb} - /> - ))} -
-
-
- - {/* Expanded subpage detail (Slide-Over Drawer) */} - {expandedSubpage !== null && gallery.subpages[expandedSubpage] && ( - { - setExpandedSubpage(null); - setLinkedAlbumId(null); - }} - updateSubpage={updateSubpage} - removeSubpage={removeSubpage} - addSection={addSection} - removeSection={removeSection} - updateSection={updateSection} - removeSubpageAlbum={removeSubpageAlbum} - removeSectionAlbum={removeSectionAlbum} - onAlbumDragEnd={handleSubpageAlbumDragEnd(expandedSubpage)} - onPickAlbum={setPickerTarget} - onEditAlbum={setEditingAlbumAddress} - onPickHero={setHeroPickerTarget} - getAlbumName={getAlbumName} - getAlbumCount={getAlbumCount} - getAlbumThumbnailId={getAlbumThumbnailId} - highlightedAlbumId={linkedAlbumId} - /> - )} -
- {/* Album Picker Modal */} {pickerTarget && ( setPickerTarget(null)} - usedAlbumIds={getAllUsedAlbumIds()} + usedAlbumIds={ops.usedAlbumIds(gallery)} /> )} @@ -1146,96 +873,4 @@ export default function PageBuilder() { )}
); - - function getEditingAlbumInfo(addr: ActiveEditAlbumAddress) { - let album: AlbumEntry; - let name: string; - let count: number; - let thumbnailId: string | null; - let onUpdate: (updates: Partial) => void; - let onRemove: () => void; - - if (addr.type === 'standalone') { - album = gallery.albums[addr.albumIndex]; - if (!album) return null; - name = getAlbumName(album.id); - count = getAlbumCount(album.id); - thumbnailId = getAlbumThumbnailId(album.id); - onUpdate = (updates) => { - setGallery((g) => { - const albums = [...g.albums]; - albums[addr.albumIndex] = { ...albums[addr.albumIndex], ...updates }; - return { ...g, albums }; - }); - markDirty(); - }; - onRemove = () => { - removeStandaloneAlbum(addr.albumIndex); - }; - } else if (addr.type === 'subpage') { - const sp = gallery.subpages[addr.subpageIndex!]; - if (!sp) return null; - album = sp.albums[addr.albumIndex]; - if (!album) return null; - name = getAlbumName(album.id); - count = getAlbumCount(album.id); - thumbnailId = getAlbumThumbnailId(album.id); - onUpdate = (updates) => { - setGallery((g) => { - const subpages = [...g.subpages]; - const sp2 = { ...subpages[addr.subpageIndex!] }; - const albums = [...sp2.albums]; - albums[addr.albumIndex] = { ...albums[addr.albumIndex], ...updates }; - sp2.albums = albums; - subpages[addr.subpageIndex!] = sp2; - return { ...g, subpages }; - }); - markDirty(); - }; - onRemove = () => { - removeSubpageAlbum(addr.subpageIndex!, addr.albumIndex); - }; - } else { - const sp = gallery.subpages[addr.subpageIndex!]; - if (!sp) return null; - const sec = sp.sections?.[addr.sectionIndex!]; - if (!sec) return null; - album = sec.albums[addr.albumIndex]; - if (!album) return null; - name = getAlbumName(album.id); - count = getAlbumCount(album.id); - thumbnailId = getAlbumThumbnailId(album.id); - onUpdate = (updates) => { - setGallery((g) => { - const subpages = [...g.subpages]; - const sp2 = { ...subpages[addr.subpageIndex!] }; - const sections = [...(sp2.sections || [])]; - const sec2 = { ...sections[addr.sectionIndex!] }; - const albums = [...sec2.albums]; - albums[addr.albumIndex] = { ...albums[addr.albumIndex], ...updates }; - sec2.albums = albums; - sections[addr.sectionIndex!] = sec2; - sp2.sections = sections; - subpages[addr.subpageIndex!] = sp2; - return { ...g, subpages }; - }); - markDirty(); - }; - onRemove = () => { - removeSectionAlbum(addr.subpageIndex!, addr.sectionIndex!, addr.albumIndex); - }; - } - - return { album, name, count, thumbnailId, onUpdate, onRemove }; - } - - function getAllUsedAlbumIds(): Set { - const ids = new Set(); - gallery.albums.forEach((a) => ids.add(a.id)); - gallery.subpages.forEach((sp) => { - sp.albums.forEach((a) => ids.add(a.id)); - sp.sections?.forEach((sec) => sec.albums.forEach((a) => ids.add(a.id))); - }); - return ids; - } } diff --git a/app/admin/components/PageHeader.tsx b/app/admin/components/PageHeader.tsx new file mode 100644 index 0000000..2f6a047 --- /dev/null +++ b/app/admin/components/PageHeader.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from 'react'; + +/** + * The one screen header (UX stage 2, docs/admin-ux-concept.md): kicker with + * the site's square marker, title, one sentence, actions on the right. Every + * screen used to build its own, each with a different size and position. + */ +export default function PageHeader({ + kicker, + title, + description, + actions, +}: { + kicker: string; + title: ReactNode; + description?: ReactNode; + actions?: ReactNode; +}) { + return ( +
+
+

{kicker}

+

{title}

+ {description &&

{description}

} +
+ {actions &&
{actions}
} +
+ ); +} diff --git a/app/admin/components/PrivacyEditor.tsx b/app/admin/components/PrivacyEditor.tsx new file mode 100644 index 0000000..febfd0d --- /dev/null +++ b/app/admin/components/PrivacyEditor.tsx @@ -0,0 +1,168 @@ +'use client'; + +/** + * Editor for content/privacy.md (#699), in Settings → Legal. + * + * Folio writes no legal text. Next to the editor it lists what this + * installation processes, read off the configuration, and "Insert headings" + * adds the section headings that list implies, nothing more. + * + * Saves on its own, like the About page: the policy is a Markdown file, not a + * key in settings.yaml. + */ + +import { useEffect, useState } from 'react'; +import * as Icons from './Icons'; +import { reportIfSessionExpired } from './sessionExpiry'; +import { useUnsavedGuard } from './useUnsavedGuard'; +import type { ProcessingFact } from '@/lib/privacy'; + +interface PrivacyData { + body: string; + enabled: boolean; + facts: ProcessingFact[]; + starter: string; +} + +type Status = { kind: 'success' | 'error'; message: string } | null; + +export default function PrivacyEditor() { + const [data, setData] = useState(null); + const [loadError, setLoadError] = useState(null); + const [body, setBody] = useState(''); + const [dirty, setDirty] = useState(false); + const [saving, setSaving] = useState(false); + const [status, setStatus] = useState(null); + + useUnsavedGuard(dirty); + + useEffect(() => { + let live = true; + fetch('/api/admin/privacy') + .then(async (res) => { + if (!res.ok) { + reportIfSessionExpired(res); + throw new Error(`HTTP ${res.status}`); + } + return (await res.json()) as PrivacyData; + }) + .then((d) => { + if (!live) return; + setData(d); + setBody(d.body); + }) + .catch((err: Error) => live && setLoadError(err.message)); + return () => { + live = false; + }; + }, []); + + async function save() { + setSaving(true); + setStatus(null); + try { + const res = await fetch('/api/admin/privacy', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ body }), + }); + if (!res.ok) { + if (!reportIfSessionExpired(res)) { + setStatus({ kind: 'error', message: `Could not save (HTTP ${res.status}).` }); + } + return; + } + setDirty(false); + setStatus({ kind: 'success', message: 'Privacy policy saved.' }); + } catch { + setStatus({ kind: 'error', message: 'Could not reach the server.' }); + } finally { + setSaving(false); + } + } + + function insertHeadings() { + if (!data) return; + setBody((b) => (b.trim() ? `${b.trimEnd()}\n\n${data.starter}\n` : `${data.starter}\n`)); + setDirty(true); + } + + if (loadError) { + // No editor over an unknown file: saving an empty one would erase the policy. + return ( +

+ The privacy policy could not be loaded ({loadError}). Reload the page to try again. +

+ ); + } + if (!data) return
; + + return ( +
+
+

What this site processes

+

+ Read off your current configuration. A checklist for writing the policy, not text for the + page. +

+
    + {data.facts.map((f) => ( +
  • + {f.topic} + {f.thirdParty && third party} + {f.detail} +
  • + ))} +
+
+ +
+ +