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} +
  • + ))} +
+
+ +
+ +