Compare commits

...

13 Commits

Author SHA1 Message Date
183910c62f Improve client proofing workflow and link editing 2026-10-04 18:32:37 +03:00
2e6f027b71 Add full editing for client proofing links 2026-10-04 18:16:03 +03:00
fe4c716aa8 Simplify client downloads and add bulk selection 2026-10-04 17:56:45 +03:00
416fae33a2 Hide site navigation on client proofing pages 2026-10-04 15:49:09 +03:00
15dcbf904e Add friendly aliases for client proofing links 2026-10-04 15:49:06 +03:00
Ralksta
c172d170ae docs: README for v0.20.1 (#840)
The README on main still said v0.20.0. Adds the v0.20.1 entry and the
asset.download permission to the upgrade note. Targets main directly;
main is merged back into dev afterwards.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
2026-10-04 10:05:41 +02:00
Ralksta
9270546c9d release: v0.20.1 2026-10-04 09:49:35 +02:00
Ralksta
5f831f719a release: v0.20.0 2026-10-02 18:14:23 +02:00
Ralksta
81ce886b01 release: v0.19.1
Patch release for the Next.js advisory GHSA-vcvr-r3jv-pc5j (critical,
RCE in the Node.js `ImageResponse` from `next/og`).

- #743 — `next` and `eslint-config-next` 16.3.5 → 16.3.6
- #744 — prettier 3.9.8 → 3.9.9 (dev only)
- `chore(release): v0.19.1` — CHANGELOG, README "Latest" line, version

Folio uses `next/og` in `/api/og`. Title and subtitle are site-signed
since #767 and unsigned requests fall back to the site title, so
visitor-chosen text never reached the SVG. The upgrade is still the fix.

Checked on `dev` at 5dab342: `tsc --noEmit` clean, lint 0 errors,
`format:check` clean, 178 files / 2147 tests pass, `npm run build`
succeeds.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
2026-10-01 23:22:13 +02:00
Ralksta
ad8b09700b release: v0.19.0
Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of
[CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md)
for the full list.

Highlights: client proofing links (#683), custom content pages (#738),
asset picker search (#740), concurrent-edit detection (#742), env-locked
settings (#739), an accessibility baseline (#741), three security fixes
with advisories (#737, #755, #759), and a pre-release quality pass
(#780–#797).

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
2026-09-29 22:50:30 +02:00
Ralksta
5f251923f0 release: v0.18.1 (#734)
## Thanks

- [@RichKidsDev](https://github.com/RichKidsDev) for reporting the admin
layout on iPhone (#732).

### Fixed

- **The admin fits a phone**
  ([#733](https://github.com/ralksta/immich-folio/pull/733), reported in
  [#732](https://github.com/ralksta/immich-folio/issues/732) by
[@RichKidsDev](https://github.com/RichKidsDev)). Below 860px the sidebar
  unfolded above the content — navigation, status and four actions — and
filled most of an iPhone screen. It is now a header row with the brand
and
a menu button; the menu opens as one column of full-width rows and
closes
again on navigation, and an unread message shows as a dot on the button.
  Desktop is unchanged.

- **The settings editor refuses prototype keys in a setting's path**
([#731](https://github.com/ralksta/immich-folio/pull/731)). All paths
are
written in the code, so this was not reachable; CodeQL flagged the
setter
  after the v0.18.0 release.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
2026-09-27 16:39:34 +02:00
Ralksta
3193c500e4 release: v0.18.0 (#725)
Release v0.18.0 — merges `dev` into `main`.

## Thanks

- [@lancetm714](https://github.com/lancetm714) for the accent-contrast
fix (#680 → #724).
- [@RichKidsDev](https://github.com/RichKidsDev) for reporting the
outdated Impressum (#697).

### Security

- **Journal drafts and password-protected entries no longer render
publicly
  as a subpage essay**
  ([#684](https://github.com/ralksta/immich-folio/pull/684),

[GHSA-987w-cpwm-c9g9](https://github.com/ralksta/immich-folio/security/advisories/GHSA-987w-cpwm-c9g9),
medium). A subpage's `essayFile` read the journal entry straight from
disk,
skipping the draft and password rules `/journal/<slug>` 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_<slug>` 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/<slug>` without an entry, now return a real
404 with the same not-found page. Album and subpage slugs still depend
on
  Immich and stay soft 404s.

- **Passwords set in the admin are stored as scrypt hashes**
  ([#706](https://github.com/ralksta/immich-folio/pull/706), closes
  [#690](https://github.com/ralksta/immich-folio/issues/690)). Subpage,
album, journal-entry and site passwords were written as typed. The save
routes now hash them; a stored hash reads _Protected_ with _Change_ and
_Remove_ and is never put into an input. Visitors who unlocked a page
stay
signed in across unrelated saves. Existing plaintext passwords are
hashed
  the next time their file is saved in the admin.

- **One notification surface in the admin**
([#707](https://github.com/ralksta/immich-folio/pull/707)) replaces
every
`alert()`: errors stay until dismissed, successes clear themselves, and
  screen readers announce both.

### Fixed

- **Map tiles load again**
([#701](https://github.com/ralksta/immich-folio/pull/701)). CARTO
started
answering every tile with an "API KEY REQUIRED" image, leaving `/map`
and
journal map blocks as a grey grid. Tiles now come from OpenStreetMap;
the
  dark look is a CSS filter, so the map now follows the colour mode.

- **The page builder no longer drops a subpage's `proofing:` flag on
save**
([#711](https://github.com/ralksta/immich-folio/pull/711)). A page
switched
  off for proofing by hand reverted after any unrelated edit.

- **Unsaved edits in Settings and About survive switching admin tabs,
and a
  backup restore is no longer overwritten by the next save**
([#687](https://github.com/ralksta/immich-folio/pull/687)). Editors that
were open during a restore now reload and offer unsaved edits back as a
  conflict. Also: "Restore my changes" after a conflict found nothing,
removing an album left its drawer showing the next album, and the
journal
  saved (rotating a backup) without changes.

- **Accent-backed buttons stay readable with a light or malformed
accent**
  ([#724](https://github.com/ralksta/immich-folio/pull/724), supersedes
[#680](https://github.com/ralksta/immich-folio/pull/680)) — contributed
by
  [@lancetm714](https://github.com/lancetm714). A white accent made the
  proofing dialog's _Download selected (.zip)_ button white on white.
`theme.accent` is now accepted as hex only and normalised to `#rrggbb`,
anything else falls back to the preset, and the proofing dialog and
filter
  pills use the mode-aware `--on-accent` text colour from #688.

- **Every preset's accent is visible in both colour modes**
([#688](https://github.com/ralksta/immich-folio/pull/688)). Presets had
one
accent for both modes; minimal's dark mode drew black buttons on black.
  Presets now carry a light and a dark accent, and text on accent-filled
  buttons picks black or white for contrast. A custom accent stays the
  owner's choice in both modes.

- **Visitor-side inconsistencies**
([#685](https://github.com/ralksta/immich-folio/pull/685)): after
visiting
`/impressum`, every subpage header turned centred and uppercase; back
links
  announced "Back to Back to …"; the hero EXIF chip ignored
`exif.location`/`camera`/`settings`; videos in journal entries opened as
  stills; the setup screen named a non-existent `.env.example`.

- **An essay's lightbox walks only the photos the story shows**
([#686](https://github.com/ralksta/immich-folio/pull/686)), not every
photo
of the subpage's albums, and the proofing bar in essays shows the
selection
  count instead of a filter button that did nothing.

- **The page panel scrolls with the mouse wheel**
  ([#720](https://github.com/ralksta/immich-folio/pull/720)).

- **The journal editor's top bar wraps on phones**
  ([#723](https://github.com/ralksta/immich-folio/pull/723), closes
[#675](https://github.com/ralksta/immich-folio/issues/675)), so the
title
  stays editable and Save stays on screen.

- **Every admin form field has a name for assistive technology**
([#715](https://github.com/ralksta/immich-folio/pull/715)). Only two
inputs
had a linked label; 47 are linked now, and clicking a label focuses its
  field.

### Internal

- **`SettingsEditor` and `PageBuilder` are split up**
  ([#710](https://github.com/ralksta/immich-folio/pull/710),
[#711](https://github.com/ralksta/immich-folio/pull/711)). The
2,200-line
settings editor is a shell with one file per section; the page builder's
YAML handling and edits are pure functions under test. Both were checked
  side by side against `dev` for identical output.
- **`useAdminFetch()`**
([#707](https://github.com/ralksta/immich-folio/pull/707))
gives Analytics, Messages, Backups and the journal list one loading and
  error pattern.
- README brought up to v0.17.0
  ([#678](https://github.com/ralksta/immich-folio/pull/678),
  [#679](https://github.com/ralksta/immich-folio/pull/679)).

### Upgrade notes

**`content/` gains two directories the app writes to:**
`content/.fonts/`
for the cached fonts and `content/messages/` for contact-form messages.
Both
are created on demand, so a writable `content/` is enough, as before.

**The server needs outbound HTTPS to Google once per font** to fill the
font
cache. Until then pages fall back to the theme's system fonts. Visitors'
browsers no longer contact Google.

**A custom CSP or reverse-proxy allowlist** must now allow
`tile.openstreetmap.org` for images instead of
`*.basemaps.cartocdn.com`,
and no longer needs `fonts.googleapis.com`, `fonts.gstatic.com` or
`unpkg.com`. The built-in CSP is updated.

**`theme.accent` must be hex** (`#rgb`, `#rrggbb` or `#rrggbbaa`).
Anything
else, including a value without `#`, falls back to the preset's accent.

**Plaintext passwords in `gallery.yaml`, `settings.yaml` or journal
entries**
are hashed the next time that file is saved in the admin; one save
clears the
doctor's warning.

**New, optional settings:** `contact.*` (the form is off by default),
`privacy.enabled`, `legal.heading`, `legal.contactUrl`,
`legal.contactLabel`,
and the `CDN_URL` and `CONTACT_NOTIFY_URL` environment variables. See
`content/settings.yaml.example` and `.env.local.example`. In the example
file,
`vatId` and `taxId` had swapped sample values; check yours if you copied
them.

No `IMAGE_CACHE_VERSION` bump is needed this time.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
2026-09-27 14:43:38 +02:00
Ralksta
eab3b9c257 docs(readme): bring features and What's New up to v0.17.0 (#679)
The README change from #678 (merged to `dev`), brought to `main` now so
the repository's front page stops describing v0.11.0 until the next
release. README only; `main` and `dev` differed by exactly this file.

Co-authored-by: ralksta <ralksta@users.noreply.github.com>
2026-09-23 12:23:05 +02:00
452 changed files with 46987 additions and 5767 deletions

View File

@@ -30,3 +30,14 @@ eslint.config.mjs
# OS metadata
.DS_Store
# Site content — gallery.yaml, settings.yaml, pages, journal entries,
# credentials (install.json, .setup-token), contact messages, proofing links,
# analytics and backups. The container mounts content/ as a volume, so none of
# it belongs in the build context: a local build from a checkout with a live
# content/ would otherwise bake all of it into the image layers, where the
# mounted volume hides it but `docker save` or a push hands it out. Only the
# tracked templates are let through; the Dockerfile copies them by name.
content
!content/*.example
!content/journal/*.example

View File

@@ -34,6 +34,19 @@ STALE_MAX_AGE=86400
# refetch. Leave it unset in normal operation.
# IMAGE_CACHE_VERSION=1
# Client proofing links: POST a JSON notification here when a client submits
# a selection (Discord, Slack, Gotify, n8n, ...). See
# docs/gallery-config.md#client-proofing-links.
# PROOFING_WEBHOOK_URL=https://discord.com/api/webhooks/...
# 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

View File

@@ -36,7 +36,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: '20'
node-version: '22'
cache: 'npm'
- name: Install dependencies
@@ -115,7 +115,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: '20'
node-version: '22'
cache: 'npm'
- name: Install dependencies

9
.gitignore vendored
View File

@@ -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.
@@ -81,11 +82,19 @@ content/.backups/
# gets its own .backups/ — the top-level pattern above does not reach it.
content/**/.backups/
content/analytics.json
content/proofing.json
content/*.screenshot-bak
# Credentials written by the /install wizard (never committed)
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/
# Content pages (lib/admin/pages-service.ts) and the uploaded favicon
content/pages/
content/favicon.svg
# ── Internal Dev Docs ────────────────────────────────
docs/dev/

File diff suppressed because it is too large Load Diff

View File

@@ -75,6 +75,12 @@ cheaper to test and stays tested when the markup around it changes. Reach for a
component test when the behaviour _is_ the rendering: a state that hides a form,
a control that must stay disabled, a keyboard shortcut.
**Never write into the checkout's `content/`.** On a self-hosted install that
directory is the live site. Services resolve it from `process.cwd()`, so a test
that saves or deletes anything points `cwd` at a temp directory first and
re-imports the service — `lib/__tests__/journal-backup.test.ts` shows how.
`vitest.content-guard.ts` fails any test that writes there anyway.
## Code style
- **TypeScript** — strict mode, no `any` without justification

View File

@@ -24,6 +24,13 @@ ENV AUTH_SECRET="build-placeholder"
RUN npm run build
# The standalone trace copies content/ along: proxy.ts and instrumentation.ts
# read it through runtime paths, which the tracer resolves to the whole
# directory. .dockerignore already keeps a checkout's content out of this
# stage; dropping the traced copy makes sure the runner below gets content/
# from nowhere but the templates it copies by name.
RUN rm -rf .next/standalone/content
# ── Runtime ───────────────────────────────────────
FROM base AS runner
WORKDIR /app
@@ -38,7 +45,16 @@ RUN adduser --system --uid 1001 nextjs
RUN apk add --no-cache su-exec
COPY --from=builder /app/public ./public
COPY --from=builder /app/content ./content
# content/ ships with the templates only. The site's own files come from the
# volume mounted at /app/content; with an empty volume the app shows the setup
# screen and the wizard writes into it. Copying the templates by name rather
# than the whole directory keeps a checkout's real content (credentials,
# contact messages, proofing links, backups) out of the image even if a build
# context ever includes it — .dockerignore is the first line of that.
RUN mkdir -p content/journal
COPY --from=builder /app/content/*.example ./content/
COPY --from=builder /app/content/journal/*.example ./content/journal/
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --chmod=755 docker-entrypoint.sh ./docker-entrypoint.sh
@@ -46,13 +62,19 @@ COPY --chmod=755 docker-entrypoint.sh ./docker-entrypoint.sh
# `npm run doctor` — the config doctor for the terminal (#521). It is the tool
# for the case where the app will not start, so it has to be in the runtime
# image rather than only in a checkout. Node 22.18+ strips the types itself,
# so this is the whole of it: the entry point, the pure checks it imports, and
# js-yaml, which the standalone bundle inlines rather than leaving on disk.
# The standalone build copies package.json verbatim, so the script entry is
# already there.
# so this is the whole of it: the entry point, every module it imports at
# runtime, and js-yaml, which the standalone bundle inlines rather than leaving
# on disk. The standalone build copies package.json verbatim, so the script
# entry is already there. scripts/__tests__/doctor-image.test.ts walks the
# import graph and fails when a module of it is missing from this list.
COPY --from=builder /app/scripts/doctor.mjs ./scripts/doctor.mjs
COPY --from=builder /app/scripts/doctor.mts ./scripts/doctor.mts
COPY --from=builder /app/lib/admin/doctor.ts ./lib/admin/doctor.ts
COPY --from=builder /app/lib/config/settingValues.ts ./lib/config/settingValues.ts
COPY --from=builder /app/lib/config/schema.ts ./lib/config/schema.ts
COPY --from=builder /app/lib/config/theme.ts ./lib/config/theme.ts
COPY --from=builder /app/lib/config/presets.ts ./lib/config/presets.ts
COPY --from=builder /app/lib/siteUrl.ts ./lib/siteUrl.ts
COPY --from=deps /app/node_modules/js-yaml ./node_modules/js-yaml
# Content dir must be writable for admin saves (ownership fixed at runtime)

View File

@@ -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.20.1** — a single click zooms the lightbox in, and the setup guide now names the `asset.download` permission that zoom and downloads need. → [What's New](#whats-new)
## Contents
- [Features](#features)
@@ -38,7 +40,7 @@ A self-hosted portfolio powered by [Immich](https://immich.app). It acts as a **
- **Justified rows** _(experimental)_ — every row fills the width at one shared height, aspect ratios intact, nothing cropped
- **Per-subpage grid overrides** — each subpage can define its own columns, gap, aspect ratio, and layout mode, and individual albums can override that again _(experimental)_
- **Cover focal points** _(experimental)_ — decide which part of a cover survives the crop
- **Fullscreen lightbox** — keyboard and swipe navigation, EXIF panel, adjacent image preloading
- **Fullscreen lightbox** — keyboard and swipe navigation, EXIF panel, adjacent image preloading, and opt-in 1:1 zoom to full resolution for checking sharpness ([guide](docs/gallery-config.md#photo-zoom))
- **EXIF metadata on hover** — camera body, lens, focal length, aperture, shutter speed, ISO shown directly on the grid
- **ThumbHash placeholders** — instant blurred previews while full images load
@@ -59,14 +61,16 @@ A self-hosted portfolio powered by [Immich](https://immich.app). It acts as a **
- **Auto-generated slugs** — URL slugs derived from album names automatically
- **YAML gallery config** — all gallery structure defined in a single `content/gallery.yaml` file
- **Markdown about page** — `content/about.md` with frontmatter for portrait, name, location, and gear list, editable from the admin panel
- **Journal** — photo essays and travel stories at `/journal`, with drafts, per-entry passwords, cover images and reading times
- **Photo Essay mode** — long-form storytelling pages alternating text with fullbleed, paired and grid image layouts, facts lists and a map of the entry's geotagged photos
- **Journal** — photo essays and travel stories at `/journal`, with drafts, per-entry passwords, cover images and reading times; new entries can start from a template (wedding, hiking, travel, …)
- **Photo Essay mode** — long-form storytelling pages alternating text with fullbleed, paired and grid image layouts, facts lists, a slice of an album, and a map with the pins you choose — typed places, or photos placed by their GPS
- **Unlisted subpages** _(experimental)_ — reachable by direct link, absent from the navigation
- **Subpage on/off toggle** — take a page offline without deleting it
- **External navigation links** _(experimental)_ — point the header at a shop, a blog, or a social profile
- **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
- **Client proofing** — clients favorite photos and export the selection; picks stay in the visitor's browser and the URL, with nothing stored server-side (proofing links, below, do save them)
- **Client proofing links** — a private link per client and album, picks saved server-side, a submit that locks the selection and pings you via webhook, expiry dates, download limits, and a Lightroom-ready export of the chosen file names ([guide](docs/gallery-config.md#client-proofing-links))
- **Originals delivery** — per-album opt-in lets visitors download a whole album or their proofing selection as a ZIP of the originals, with GPS coordinates removed from JPEG, HEIC and AVIF files
- **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
@@ -87,8 +91,10 @@ A self-hosted portfolio powered by [Immich](https://immich.app). It acts as a **
- **Album picker** — browse all shared Immich albums with search, see photo counts, and add them with one click
- **Settings editor** — configure theme, grid layout, footer, legal/impressum, SEO, image protection and the about page from a visual UI
- **Visual previews everywhere** — grid layout, theme, photo frame, hero style and the Google search snippet are picked from preview cards instead of text fields
- **Journal Studio** — split-screen block editor with a live preview of the real page
- **Journal Studio** — split-screen block editor with a live preview of the real page; start from a template, drag blocks into order
- **Essay block editor** — assemble photo essays block by block without touching Markdown
- **Unsaved edits survive** — leave the page builder or the journal editor mid-edit, and your changes are waiting when you come back
- **Diagnostics** — a page that checks the Immich connection, config and security settings, and links every finding to its fix
- **Photo order editor** — drag & drop a hand-picked opening sequence for any album
- **Favicon upload** — give the site its own icon in the browser tab
- **Backup manager** — every save is backed up automatically; restore any of them with one click
@@ -112,6 +118,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)
@@ -217,6 +224,9 @@ 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
PROOFING_WEBHOOK_URL=https://… # notify on submitted client proofing selections
```
> 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).
@@ -227,11 +237,14 @@ WEBHOOK_SECRET=long-random-string # enables POST /api/webhook cache invalida
Create a dedicated API key in Immich under **Account Settings → API Keys**. Immich Folio only needs **read access** — it never modifies your library.
| Permission | Required | Used for |
| ------------ | -------- | -------------------------------------------------------------------- |
| `album.read` | ✅ Yes | List and fetch album metadata & photo lists |
| `asset.read` | ✅ Yes | Fetch asset metadata, EXIF data, thumbnails, previews, and originals |
| `asset.view` | ✅ Yes | Stream image/video files (thumbnail, preview, video playback) |
| Permission | Required | Used for |
| ---------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `album.read` | ✅ Yes | List and fetch album metadata & photo lists |
| `asset.read` | ✅ Yes | Fetch asset metadata, EXIF data and search results |
| `asset.view` | ✅ Yes | Stream image/video files (thumbnail, preview, video playback) |
| `asset.download` | Only for zoom and downloads | Stream originals: [photo zoom](docs/gallery-config.md#photo-zoom) on JPEG/AVIF and [originals download](docs/gallery-config.md#originals-download) |
> **Zoom and downloads need `asset.download`.** Without it Immich answers `403` for the original file, so the zoom button reports that full resolution is unavailable and downloads fail. Leave it off if you use neither feature.
> **No write permissions needed.** `album.create`, `asset.upload`, `asset.delete`, etc. can all be left **off**.
@@ -251,7 +264,7 @@ Long-form storytelling with fullbleed photos, side-by-side pairs and grids, quot
### Theming
Seven built-in presets with distinct visual identities — or mix and match with fine-grained control over colors, fonts, corners, photo frames, hero layout, and grid style.
Twelve built-in presets with distinct visual identities — or mix and match with fine-grained control over colors, fonts, corners, photo frames, hero layout, and grid style.
**Studio Modern** is the default: Leica precision rebuilt around the Archivo grotesque, with IBM Plex Mono for every piece of photographic metadata, hairline rules, zero radius, and red as signal only.
@@ -262,7 +275,8 @@ Seven built-in presets with distinct visual identities — or mix and match with
<p align="center"><em>Studio Modern, dark and light — every preset ships both, and visitors switch with a toggle in the navigation bar.</em></p>
```yaml
theme: studio-modern # or: studio, minimal, editorial, classic, noir, monograph
theme: studio-modern # or: studio, minimal, editorial, classic, noir, monograph,
# kunsthalle, ma, cyanotype, salon, birch
```
→ **[View all Themes & Configuration Guide](docs/theming.md)**
@@ -322,11 +336,11 @@ own password, separate from any album passwords, and writes straight to
<p align="center">
<img src="docs/screenshots/admin-login.png" width="49%" alt="Admin panel login screen" />
<img src="docs/screenshots/admin-page-builder.png" width="49%" alt="Visual page builder with hero images, standalone albums and subpages" />
<img src="docs/screenshots/admin-page-builder.png" width="49%" alt="Page builder: the site structure on the left, the selected page with its albums on the right" />
</p>
<p align="center">
<img src="docs/screenshots/admin-album-picker.png" width="49%" alt="Album picker listing shared Immich albums with photo counts" />
<img src="docs/screenshots/admin-settings.png" width="49%" alt="Settings editor with site identity and feature toggles" />
<img src="docs/screenshots/admin-settings.png" width="49%" alt="Settings editor: sections on the left, site identity and feature switches grouped by meaning" />
</p>
<p align="center"><em>Login · page builder · album picker · settings editor</em></p>
@@ -334,17 +348,20 @@ own password, separate from any album passwords, and writes straight to
## What's New
v0.11.0 — a first-run wizard, a journal, and a set of experimental portfolio
features:
v0.20.1 — zoom by a single click, and the API key permission it needs:
- **First-run setup wizard at `/install`** — configure a fresh deployment from the browser instead of writing config files → [First-Run Setup](#first-run-setup)
- **Journal** — photo essays and travel stories at `/journal`, with a split-screen block editor, drafts, per-entry passwords and cover images → [Journal guide](docs/journal.md)
- **About page editor** — portrait, name, location, gear and bio edited in the admin panel, plus a toggle to take the page offline
- **Custom favicon upload** — SVG, PNG, ICO or JPEG, stored in the content volume
- **Experimental portfolio features** — justified grid layout, `cover` hero splash screen, unlisted subpages, per-album grid overrides, cover focal points, external navigation links
- **Admin panel** — every area has its own URL, a floating save bar, and a drag & drop photo-order editor
- **Single-click zoom** — a click on the lightbox photo zooms in by 1.5×, a second click goes back; the magnifier button and `+` still go to 1:1 ([guide](docs/gallery-config.md#photo-zoom))
- **`asset.download` for zoom and downloads** — zoom into JPEG/AVIF photos and original downloads stream the original from Immich, which needs this permission on the API key ([permissions](#immich-api-key-permissions))
No migration and no configuration change are required to upgrade.
v0.20.0 — zoom, five new presets and photos as you edited them:
- **1:1 zoom in the lightbox** — click, pinch, Ctrl + scroll or press `+` to see a photo at full resolution and judge its sharpness; off by default, switched on for the site, a page or a single album ([guide](docs/gallery-config.md#photo-zoom))
- **Five new theme presets** — Kunsthalle (a grey gallery wall with numbered wall labels), Ma, Cyanotype, Salon and Birch, each in dark and light; the passepartout mat of Studio, Classic and Noir is finally visible too
- **Edits made in Immich show up** — crops and rotations from Immich's editor appear in the grid, the lightbox, share images and downloads
- **No GPS in downloads** — original downloads and ZIPs keep camera data and colour profile but lose the coordinates and sub-city place names ([advisory](https://github.com/ralksta/immich-folio/security/advisories/GHSA-g4xf-4285-4cv4))
- **Legal pages on a locked site** — with a site password set, the Impressum, the privacy page and the contact form stay reachable, and unknown pages answer a real 404
**Upgrade note:** zoom needs `zoom: true`, the `asset.download` permission on the Immich API key, and for HEIC/RAW photos Immich's _Full-size image_ setting in JPEG format. If you build the Docker image yourself, keep `content/` mounted as a volume; if you ever pushed a self-built image, see the [changelog](CHANGELOG.md) for what to rotate.
Security fixes ship in normal releases, so **running the latest release is the
recommended baseline**.
@@ -373,7 +390,16 @@ 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. In v0.19.0, gave the proofing dialog's
_Email to photographer_ an actual recipient.
- **[@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. In v0.19.0, reported that _Email to photographer_ opened a
mail with nobody in the _To:_ line.
- **[@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

View File

@@ -1,8 +1,14 @@
/**
* Instant shell for the homepage.
*
* It lives in the (home) route group, not in app/, on purpose. A loading.tsx
* in app/ wraps every route below the root layout in a Suspense boundary, and
* a page inside one streams its 200 before it can call notFound() — which made
* every unknown slug the catch-all answers a soft 404. See
* app/[...path]/page.tsx.
*/
import styles from './loading.module.css';
import styles from '../loading.module.css';
import { getServerDictionary } from '@/lib/i18n/server';
export default function Loading() {

View File

@@ -7,73 +7,63 @@
import Link from 'next/link';
import Image from 'next/image';
import { immich, type ImmichAsset } from '@/lib/immich';
import { getConfig } from '@/lib/config';
import { getConfig, type ExifDisplayConfig } from '@/lib/config';
import { assetLocationPrecision } from '@/lib/assetLocation';
import { placeLabel } from '@/lib/mapPrecision';
import { imageUrl, assetPlaceholder } from '@/lib/urls';
import { HeroCarousel } from '@/components/HeroCarousel';
import { FadeIn } from '@/components/FadeIn';
import { getServerDictionary } from '@/lib/i18n/server';
import type { Dictionary } from '@/lib/i18n';
import { loadSiteNav } from '@/lib/siteNav.server';
import type { SiteNavLink } from '@/lib/siteNav';
// Render at request time — requires live Immich connection
export const dynamic = 'force-dynamic';
type HeroSubpage = { slug: string; name: string; albumCount: number };
type HeroAlbum = { id: string; slug: string; albumName: string; assetCount: number };
const pad2 = (n: number) => String(n).padStart(2, '0');
/**
* Flatten subpages + standalone albums into one indexed nav list.
* Every entry carries an index and a count; presets decide what to show.
*/
function heroNavEntries(subpages: HeroSubpage[], albums: HeroAlbum[], t: Dictionary) {
return [
...subpages.map((sp) => ({
key: `sp-${sp.slug}`,
href: `/${sp.slug}`,
label: sp.name,
count: t.common.albums(sp.albumCount),
})),
...albums.map((a) => ({
key: `al-${a.id}`,
href: `/${a.slug}`,
label: a.albumName,
count: t.common.photos(a.assetCount),
})),
];
}
/**
* Camera line for the hero chip: "Q3 · 28MM · ƒ/5.6 · 1/250 · ISO 200".
* Returns undefined when the asset carries no usable EXIF.
*
* Honours the same `exif:` groups as the lightbox panel (`/api/exif`), and the
* city passes the album's `location:` precision too — the chip is the most
* visible EXIF on the site and must not publish what the owner switched off.
*/
function heroExifLine(asset: ImmichAsset): string | undefined {
async function heroExifLine(
asset: ImmichAsset,
show: ExifDisplayConfig,
): Promise<string | undefined> {
const e = asset.exifInfo;
if (!e) return undefined;
const city = show.location
? placeLabel(await assetLocationPrecision(asset.id), { city: e.city ?? '', country: '' }).city
: '';
const parts = [
e.city || e.model || undefined,
e.focalLength ? `${Math.round(e.focalLength)}mm` : undefined,
e.fNumber ? `ƒ/${e.fNumber}` : undefined,
e.exposureTime ? `${e.exposureTime}s` : undefined,
e.iso ? `ISO ${e.iso}` : undefined,
city || (show.camera ? e.model : undefined) || undefined,
show.camera && e.focalLength ? `${Math.round(e.focalLength)}mm` : undefined,
show.settings && e.fNumber ? `ƒ/${e.fNumber}` : undefined,
show.settings && e.exposureTime ? `${e.exposureTime}s` : undefined,
show.settings && e.iso ? `ISO ${e.iso}` : undefined,
].filter(Boolean);
return parts.length > 0 ? parts.join(' · ') : undefined;
}
/** Shared nav links for hero sections. */
function HeroNavLinks({ subpages, albums }: { subpages: HeroSubpage[]; albums: HeroAlbum[] }) {
const t = getServerDictionary();
/** Shared nav links for hero sections — same entries and order as the header. */
function HeroNavLinks({ links }: { links: SiteNavLink[] }) {
return (
<>
{heroNavEntries(subpages, albums, t).map((entry, i) => (
{links.map((entry, i) => (
<Link key={entry.key} href={entry.href} className="hero__nav-link">
<span className="hero__nav-index" aria-hidden="true">
{pad2(i + 1)}
</span>
<span className="hero__nav-label">{entry.label}</span>
<span className="hero__nav-count" aria-hidden="true">
{entry.count}
</span>
{entry.count && (
<span className="hero__nav-count" aria-hidden="true">
{entry.count}
</span>
)}
</Link>
))}
</>
@@ -84,13 +74,13 @@ function HeroNavLinks({ subpages, albums }: { subpages: HeroSubpage[]; albums: H
function HeroTextContent({
title,
subtitle,
subpages,
albums,
links,
navLabel,
}: {
title: string;
subtitle?: string;
subpages: HeroSubpage[];
albums: HeroAlbum[];
links: SiteNavLink[];
navLabel: string;
}) {
return (
<>
@@ -103,8 +93,8 @@ function HeroTextContent({
</FadeIn>
)}
<FadeIn delay={200}>
<nav className="hero__nav">
<HeroNavLinks subpages={subpages} albums={albums} />
<nav className="hero__nav" aria-label={navLabel}>
<HeroNavLinks links={links} />
</nav>
</FadeIn>
</>
@@ -119,19 +109,16 @@ export default async function HomePage() {
return null;
}
const [subpages, albums] = await Promise.all([
immich.getSubpages(),
immich.getStandaloneAlbums(),
]);
const links = await loadSiteNav();
// Fetch ThumbHash + camera line for all hero images
const heroData = await Promise.all(
config.heroImages.map(async (id) => {
const asset = await immich.getAssetInfo(id);
const ph = asset ? assetPlaceholder(asset) : null;
const exif = asset ? heroExifLine(asset) : undefined;
const exif = asset ? await heroExifLine(asset, config.exif) : undefined;
return {
src: imageUrl(id, 'preview'),
src: imageUrl(asset ?? id, 'preview'),
...(ph ? { blurDataURL: ph.blurDataURL } : {}),
...(exif ? { exif } : {}),
};
@@ -144,8 +131,8 @@ export default async function HomePage() {
// Welcome-page splash: one fullbleed image, the site
// title, and one link into the portfolio (the first nav entry).
if (heroStyle === 'cover') {
const entries = heroNavEntries(subpages, albums, t);
const enterHref = entries[0]?.href ?? '/about';
// No fallback: /about used to be hardcoded here and 404ed with About off.
const enterHref = links[0]?.href;
return (
<div className="hero hero--cover">
@@ -159,14 +146,16 @@ export default async function HomePage() {
<p className="hero__subtitle">{config.siteSubtitle}</p>
</FadeIn>
)}
<FadeIn delay={250}>
<Link href={enterHref} className="hero__cover-enter">
{t.home.enter}
<span className="hero__cover-enter-arrow" aria-hidden="true">
→
</span>
</Link>
</FadeIn>
{enterHref && (
<FadeIn delay={250}>
<Link href={enterHref} className="hero__cover-enter">
{t.home.enter}
<span className="hero__cover-enter-arrow" aria-hidden="true">
→
</span>
</Link>
</FadeIn>
)}
</div>
</div>
);
@@ -189,8 +178,8 @@ export default async function HomePage() {
<div className="hero__divider" />
</FadeIn>
<FadeIn delay={300}>
<nav className="hero__nav hero__nav--indexed">
<HeroNavLinks subpages={subpages} albums={albums} />
<nav className="hero__nav hero__nav--indexed" aria-label={t.nav.heroNavAria}>
<HeroNavLinks links={links} />
</nav>
</FadeIn>
</div>
@@ -200,8 +189,6 @@ export default async function HomePage() {
// ── Stacked: fullbleed image + text at bottom + thumbnail strip ─
if (heroStyle === 'stacked') {
const allEntries = heroNavEntries(subpages, albums, t);
return (
<div className="hero hero--stacked">
<div className="hero__stacked-image">
@@ -218,8 +205,8 @@ export default async function HomePage() {
</div>
</div>
<FadeIn delay={200}>
<nav className="hero__thumbnail-strip">
{allEntries.map((entry, i) => (
<nav className="hero__thumbnail-strip" aria-label={t.nav.heroNavAria}>
{links.map((entry, i) => (
<Link key={entry.key} href={entry.href} className="hero__thumbnail-item">
<span className="hero__thumbnail-index" aria-hidden="true">
{pad2(i + 1)}
@@ -257,8 +244,8 @@ export default async function HomePage() {
<HeroTextContent
title={config.siteTitle}
subtitle={config.siteSubtitle}
subpages={subpages}
albums={albums}
links={links}
navLabel={t.nav.heroNavAria}
/>
</div>
</div>
@@ -273,8 +260,8 @@ export default async function HomePage() {
<HeroTextContent
title={config.siteTitle}
subtitle={config.siteSubtitle}
subpages={subpages}
albums={albums}
links={links}
navLabel={t.nav.heroNavAria}
/>
</div>
</div>
@@ -290,8 +277,8 @@ export default async function HomePage() {
<HeroTextContent
title={config.siteTitle}
subtitle={config.siteSubtitle}
subpages={subpages}
albums={albums}
links={links}
navLabel={t.nav.heroNavAria}
/>
</div>
</div>
@@ -307,15 +294,15 @@ export default async function HomePage() {
<HeroTextContent
title={config.siteTitle}
subtitle={config.siteSubtitle}
subpages={subpages}
albums={albums}
links={links}
navLabel={t.nav.heroNavAria}
/>
</div>
</div>
{/* ── Right Panel (Hero Carousel) ─────────────── */}
<div className="hero__right">
<HeroCarousel images={heroData} />
<HeroCarousel images={heroData} sizes="(max-width: 640px) 100vw, 50vw" />
</div>
</div>
);

View File

@@ -11,14 +11,16 @@ import { albumMetaDetail } from '@/lib/albumMeta';
import { AlbumNav } from '@/components/AlbumNav';
import { StructuredData } from '@/components/StructuredData';
import type { AlbumNavPair } from '@/lib/albumNav';
import type { ProofSessionInit } from '@/components/ProofingContext';
interface AlbumDetailViewProps {
album: ImmichAlbum;
images: PhotoItem[];
layout: GridConfig['layout'];
gridStyle: React.CSSProperties;
backLinkHref: string;
backLinkLabel: string;
/** Omitted on a client proofing link, which has nowhere to go back to. */
backLinkHref?: string;
backLinkLabel?: string;
subtitle?: string;
heroImageUrl?: string;
heroBlurDataURL?: string;
@@ -31,6 +33,12 @@ interface AlbumDetailViewProps {
proofing?: boolean;
/** Offer the "send by email" button in the proofing modal. */
allowMailto?: boolean;
/**
* `encodeEmail()` of where the proofing dialog's "email to photographer"
* goes, when one is configured (#736). Encoded, like every address the site
* renders, so the plain form stays out of the HTML and the RSC payload.
*/
encodedMailto?: string;
/**
* The ZIP endpoint for this album, when it offers downloads (#475). Renders a
* "download album" link in the header and lets the proofing modal offer a
@@ -41,6 +49,10 @@ interface AlbumDetailViewProps {
nav?: AlbumNavPair;
/** JSON-LD for this album, or null when no site URL is configured (#472). */
structuredData?: Record<string, unknown> | null;
/** A client proofing link's session; see PhotoGrid. */
proofSession?: ProofSessionInit;
/** Rendered under the title — the proofing link's greeting and notes. */
intro?: React.ReactNode;
}
export function AlbumDetailView({
@@ -58,9 +70,12 @@ export function AlbumDetailView({
showGear = true,
proofing,
allowMailto,
encodedMailto,
downloadArchiveUrl,
nav,
structuredData,
proofSession,
intro,
}: AlbumDetailViewProps) {
const metaDetail = albumMetaDetail(album, showGear);
const t = getServerDictionary();
@@ -86,7 +101,7 @@ export function AlbumDetailView({
)}
<div className={`album-header${heroImageUrl ? ' album-header--has-hero' : ''}`}>
<div className="album-header__main">
<BackLink href={backLinkHref} label={backLinkLabel} />
{backLinkHref && <BackLink href={backLinkHref} label={backLinkLabel ?? ''} />}
<h1 className="album-header__title">{album.albumName}</h1>
{subtitle && (
<p className="subpage-subtitle" style={{ textAlign: 'left', marginLeft: 0 }}>
@@ -94,6 +109,7 @@ export function AlbumDetailView({
</p>
)}
{album.description && <p className="album-header__description">{album.description}</p>}
{intro}
{downloadArchiveUrl && (
<a className="album-header__download" href={downloadArchiveUrl}>
<svg
@@ -127,8 +143,10 @@ export function AlbumDetailView({
showExifPanel={showExifPanel}
proofing={proofing}
allowMailto={allowMailto}
encodedMailto={encodedMailto}
downloadArchiveUrl={downloadArchiveUrl}
albumName={album.albumName}
proofSession={proofSession}
/>
{nav && <AlbumNav {...nav} />}
</>

View File

@@ -2,16 +2,32 @@
import { useState, useMemo } from 'react';
import Image from 'next/image';
import dynamic from 'next/dynamic';
import { Lightbox, type LightboxWatermark } from '@/components/Lightbox';
import { ProofingProvider, useProofing } from '@/components/ProofingContext';
import { ProofingModal } from '@/components/ProofingModal';
import { useProofing } from '@/components/useProofing';
import { ProofingProvider, ProofingModal } from '@/components/ProofingLazy';
import { FadeIn } from '@/components/FadeIn';
import { LeafletMap, escapeHtml } from '@/components/LeafletMap';
import { escapeHtml } from '@/lib/escapeHtml';
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';
import { formatJournalDate, journalDateTime } from '@/lib/journalDate';
/**
* The map block is the only thing in an essay that needs Leaflet, and most
* essays have none. Imported statically, LeafletMap put its wrapper and
* Leaflet's stylesheet (render-blocking CSS) on every album, subpage and
* journal page. The map only ever drew on the client — Leaflet itself was
* already imported inside an effect — so the server renders the same empty
* container it rendered before, and the wrapper arrives with the map.
*/
const LeafletMap = dynamic(() => import('@/components/LeafletMap').then((m) => m.LeafletMap), {
ssr: false,
loading: () => <div className="essay-map" />,
});
interface EssayViewProps {
essay: ParsedEssay;
@@ -27,6 +43,8 @@ interface EssayViewProps {
proofing?: boolean;
/** Offer the "send by email" button in the proofing modal. */
allowMailto?: boolean;
/** `encodeEmail()` of the proofing recipient, when one is configured (#736). */
encodedMailto?: string;
}
function EssayViewContent({
@@ -60,19 +78,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 (
<div
key={key}
className="essay-image-wrapper photo-grid__item"
onClick={() => setLightboxIndex(index)}
onClick={() => openPhoto(item)}
style={{
...(item.dominantColor ? { backgroundColor: item.dominantColor } : {}),
flexGrow: item.aspectRatio ?? 1.5,
@@ -111,7 +145,7 @@ function EssayViewContent({
background: 'none',
border: 'none',
cursor: 'pointer',
color: isFav ? '#ff4d4f' : 'rgba(255,255,255,0.85)',
color: isFav ? 'var(--lightbox-fav)' : 'rgba(255,255,255,0.85)',
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.6))',
}}
>
@@ -166,7 +200,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 +208,7 @@ function EssayViewContent({
<figure className={`essay-figure essay-figure--${block.layout}`}>
<div
className="essay-image-wrapper photo-grid__item"
onClick={() => setLightboxIndex(index)}
onClick={() => openPhoto(item)}
style={{
...(item.dominantColor ? { backgroundColor: item.dominantColor } : {}),
...(item.aspectRatio ? { aspectRatio: `${item.aspectRatio}` } : {}),
@@ -219,7 +253,7 @@ function EssayViewContent({
background: 'none',
border: 'none',
cursor: 'pointer',
color: isFav ? '#ff4d4f' : 'rgba(255,255,255,0.85)',
color: isFav ? 'var(--lightbox-fav)' : 'rgba(255,255,255,0.85)',
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.6))',
}}
>
@@ -372,14 +406,22 @@ function EssayViewContent({
}
};
// The story's photos that are selected, not the whole stored set (see PhotoGrid).
const selectedCount = proofing ? proofing.getSelectedTokens().length : 0;
const dateText = formatJournalDate(essay.frontmatter.date, t.dateLocale);
const dateTime = journalDateTime(essay.frontmatter.date);
return (
<div className="essay-container">
<header className="essay-header">
{displayTitle && <h1 className="essay-header__title">{displayTitle}</h1>}
{displaySubtitle && <p className="essay-header__subtitle">{displaySubtitle}</p>}
{(essay.frontmatter.author || essay.frontmatter.date) && (
{(essay.frontmatter.author || dateText) && (
<div className="essay-header__meta">
{[essay.frontmatter.author, essay.frontmatter.date].filter(Boolean).join(' • ')}
{essay.frontmatter.author}
{essay.frontmatter.author && dateText && ' • '}
{dateText && (dateTime ? <time dateTime={dateTime}>{dateText}</time> : dateText)}
</div>
)}
</header>
@@ -388,7 +430,7 @@ function EssayViewContent({
<figure className="essay-figure essay-figure--wide" style={{ marginBottom: '4rem' }}>
<div
className="essay-image-wrapper photo-grid__item"
onClick={() => setLightboxIndex(coverAsset.index)}
onClick={() => openPhoto(coverAsset.item)}
style={{
...(coverAsset.item.dominantColor
? { backgroundColor: coverAsset.item.dominantColor }
@@ -412,9 +454,9 @@ function EssayViewContent({
</figure>
)}
<main>{essay.blocks.map(renderBlock)}</main>
<div>{essay.blocks.map(renderBlock)}</div>
{proofing && proofing.favorites.size > 0 && (
{proofing && selectedCount > 0 && (
<div
className="proofing-sticky-bar"
style={{
@@ -426,7 +468,7 @@ function EssayViewContent({
alignItems: 'center',
gap: '12px',
padding: '8px 16px',
borderRadius: '30px',
borderRadius: 'var(--radius-lg)',
background: 'var(--bg-card, #1e1e1e)',
color: 'var(--text-primary, #ffffff)',
border: '1px solid var(--border-subtle, rgba(255,255,255,0.15))',
@@ -434,26 +476,11 @@ function EssayViewContent({
backdropFilter: 'blur(8px)',
}}
>
<button
type="button"
onClick={() => proofing.setIsFilterActive((prev) => !prev)}
style={{
background: proofing.isFilterActive
? 'var(--accent, #e60012)'
: 'var(--bg-card-hover)',
color: proofing.isFilterActive ? '#fff' : 'var(--text-primary)',
border: 'none',
padding: '6px 14px',
borderRadius: '20px',
fontSize: '0.85rem',
fontWeight: 500,
cursor: 'pointer',
}}
>
{proofing.isFilterActive
? t.proofing.showAll
: t.proofing.selected(proofing.favorites.size)}
</button>
{/* A count, not the album grid's filter toggle: a story's layout is
authored, so there is nothing for "show favourites" to narrow. */}
<span style={{ fontSize: '0.85rem', fontWeight: 500, padding: '6px 4px' }}>
{t.proofing.selected(selectedCount)}
</span>
<button
type="button"
onClick={() => proofing.setIsModalOpen(true)}
@@ -476,15 +503,15 @@ function EssayViewContent({
{lightboxIndex !== null && (
<Lightbox
assets={assets}
assets={sequence}
currentIndex={lightboxIndex}
onClose={() => setLightboxIndex(null)}
onNext={() =>
setLightboxIndex((prev) => (prev !== null ? (prev + 1) % assets.length : null))
setLightboxIndex((prev) => (prev !== null ? (prev + 1) % sequence.length : null))
}
onPrev={() =>
setLightboxIndex((prev) =>
prev !== null ? (prev - 1 + assets.length) % assets.length : null,
prev !== null ? (prev - 1 + sequence.length) % sequence.length : null,
)
}
watermark={watermark}
@@ -511,6 +538,7 @@ export function EssayView(props: EssayViewProps) {
// default key with every album (and story) on the site.
albumName={props.title || props.essay.frontmatter.title}
allowMailto={props.allowMailto ?? true}
encodedMailto={props.encodedMailto}
>
<EssayViewContent {...props} />
</ProofingProvider>

View File

@@ -0,0 +1,28 @@
/**
* Placeholder for album and subpage content while Immich answers. These routes
* are force-dynamic and wait on a live Immich round-trip — up to
* IMMICH_TIMEOUT_MS when the server is struggling — so without this the viewer
* stares at a blank page.
*
* This used to be app/[...path]/loading.tsx. It is a Suspense fallback inside
* the page now, placed after the page has decided whether the path exists: a
* loading.tsx wraps the whole page, so the skeleton went out with a 200 before
* the page could call notFound().
*/
import styles from '../loading.module.css';
import { getServerDictionary } from '@/lib/i18n/server';
export function PathSkeleton() {
const t = getServerDictionary();
return (
<>
<div className={styles.heading} aria-hidden="true" />
<div className={styles.grid} role="status" aria-label={t.common.loadingPhotos}>
{Array.from({ length: 9 }).map((_, i) => (
<div key={i} className={styles.tile} />
))}
</div>
</>
);
}

View File

@@ -15,10 +15,14 @@ import { useState, useCallback, useEffect, useMemo } from 'react';
import Image from 'next/image';
import { Lightbox, type LightboxWatermark } from '@/components/Lightbox';
import { FadeIn } from '@/components/FadeIn';
import { ProofingProvider, useProofing } from '@/components/ProofingContext';
import { ProofingModal } from '@/components/ProofingModal';
import type { ProofSessionInit } from '@/components/ProofingContext';
import { useProofing } from '@/components/useProofing';
import { ProofingProvider, ProofingModal, ProofSessionControls } from '@/components/ProofingLazy';
import { useDictionary } from '@/components/I18nProvider';
import { parsePhotoHash, parsePhotoQuery, buildPhotoQuery } from '@/lib/photoHash';
import { justifiedTileStyle } from '@/lib/justifiedRow';
const pad2 = (n: number) => String(n).padStart(2, '0');
export interface PhotoItem {
id: string;
@@ -39,6 +43,14 @@ export interface PhotoItem {
caption?: string;
/** Present only when the surrounding album offers its originals (#475). */
downloadUrl?: string;
/**
* The full-resolution image for the lightbox zoom, fetched only when the
* visitor zooms (#467). Present only where zoom resolves on and the photo
* has a zoomable source; the size is the image's pixel size, upright.
*/
zoomUrl?: string;
zoomWidth?: number;
zoomHeight?: number;
}
interface PhotoGridProps {
@@ -60,6 +72,12 @@ interface PhotoGridProps {
proofing?: boolean;
/** Offer the "send by email" button in the proofing modal. */
allowMailto?: boolean;
/**
* `encodeEmail()` of where the proofing dialog's "email to photographer"
* goes, when one is configured (#736). Lets the modal address the compose;
* without it the button is hidden.
*/
encodedMailto?: string;
/**
* The ZIP endpoint for this album, when it offers downloads. Lets the
* proofing modal offer a "download selected" action (#475).
@@ -71,6 +89,11 @@ interface PhotoGridProps {
* selection made in one album leaks into the next.
*/
albumName?: string;
/**
* A client proofing link's session (/proof/<token>). Turns proofing on and
* keeps the selection on the server instead of in the browser.
*/
proofSession?: ProofSessionInit;
}
function PhotoGridInner({
@@ -102,11 +125,25 @@ function PhotoGridInner({
setLightboxIndex(byToken);
return;
}
// A `photo` that names no photo in this album — mistyped, truncated by a
// messenger, or a position like `?photo=5` (that was never a format) —
// opens nothing, so it should not stay in the address bar to be copied
// and shared on. Checked against the whole album, not `displayedAssets`:
// a real photo that the "selected only" filter hides is still a valid link.
if (token !== null && !assets.some((a) => a.id === token)) {
window.history.replaceState(
null,
'',
window.location.pathname +
buildPhotoQuery(window.location.search, null) +
window.location.hash,
);
}
const idx = parsePhotoHash(window.location.hash);
if (idx !== null && idx < displayedAssets.length) {
setLightboxIndex(idx);
}
}, [displayedAssets]);
}, [assets, displayedAssets]);
// ── Sync the URL's `photo` param when the lightbox state changes ──
useEffect(() => {
@@ -124,6 +161,33 @@ function PhotoGridInner({
}
}, [lightboxIndex, displayedAssets]);
// ── The photo on screen can leave the list ─────────────────────
// With "selected only" on, un-hearting the photo in the viewer drops it from
// `displayedAssets`. The viewer keeps its position, which now shows the next
// favourite; past the end it steps back to the last one, and with nothing
// left it closes. `shownIndex` keeps this render valid (it used to hand the
// viewer an index past the end, and the page crashed); the effect settles
// the state and the URL.
const shownIndex =
lightboxIndex === null || displayedAssets.length === 0
? null
: Math.min(lightboxIndex, displayedAssets.length - 1);
useEffect(() => {
if (lightboxIndex === null || lightboxIndex < displayedAssets.length) return;
if (displayedAssets.length > 0) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setLightboxIndex(displayedAssets.length - 1);
return;
}
setLightboxIndex(null);
window.history.replaceState(
null,
'',
window.location.pathname + buildPhotoQuery(window.location.search, null),
);
}, [lightboxIndex, displayedAssets.length]);
// ── Listen for browser back/forward ────────────────────────────
useEffect(() => {
const handlePopState = () => {
@@ -172,25 +236,31 @@ function PhotoGridInner({
// Keyboard navigation and the scroll lock live in <Lightbox/>.
// A work's catalogue number on the wall label is its place in the album,
// not in the current view: the "selected only" filter must not renumber it.
const albumPosition = useMemo(() => new Map(assets.map((a, i) => [a.id, i + 1])), [assets]);
const gridItems = useMemo(() => {
return displayedAssets.map((asset, index) => {
const isFav = proofing ? proofing.isFavorite(asset.id) : false;
// A focal length alone is not a label, as before.
const exifLine =
asset.camera || asset.lens
? [asset.camera, asset.lens, asset.focalLength].filter(Boolean).join(' · ')
: '';
// Justified rows: the flex sizing must sit on the outermost grid child,
// which is the FadeIn wrapper — not the .photo-grid__item inside it.
// Aspect ratio drives both grow factor and basis; ~3:2 fallback for
// assets without dimensions (e.g. videos) keeps the row math sane.
const justifiedAr = asset.aspectRatio || 1.5;
const justifiedStyle: React.CSSProperties | undefined =
layout === 'justified'
? {
flexGrow: justifiedAr,
flexBasis: `calc(var(--grid-row-height, 300px) * ${justifiedAr})`,
}
: undefined;
layout === 'justified' ? justifiedTileStyle(asset.aspectRatio) : undefined;
return (
<FadeIn key={asset.id} delay={index < 12 ? index * 50 : 0} style={justifiedStyle}>
<FadeIn
key={asset.id}
as="figure"
delay={index < 12 ? index * 50 : 0}
style={justifiedStyle}
>
<div
className={`photo-grid__item${
layout === 'showcase' && index === 0 ? ' photo-grid__featured' : ''
@@ -204,26 +274,34 @@ function PhotoGridInner({
}}
role="button"
tabIndex={0}
data-lightbox-index={index}
aria-label={t.lightbox.openPhoto(index + 1)}
aria-haspopup="dialog"
style={{
...(asset.dominantColor ? { backgroundColor: asset.dominantColor } : {}),
...((layout === 'masonry' || layout === 'showcase') && asset.aspectRatio
? { aspectRatio: `${asset.aspectRatio}` }
: {}),
position: 'relative',
}}
>
<Image
src={asset.thumbUrl}
alt={asset.caption ?? ''}
fill
sizes="(max-width: 600px) 50vw, (max-width: 1000px) 33vw, 25vw"
loading={index < 6 ? 'eager' : 'lazy'}
{...(asset.blurDataURL
? { placeholder: 'blur' as const, blurDataURL: asset.blurDataURL }
: {})}
/>
{/* The photo's own box. Without a frame it covers the whole tile;
under passepartout globals.css insets it by the mat, so `fill`
and the placeholder colour stay on the photo, not on the mat. */}
<div
className="photo-grid__media"
style={asset.dominantColor ? { backgroundColor: asset.dominantColor } : undefined}
>
<Image
src={asset.thumbUrl}
alt={asset.caption ?? ''}
fill
sizes="(max-width: 600px) 50vw, (max-width: 1000px) 33vw, 25vw"
loading={index < 6 ? 'eager' : 'lazy'}
{...(asset.blurDataURL
? { placeholder: 'blur' as const, blurDataURL: asset.blurDataURL }
: {})}
/>
</div>
{proofing && (
<button
@@ -248,7 +326,7 @@ function PhotoGridInner({
background: 'none',
border: 'none',
cursor: 'pointer',
color: isFav ? '#ff4d4f' : 'rgba(255,255,255,0.85)',
color: isFav ? 'var(--lightbox-fav)' : 'rgba(255,255,255,0.85)',
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.6))',
padding: 0,
}}
@@ -274,24 +352,68 @@ function PhotoGridInner({
</svg>
</div>
)}
{(asset.camera || asset.lens) && (
{exifLine && (
<div className="photo-grid__item-exif" aria-hidden="true">
{[asset.camera, asset.lens, asset.focalLength].filter(Boolean).join(' · ')}
{exifLine}
</div>
)}
</div>
{/* The wall label under the photo, outside the tile and its clip.
globals.css hides it; a preset that hangs its photos as works
(kunsthalle) shows it. It carries only what the site already
publishes for the photo: its number, the caption that is the
image's alt text (absent unless the `caption` EXIF group is on),
and the EXIF line. The number and EXIF repeat the tile's own
label and the lightbox, so they stay out of the figure's name. */}
<figcaption className="photo-grid__label">
<span className="photo-grid__label-no" aria-hidden="true">
{pad2(albumPosition.get(asset.id) ?? index + 1)}
</span>
{asset.caption && <span className="photo-grid__label-title">{asset.caption}</span>}
{exifLine && (
<span className="photo-grid__label-exif" aria-hidden="true">
{exifLine}
</span>
)}
</figcaption>
</FadeIn>
);
});
}, [displayedAssets, layout, openLightbox, proofing, t]);
}, [albumPosition, displayedAssets, layout, openLightbox, proofing, t]);
// Photos of this album that are selected. Not `favorites.size`: the stored set
// can also hold a photo since removed, or a favourite from another album whose
// name slugs the same way (they share `folio_fav_<name>`).
const selectedCount = proofing ? proofing.getSelectedTokens().length : 0;
return (
<>
<div className={`photo-grid photo-grid--${layout}`} style={gridStyle}>
{gridItems}
</div>
{proofing?.isFilterActive && displayedAssets.length === 0 ? (
// Un-hearting the last favourite with the filter on left a blank page
// between the header and the footer. Say why, and offer the way back
// here as well as in the bar in the corner.
<div className="empty-state" role="status">
<p className="empty-state__text">{t.proofing.filterEmpty}</p>
<div className="empty-state__actions">
<button
type="button"
className="empty-state__button"
onClick={() => proofing.setIsFilterActive(false)}
>
{t.proofing.showAll}
</button>
</div>
</div>
) : (
<div className={`photo-grid photo-grid--${layout}`} style={gridStyle} data-lightbox-group>
{gridItems}
</div>
)}
{proofing && proofing.favorites.size > 0 && (
{/* Kept while the filter is on: un-hearting the last favourite there
empties the grid, and the bar holds the only way back to all photos. */}
{proofing && !proofing.session && (selectedCount > 0 || proofing.isFilterActive) && (
<div
className="proofing-sticky-bar"
style={{
@@ -303,7 +425,7 @@ function PhotoGridInner({
alignItems: 'center',
gap: '12px',
padding: '8px 16px',
borderRadius: '30px',
borderRadius: 'var(--radius-lg)',
background: 'var(--bg-card, #1e1e1e)',
color: 'var(--text-primary, #ffffff)',
border: '1px solid var(--border-subtle, rgba(255,255,255,0.15))',
@@ -318,18 +440,16 @@ function PhotoGridInner({
background: proofing.isFilterActive
? 'var(--accent, #e60012)'
: 'var(--bg-card-hover)',
color: proofing.isFilterActive ? '#fff' : 'var(--text-primary)',
color: proofing.isFilterActive ? 'var(--on-accent, #fff)' : 'var(--text-primary)',
border: 'none',
padding: '6px 14px',
borderRadius: '20px',
borderRadius: 'var(--radius-md)',
fontSize: '0.85rem',
fontWeight: 500,
cursor: 'pointer',
}}
>
{proofing.isFilterActive
? t.proofing.showAll
: t.proofing.selected(proofing.favorites.size)}
{proofing.isFilterActive ? t.proofing.showAll : t.proofing.selected(selectedCount)}
</button>
<button
type="button"
@@ -349,12 +469,13 @@ function PhotoGridInner({
</div>
)}
{proofing && <ProofingModal />}
{proofing && !proofing.session && <ProofingModal />}
{proofing?.session && <ProofSessionControls />}
{lightboxIndex !== null && (
{shownIndex !== null && (
<Lightbox
assets={displayedAssets}
currentIndex={lightboxIndex}
currentIndex={shownIndex}
onClose={closeLightbox}
onNext={goNext}
onPrev={goPrev}
@@ -372,7 +493,7 @@ export function PhotoGrid(props: PhotoGridProps) {
// Without the provider useProofing() returns null, and every proofing control
// (hearts in the grid and in the lightbox, selection bar, modal) drops out on
// its own.
if (!props.proofing) {
if (!props.proofing && !props.proofSession) {
return <PhotoGridInner {...props} />;
}
@@ -381,7 +502,9 @@ export function PhotoGrid(props: PhotoGridProps) {
albumTokens={albumTokens}
albumName={props.albumName}
allowMailto={props.allowMailto ?? true}
encodedMailto={props.encodedMailto}
downloadArchiveUrl={props.downloadArchiveUrl}
session={props.proofSession}
>
<PhotoGridInner {...props} />
</ProofingProvider>

View File

@@ -1,6 +1,6 @@
import Link from 'next/link';
import Image from 'next/image';
import { imageUrl } from '@/lib/urls';
import { imageUrl, type ImageRef } from '@/lib/urls';
import { SubpageSectionConfig } from '@/lib/config';
import { getServerDictionary } from '@/lib/i18n/server';
import { BackLink } from '@/components/BackLink';
@@ -11,6 +11,8 @@ interface SubpageAlbum {
albumName: string;
assetCount: number;
albumThumbnailAssetId: string | null;
/** The cover asset as Immich has it, so an edited cover gets its edit marker (#831). */
cover?: ImageRef;
/** EXPERIMENTAL: focal point for the cover crop (CSS object-position) */
coverPosition?: string;
}
@@ -73,7 +75,7 @@ function AlbumGrid({
>
{album.albumThumbnailAssetId ? (
<Image
src={imageUrl(album.albumThumbnailAssetId, 'preview')}
src={imageUrl(album.cover ?? album.albumThumbnailAssetId, 'preview')}
alt=""
fill
sizes="(max-width: 600px) 100vw, (max-width: 1000px) 50vw, 33vw"

View File

@@ -0,0 +1,85 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, within } from '@testing-library/react';
import { PhotoGrid, type PhotoItem } from '../PhotoGrid';
/**
* With "show selected" on, the lightbox walks the favourites only. Un-hearting
* the photo on screen takes it out of that list while the viewer is open: on
* the last favourite the index pointed past the end and the viewer crashed the
* page (`current` undefined), and on the only one the list was empty.
*/
vi.mock('next/image', () => ({
// eslint-disable-next-line @next/next/no-img-element
default: ({ alt }: { alt?: string }) => <img alt={alt ?? ''} />,
}));
vi.mock('@/components/FadeIn', () => ({
FadeIn: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
const photo = (id: string): PhotoItem => ({
id,
type: 'image',
thumbUrl: `/t/${id}`,
previewUrl: `/p/${id}`,
exifUrl: `/e/${id}`,
aspectRatio: 1.5,
});
const ASSETS = [photo('a1'), photo('a2'), photo('a3')];
let store: Map<string, string>;
beforeEach(() => {
// Node 26 defines its own global localStorage (undefined without
// --localstorage-file), which shadows jsdom's; give the test a working one.
store = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, String(v)),
removeItem: (k: string) => void store.delete(k),
clear: () => store.clear(),
});
window.history.replaceState(null, '', '/wedding');
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
async function openFilteredLightboxOn(n: number) {
render(<PhotoGrid assets={ASSETS} proofing albumName="Wedding" />);
fireEvent.click(await screen.findByText(/❤️ \d Selected/));
fireEvent.click(screen.getByLabelText(`View photo ${n}`));
return screen.getByRole('dialog');
}
const shownPhoto = (dialog: HTMLElement) =>
dialog.querySelector('img')?.getAttribute('src')?.split('?')[0];
describe('un-hearting inside the filtered lightbox', () => {
it('moves to the previous favourite when the last one is removed', async () => {
store.set('folio_fav_wedding', JSON.stringify(['a1', 'a3']));
const dialog = await openFilteredLightboxOn(2);
expect(shownPhoto(dialog)).toBe('/p/a3');
fireEvent.click(within(dialog).getByLabelText('Remove from favorites'));
const after = screen.getByRole('dialog');
expect(shownPhoto(after)).toBe('/p/a1');
expect(within(after).getByText('1 / 1')).toBeTruthy();
expect(new URLSearchParams(window.location.search).get('photo')).toBe('a1');
});
it('closes the viewer when the only favourite is removed', async () => {
store.set('folio_fav_wedding', JSON.stringify(['a2']));
const dialog = await openFilteredLightboxOn(1);
fireEvent.click(within(dialog).getByLabelText('Remove from favorites'));
expect(screen.queryByRole('dialog')).toBeNull();
expect(new URLSearchParams(window.location.search).get('photo')).toBeNull();
});
});

View File

@@ -0,0 +1,61 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { JournalBlock } from '@/lib/journal';
const getAlbumAssetsRaw = vi.fn();
vi.mock('@/lib/immich', () => ({
immich: {
get getAlbumAssetsRaw() {
return getAlbumAssetsRaw;
},
},
}));
import { loadAlbumBlocks } from '../essayPayload';
const album = (albumId: string) => ({ type: 'album', albumId, line: 0 }) as unknown as JournalBlock;
const photo = { type: 'photo', line: 0 } as unknown as JournalBlock;
describe('loadAlbumBlocks', () => {
beforeEach(() => {
getAlbumAssetsRaw.mockReset();
});
// It used to await each album before asking for the next, so a page with
// three album blocks waited for three Immich round trips in a row.
it('asks for every album before any answer arrives', async () => {
const pending: Array<() => void> = [];
getAlbumAssetsRaw.mockImplementation(
(id: string) =>
new Promise((resolve) => pending.push(() => resolve([{ id: `${id}-asset` }]))),
);
const result = loadAlbumBlocks([album('a'), photo, album('b'), album('c')], () => {});
await Promise.resolve();
expect(getAlbumAssetsRaw).toHaveBeenCalledTimes(3);
pending.forEach((resolve) => resolve());
expect([...(await result).keys()]).toEqual(['a', 'b', 'c']);
});
it('loads an album named by two blocks once', async () => {
getAlbumAssetsRaw.mockResolvedValue([]);
await loadAlbumBlocks([album('a'), album('a')], () => {});
expect(getAlbumAssetsRaw).toHaveBeenCalledTimes(1);
});
it('leaves out an album that fails and reports it, keeping the rest', async () => {
const failure = new Error('immich down');
getAlbumAssetsRaw.mockImplementation(async (id: string) => {
if (id === 'b') throw failure;
return [{ id: `${id}-asset` }];
});
const onError = vi.fn();
const result = await loadAlbumBlocks([album('a'), album('b'), album('c')], onError);
expect([...result.keys()]).toEqual(['a', 'c']);
expect(onError).toHaveBeenCalledWith('b', failure);
});
});

View File

@@ -0,0 +1,161 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Metadata } from 'next';
/**
* generateMetadata runs for any URL a visitor types. It used to start from
* `title = slug` and keep that whenever nothing matched, so an unknown path,
* an offline subpage or a locked album put the requested slug in the
* `<title>` and in a validly signed share-card URL. Every branch that finds
* nothing — or finds it locked — now answers with a dictionary title and no
* card of its own.
*/
vi.mock('next/navigation', () => ({
notFound: () => {
throw new Error('NEXT_NOT_FOUND');
},
}));
vi.mock('next/headers', () => ({
cookies: async () => ({ get: () => undefined }),
}));
vi.mock('@/lib/admin/auth', () => ({ isAdminAuthenticated: async () => false }));
vi.mock('@/lib/i18n/server', async () => {
const { getDictionary } = await import('@/lib/i18n');
return { getServerDictionary: () => getDictionary('en') };
});
vi.mock('@/lib/ogImage', () => ({
ogImageUrl: (title: string, subtitle = '') =>
`/api/og?title=${encodeURIComponent(title)}&subtitle=${encodeURIComponent(subtitle)}&sig=x`,
}));
vi.mock('../contentPage', () => ({
contentPageMetadata: vi.fn(async () => null),
renderContentPage: vi.fn(async () => null),
}));
const locked = vi.hoisted(() => ({ keys: new Set<string>() }));
vi.mock('@/lib/auth', () => ({
isProtected: (key: string) => locked.keys.has(key),
isAuthenticated: () => false,
withoutLockedAlbums: <T>(albums: T[]) => albums,
}));
const immich = vi.hoisted(() => ({
isSubpageSlug: vi.fn((slug: string) => slug === 'travel' || slug === 'solo'),
getSubpageAlbums: vi.fn(),
getAlbumBySlug: vi.fn(),
}));
vi.mock('@/lib/immich', () => ({ immich }));
const ICELAND = {
id: 'album-1',
albumName: 'Iceland',
slug: 'iceland',
assets: [{ id: 'a1', type: 'IMAGE' }],
albumThumbnailAssetId: null,
};
import { generateMetadata } from '../page';
import { getDictionary } from '@/lib/i18n';
const en = getDictionary('en');
const meta = (path: string[]) =>
generateMetadata({ params: Promise.resolve({ path }), searchParams: Promise.resolve({}) });
/** Every string the metadata would put in the page head or a share URL. */
function emitted(m: Metadata): string {
return JSON.stringify(m);
}
beforeEach(() => {
vi.clearAllMocks();
locked.keys.clear();
immich.getAlbumBySlug.mockImplementation(async (slug: string) =>
slug === 'iceland' ? ICELAND : null,
);
immich.getSubpageAlbums.mockImplementation(async (slug: string) => {
if (slug === 'travel') {
return {
subpage: { slug: 'travel', name: 'travel', title: 'Travel', albumIds: ['album-1', 'b'] },
albums: [ICELAND, { ...ICELAND, id: 'b', slug: 'norway', albumName: 'Norway' }],
};
}
if (slug === 'solo') {
return { subpage: { slug: 'solo', name: 'solo', albumIds: ['album-1'] }, albums: [ICELAND] };
}
return null;
});
});
describe('page metadata never echoes the requested path', () => {
it.each([
['an unknown slug', ['unknown-visitor-text']],
['an unknown album under a real subpage', ['travel', 'nope']],
['a three-segment path', ['iceland', 'x', 'y']],
])('gives %s the generic not-found title and no card', async (_, path) => {
const m = await meta(path);
expect(m.title).toBe(en.error.notFoundTitle);
expect(m.openGraph).toBeUndefined();
expect(m.twitter).toBeUndefined();
for (const segment of path) expect(emitted(m)).not.toContain(encodeURIComponent(segment));
});
it('drops the layout robots tag on a not-found path, leaving only Next’s noindex', async () => {
const m = await meta(['nope']);
expect(m.robots).toBeNull();
});
it('treats an offline subpage like any unknown slug', async () => {
// isSubpageSlug is false for `enabled: false`, and no album has the slug.
const m = await meta(['dubai']);
expect(m.title).toBe(en.error.notFoundTitle);
expect(emitted(m)).not.toContain('dubai');
});
it('gives a locked standalone album the generic protected title', async () => {
locked.keys.add('album-1');
const m = await meta(['iceland']);
expect(m.title).toBe(en.password.protectedPage);
expect(emitted(m)).not.toContain('iceland');
expect(emitted(m)).not.toContain('Iceland');
expect(m.openGraph).toBeUndefined();
});
it('gives a locked multi-album subpage the protected title, not its slug', async () => {
locked.keys.add('travel');
const m = await meta(['travel']);
expect(m.title).toBe(en.password.protectedPage);
expect(emitted(m)).not.toContain('travel');
});
it('gives an album under a locked subpage the protected title', async () => {
locked.keys.add('travel');
const m = await meta(['travel', 'iceland']);
expect(m.title).toBe(en.password.protectedPage);
expect(emitted(m)).not.toContain('Iceland');
});
it('gives a locked album on a single-album subpage the protected title', async () => {
locked.keys.add('album-1');
const m = await meta(['solo']);
expect(m.title).toBe(en.password.protectedPage);
expect(emitted(m)).not.toContain('solo');
});
it('still describes published content by its own name', async () => {
const album = await meta(['iceland']);
expect(album.title).toBe('Iceland');
expect(emitted(album)).toContain(encodeURIComponent('Iceland'));
const subpage = await meta(['travel']);
expect(subpage.title).toBe('Travel');
const nested = await meta(['travel', 'iceland']);
expect(nested.title).toBe('Iceland');
});
});

View File

@@ -0,0 +1,181 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Suspense, isValidElement, type ReactElement } from 'react';
import fs from 'node:fs';
import path from 'node:path';
/**
* Next sends the status with the first byte, and the first byte goes out as
* soon as a Suspense fallback renders. The catch-all used to sit inside
* loading.tsx boundaries (app/ and app/[...path]/), so its skeleton streamed
* as a 200 before the page could call notFound(): `/nope`, an offline subpage,
* a draft content page and `/<subpage>/<unknown>` were all soft 404s — the
* not-found page with a 200.
*
* The page now decides whether the path exists before it renders a boundary,
* and only then streams the skeleton around the part that fetches photos.
*/
const NOT_FOUND = new Error('NEXT_NOT_FOUND');
vi.mock('next/navigation', () => ({
notFound: () => {
throw NOT_FOUND;
},
}));
vi.mock('next/headers', () => ({
cookies: async () => ({ get: () => undefined }),
}));
const admin = vi.hoisted(() => ({ signedIn: false }));
vi.mock('@/lib/admin/auth', () => ({ isAdminAuthenticated: async () => admin.signedIn }));
vi.mock('@/lib/i18n/server', async () => {
const { getDictionary } = await import('@/lib/i18n');
return { getServerDictionary: () => getDictionary('en') };
});
const locked = vi.hoisted(() => ({ keys: new Set<string>() }));
vi.mock('@/lib/auth', () => ({
isProtected: (key: string) => locked.keys.has(key),
isAuthenticated: () => false,
withoutLockedAlbums: <T>(albums: T[]) => albums,
}));
const pages = vi.hoisted(() => ({
access: new Map<string, 'open' | 'draft' | 'locked'>(),
}));
vi.mock('../contentPage', () => ({
contentPageAccess: vi.fn(async (slug: string) => pages.access.get(slug) ?? null),
contentPageMetadata: vi.fn(async () => null),
renderContentPage: vi.fn(async () => null),
}));
const ICELAND = {
id: 'album-1',
albumName: 'Iceland',
slug: 'iceland',
assets: [],
albumThumbnailAssetId: null,
};
const immich = vi.hoisted(() => ({
isSubpageSlug: vi.fn(),
getSubpageAlbums: vi.fn(),
findAlbumBySlug: vi.fn(),
getAlbumBySlug: vi.fn(),
getStandaloneAlbums: vi.fn(async () => []),
getSubpages: vi.fn(async () => []),
getAssetInfo: vi.fn(async () => null),
}));
vi.mock('@/lib/immich', () => ({ immich }));
import PathPage from '../page';
import { PathSkeleton } from '../PathSkeleton';
const call = (segments: string[]) => ({
params: Promise.resolve({ path: segments }),
searchParams: Promise.resolve({}),
});
beforeEach(() => {
vi.clearAllMocks();
admin.signedIn = false;
locked.keys.clear();
pages.access = new Map([
['about-us', 'open'],
['pricing', 'draft'],
['members', 'locked'],
]);
// `dubai` is `enabled: false`: isSubpageSlug() and every lookup skip it.
immich.isSubpageSlug.mockImplementation((slug: string) =>
['travel', 'vault', 'empty'].includes(slug),
);
immich.getSubpageAlbums.mockImplementation(async (slug: string) => {
if (slug === 'empty') return { subpage: { slug, name: 'Empty', albumIds: [] }, albums: [] };
if (slug === 'travel' || slug === 'vault') {
return { subpage: { slug, name: slug, albumIds: ['album-1'] }, albums: [ICELAND] };
}
return null;
});
immich.findAlbumBySlug.mockImplementation(async (slug: string, subpage?: string) =>
slug === 'iceland' && (subpage === undefined || subpage === 'travel' || subpage === 'vault')
? ICELAND
: null,
);
immich.getAlbumBySlug.mockResolvedValue(ICELAND);
});
describe('catch-all not-found status', () => {
it.each([
['an unknown slug', ['nope']],
['a long made-up slug', ['this-is-a-long-made-up-slug-that-names-nothing-at-all-0123456789']],
['a disabled subpage', ['dubai']],
['a draft content page', ['pricing']],
['a subpage without published albums', ['empty']],
['an unknown album under a real subpage', ['travel', 'nope']],
['an album under a disabled subpage', ['dubai', 'iceland']],
['a three-segment path', ['x', 'y', 'z']],
])('calls notFound() for %s before anything can stream', async (_, segments) => {
await expect(PathPage(call(segments))).rejects.toBe(NOT_FOUND);
// No photo was fetched: the decision came from the album list alone.
expect(immich.getAlbumBySlug).not.toHaveBeenCalled();
});
it.each([
['a subpage', ['travel']],
['an album under a subpage', ['travel', 'iceland']],
['a standalone album', ['iceland']],
['a content page', ['about-us']],
['a locked content page (its gate renders)', ['members']],
])('streams %s behind the skeleton, deciding first', async (_, segments) => {
const element = (await PathPage(call(segments))) as ReactElement<{ fallback: ReactElement }>;
expect(isValidElement(element)).toBe(true);
expect(element.type).toBe(Suspense);
expect(element.props.fallback.type).toBe(PathSkeleton);
// The asset fetch — the slow part — happens inside the boundary.
expect(immich.getAlbumBySlug).not.toHaveBeenCalled();
});
it('does not tell a visitor without the password which albums a locked subpage holds', async () => {
locked.keys.add('vault');
const known = await PathPage(call(['vault', 'iceland']));
const unknown = await PathPage(call(['vault', 'no-such-album']));
expect((known as ReactElement).type).toBe(Suspense);
expect((unknown as ReactElement).type).toBe(Suspense);
expect(immich.findAlbumBySlug).not.toHaveBeenCalled();
});
it('lets a locked subpage show its gate even before its albums are known', async () => {
locked.keys.add('empty');
const element = await PathPage(call(['empty']));
expect((element as ReactElement).type).toBe(Suspense);
});
it('leaves a signed-in admin the diagnostic banner instead of the 404', async () => {
admin.signedIn = true;
const element = await PathPage(call(['nope']));
expect((element as ReactElement).type).toBe(Suspense);
// ...but never for a path deeper than the catch-all serves.
await expect(PathPage(call(['x', 'y', 'z']))).rejects.toBe(NOT_FOUND);
});
it('surfaces an Immich outage as an error, never as a missing album', async () => {
const outage = new Error('Immich unavailable');
immich.findAlbumBySlug.mockRejectedValue(outage);
await expect(PathPage(call(['iceland']))).rejects.toBe(outage);
await expect(PathPage(call(['travel', 'iceland']))).rejects.toBe(outage);
});
it('has no loading.tsx above it, which would stream the 200 first', () => {
// A loading file wraps every route below its folder in a Suspense
// boundary, and nothing the page does inside one can change the status.
const appDir = path.join(process.cwd(), 'app');
for (const dir of [appDir, path.join(appDir, '[...path]')]) {
const loading = fs.readdirSync(dir).filter((name) => /^loading\.(t|j)sx?$/.test(name));
expect(loading, dir).toEqual([]);
}
});
});

View File

@@ -0,0 +1,88 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { ReactElement } from 'react';
/**
* The catch-all answers one segment (subpage, content page, standalone album)
* and two (subpage/album). A third segment used to fall through to the
* one-segment branch, which only reads `path[0]`: `/travel/iceland/anything`
* rendered the Travel subpage and `/iceland/x/y` the Iceland album, each with
* a 200, under an endless supply of URLs.
*/
const NOT_FOUND = new Error('NEXT_NOT_FOUND');
vi.mock('next/navigation', () => ({
notFound: () => {
throw NOT_FOUND;
},
}));
vi.mock('next/headers', () => ({
cookies: async () => ({ get: () => undefined }),
}));
vi.mock('@/lib/admin/auth', () => ({ isAdminAuthenticated: async () => false }));
const immich = vi.hoisted(() => ({
isSubpageSlug: vi.fn((slug: string) => slug === 'travel'),
getSubpageAlbums: vi.fn(),
getAlbumBySlug: vi.fn(),
findAlbumBySlug: vi.fn(),
getStandaloneAlbums: vi.fn(async () => []),
getSubpages: vi.fn(async () => []),
getAssetInfo: vi.fn(async () => null),
}));
vi.mock('@/lib/immich', () => ({ immich }));
const ALBUM = {
id: 'album-1',
albumName: 'Iceland',
slug: 'iceland',
assets: [],
albumThumbnailAssetId: null,
};
import PathPage, { generateMetadata } from '../page';
import { getServerDictionary } from '@/lib/i18n/server';
const call = (path: string[]) => ({
params: Promise.resolve({ path }),
searchParams: Promise.resolve({}),
});
beforeEach(() => {
vi.clearAllMocks();
immich.getAlbumBySlug.mockResolvedValue(ALBUM);
immich.findAlbumBySlug.mockResolvedValue(ALBUM);
immich.getSubpageAlbums.mockResolvedValue({
subpage: { slug: 'travel', name: 'Travel', albumIds: ['album-1', 'album-2'] },
albums: [ALBUM, { ...ALBUM, id: 'album-2', slug: 'norway', albumName: 'Norway' }],
});
});
describe('catch-all path depth', () => {
it.each([[['iceland', 'x', 'y']], [['travel', 'iceland', 'extra']], [['travel', 'a', 'b', 'c']]])(
'answers %j with a 404',
async (path) => {
await expect(PathPage(call(path))).rejects.toBe(NOT_FOUND);
expect(immich.getAlbumBySlug).not.toHaveBeenCalled();
expect(immich.findAlbumBySlug).not.toHaveBeenCalled();
expect(immich.getSubpageAlbums).not.toHaveBeenCalled();
},
);
it('gives a three-segment path no album metadata, only the not-found title', async () => {
const meta = await generateMetadata(call(['iceland', 'x', 'y']));
expect(meta.title).toBe(getServerDictionary().error.notFoundTitle);
expect(immich.getAlbumBySlug).not.toHaveBeenCalled();
});
it('still renders one and two segments', async () => {
for (const path of [['iceland'], ['travel', 'iceland']]) {
// The page decides, then hands the rendering to the content inside its
// Suspense boundary; run that too.
const shell = (await PathPage(call(path))) as ReactElement<{ children: ReactElement }>;
const content = shell.props.children as ReactElement<object>;
const render = content.type as (props: object) => Promise<unknown>;
await expect(render(content.props)).resolves.toBeTruthy();
}
});
});

View File

@@ -0,0 +1,73 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { PhotoGrid, type PhotoItem } from '../PhotoGrid';
/**
* A `?photo=` that names no photo in the album opened nothing and stayed in
* the address bar, to be copied and shared on (P-16). It is removed now; a
* valid deep link still opens its photo.
*/
vi.mock('next/image', () => ({
// eslint-disable-next-line @next/next/no-img-element
default: ({ alt }: { alt?: string }) => <img alt={alt ?? ''} />,
}));
vi.mock('@/components/FadeIn', () => ({
FadeIn: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
const photo = (id: string): PhotoItem => ({
id,
type: 'image',
thumbUrl: `/t/${id}`,
previewUrl: `/p/${id}`,
exifUrl: `/e/${id}`,
aspectRatio: 1.5,
});
const ASSETS = [photo('a1'), photo('a2'), photo('a3')];
beforeEach(() => {
const store = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, String(v)),
removeItem: (k: string) => void store.delete(k),
});
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('?photo= on load', () => {
it('opens the photo a valid token names', () => {
window.history.replaceState(null, '', '/wedding?photo=a2');
render(<PhotoGrid assets={ASSETS} albumName="Wedding" />);
expect(screen.getByRole('dialog')).toBeTruthy();
expect(window.location.search).toBe('?photo=a2');
});
it('drops a token that names no photo, keeping other parameters', () => {
window.history.replaceState(null, '', '/wedding?preview=true&photo=bogus');
render(<PhotoGrid assets={ASSETS} albumName="Wedding" />);
expect(screen.queryByRole('dialog')).toBeNull();
expect(window.location.pathname + window.location.search).toBe('/wedding?preview=true');
});
it('drops a position-style ?photo=5, which was never a format', () => {
window.history.replaceState(null, '', '/wedding?photo=5');
render(<PhotoGrid assets={ASSETS} albumName="Wedding" />);
expect(screen.queryByRole('dialog')).toBeNull();
expect(window.location.search).toBe('');
});
it('still honours a legacy #photo-N hash next to a bad token', () => {
window.history.replaceState(null, '', '/wedding?photo=bogus#photo-3');
render(<PhotoGrid assets={ASSETS} albumName="Wedding" />);
expect(screen.getByRole('dialog')).toBeTruthy();
expect(new URLSearchParams(window.location.search).get('photo')).toBe('a3');
});
});

View File

@@ -0,0 +1,126 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import { PhotoGrid, type PhotoItem } from '../PhotoGrid';
/**
* Kunsthalle's wall label was the EXIF overlay with a CSS-counter number in
* front of it, so a photo without camera or lens got no label and no number.
* Each photo is now a <figure> with a figcaption that always carries its
* number; globals.css hides it outside the presets that show it.
*/
vi.mock('next/image', () => ({
// eslint-disable-next-line @next/next/no-img-element
default: ({ alt }: { alt?: string }) => <img alt={alt ?? ''} />,
}));
vi.mock('@/components/FadeIn', () => ({
FadeIn: ({
children,
as: Wrapper = 'div',
}: {
children: React.ReactNode;
as?: 'div' | 'figure';
}) => <Wrapper>{children}</Wrapper>,
}));
const photo = (id: string, extra: Partial<PhotoItem> = {}): PhotoItem => ({
id,
type: 'image',
thumbUrl: `/t/${id}`,
previewUrl: `/p/${id}`,
exifUrl: `/e/${id}`,
aspectRatio: 1.5,
...extra,
});
const ASSETS = [
photo('a1', { camera: 'Leica M11', lens: 'Summicron 35', focalLength: '35mm' }),
photo('a2'),
photo('a3', { caption: 'Cloister at dusk' }),
photo('a4', { focalLength: '50mm' }),
];
let store: Map<string, string>;
beforeEach(() => {
store = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, String(v)),
removeItem: (k: string) => void store.delete(k),
clear: () => store.clear(),
});
window.history.replaceState(null, '', '/kloster');
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
const labels = (container: HTMLElement) =>
[...container.querySelectorAll('figure > figcaption.photo-grid__label')] as HTMLElement[];
describe('photo wall label', () => {
it('numbers every photo, with or without EXIF', () => {
const { container } = render(<PhotoGrid assets={ASSETS} />);
const numbers = labels(container).map(
(l) => l.querySelector('.photo-grid__label-no')?.textContent,
);
expect(numbers).toEqual(['01', '02', '03', '04']);
});
it('sits next to the tile, not inside its clipped box', () => {
const { container } = render(<PhotoGrid assets={ASSETS} />);
for (const label of labels(container)) {
expect(label.closest('.photo-grid__item')).toBeNull();
expect(label.previousElementSibling?.classList.contains('photo-grid__item')).toBe(true);
}
});
it('carries the caption only where one is published, and names the figure with it', () => {
const { container } = render(<PhotoGrid assets={ASSETS} />);
const titles = labels(container).map(
(l) => l.querySelector('.photo-grid__label-title')?.textContent ?? null,
);
expect(titles).toEqual([null, null, 'Cloister at dusk', null]);
// The same text the photo already publishes as its alt text.
expect(container.querySelector('img[alt="Cloister at dusk"]')).not.toBeNull();
// What assistive tech reads off the label (the figure's name, by HTML-AAM)
// is the caption alone.
const spoken = (l: HTMLElement) =>
[...l.children]
.filter((c) => c.getAttribute('aria-hidden') !== 'true')
.map((c) => c.textContent)
.join('');
expect(labels(container).map(spoken)).toEqual(['', '', 'Cloister at dusk', '']);
});
it('prints the EXIF line only for a camera or lens, as the tile does', () => {
const { container } = render(<PhotoGrid assets={ASSETS} />);
const exif = labels(container).map(
(l) => l.querySelector('.photo-grid__label-exif')?.textContent ?? null,
);
expect(exif).toEqual(['Leica M11 · Summicron 35 · 35mm', null, null, null]);
});
it('keeps the number and EXIF, which repeat the tile and lightbox, out of the figure name', () => {
const { container } = render(<PhotoGrid assets={ASSETS} />);
const [first] = labels(container);
expect(first.querySelector('.photo-grid__label-no')?.getAttribute('aria-hidden')).toBe('true');
expect(first.querySelector('.photo-grid__label-exif')?.getAttribute('aria-hidden')).toBe(
'true',
);
});
it('keeps a photo’s album number when the favourites filter hides the others', async () => {
store.set('folio_fav_kloster', JSON.stringify(['a3']));
const { container } = render(<PhotoGrid assets={ASSETS} proofing albumName="Kloster" />);
fireEvent.click(await screen.findByText('❤️ 1 Selected'));
const numbers = labels(container).map(
(l) => l.querySelector('.photo-grid__label-no')?.textContent,
);
expect(numbers).toEqual(['03']);
});
});

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, vi } from 'vitest';
import type { ImmichAsset } from '@/lib/immich';
/**
* #831: a photo edited in Immich (crop, rotate) reaches the grid, the
* lightbox and the zoom as edited — an image URL with the edit marker, the
* edited proportions, and the edited full-size rendition behind the zoom.
*/
vi.mock('@/lib/config', () => ({
getConfig: () => ({ authSecret: 'test-auth-secret-32-chars-long-min' }),
getConfigOrNull: () => null,
}));
const { toPhotoItems } = await import('../photoItems');
const exif = (w: number, h: number, orientation: string | null = null) =>
({ exifImageWidth: w, exifImageHeight: h, orientation }) as ImmichAsset['exifInfo'];
const asset = (over: Partial<ImmichAsset>): ImmichAsset => ({
id: '11111111-1111-1111-1111-111111111111',
type: 'IMAGE',
originalFileName: 'scan.jpg',
originalMimeType: 'image/jpeg',
thumbhash: null,
fileCreatedAt: '2020-07-24T11:13:27.000Z',
isTrashed: false,
...over,
});
const ALBUM = '22222222-2222-2222-2222-222222222222';
describe('toPhotoItems for photos edited in Immich (#831)', () => {
// Measured on Immich 3.2: a scan stored 1076×723, rotated 270° in Immich.
const rotated = asset({
isEdited: true,
updatedAt: '2026-09-04T19:22:09.220Z',
width: 723,
height: 1076,
exifInfo: exif(1076, 723),
});
it('marks the edited photo’s URLs and gives it the edited proportions', () => {
const [item] = toPhotoItems([rotated], false, false);
expect(item.thumbUrl).toMatch(/[?&]e=[0-9a-z]+(&|$)/);
expect(item.previewUrl).toBe(item.thumbUrl);
expect(item.aspectRatio).toBeCloseTo(723 / 1076);
});
it('leaves a photo that is not edited exactly as it was', () => {
const [item] = toPhotoItems(
[asset({ width: 1076, height: 723, exifInfo: exif(1076, 723) })],
false,
false,
);
expect(item.thumbUrl).toMatch(/\?size=preview$/);
expect(item.aspectRatio).toBeCloseTo(1076 / 723);
});
it('offers zoom at the edited size', () => {
const [item] = toPhotoItems([rotated], false, false, undefined, ALBUM);
expect(item.zoomUrl).toMatch(/^\/api\/zoom\//);
expect([item.zoomWidth, item.zoomHeight]).toEqual([723, 1076]);
});
});

View File

@@ -0,0 +1,121 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, within } from '@testing-library/react';
import { PhotoGrid, type PhotoItem } from '../PhotoGrid';
import { EssayView } from '../EssayView';
import { parseEssayMarkdown } from '@/lib/essay';
import { encodeEmail } from '@/lib/emailObfuscation';
/**
* The anonymous proofing selection lives under `folio_fav_<albumName>`, so it
* can hold tokens this grid does not show: a photo since removed from the
* album, or a favourite from another album whose name slugs the same way.
* Every count the visitor sees must be the photos of *this* album that are
* selected — the modal's download button already counted that way, while the
* bar, the title and the email subject counted the whole stored set.
*/
vi.mock('next/image', () => ({
// eslint-disable-next-line @next/next/no-img-element
default: ({ alt }: { alt?: string }) => <img alt={alt ?? ''} />,
}));
vi.mock('@/components/Lightbox', () => ({ Lightbox: () => null }));
vi.mock('@/components/LeafletMap', () => ({
LeafletMap: () => null,
escapeHtml: (s: string) => s,
}));
vi.mock('@/components/FadeIn', () => ({
FadeIn: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
const photo = (id: string): PhotoItem => ({
id,
type: 'image',
thumbUrl: `/t/${id}`,
previewUrl: `/p/${id}`,
exifUrl: `/e/${id}`,
aspectRatio: 1.5,
});
const ASSETS = [photo('a1'), photo('a2'), photo('a3')];
let store: Map<string, string>;
beforeEach(() => {
// Node 26 defines its own global localStorage (undefined without
// --localstorage-file), which shadows jsdom's; give the test a working one.
store = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, String(v)),
removeItem: (k: string) => void store.delete(k),
clear: () => store.clear(),
});
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('proofing counts in the album grid', () => {
it('counts only this album’s photos, not stale or foreign favourites', async () => {
// One photo of this album, one removed photo, one from a same-named album.
store.set('folio_fav_wedding', JSON.stringify(['a2', 'gone', 'other-album']));
render(<PhotoGrid assets={ASSETS} proofing albumName="Wedding" />);
expect(await screen.findByText('❤️ 1 Selected')).toBeTruthy();
fireEvent.click(screen.getByText('Share & Export'));
expect(screen.getByText('❤️ Selection (1)')).toBeTruthy();
expect(screen.getByText(/You have selected 1 photo\./)).toBeTruthy();
});
it('shows no selection bar when only foreign favourites are stored', () => {
store.set('folio_fav_wedding', JSON.stringify(['gone', 'other-album']));
render(<PhotoGrid assets={ASSETS} proofing albumName="Wedding" />);
// `render` flushes the hydration effect, so the stored set is loaded here.
expect(screen.queryByText(/Selected/)).toBeNull();
expect(screen.queryByText('Share & Export')).toBeNull();
});
it('keeps "Show All" reachable after the last favourite is removed in the filter', async () => {
store.set('folio_fav_wedding', JSON.stringify(['a1']));
render(<PhotoGrid assets={ASSETS} proofing albumName="Wedding" />);
fireEvent.click(await screen.findByText('❤️ 1 Selected'));
// Filtered to the one favourite; un-heart it and the grid is empty.
fireEvent.click(screen.getByLabelText('Remove favorite'));
expect(screen.queryByLabelText('Add favorite')).toBeNull();
// The empty state offers "Show All" too; this is about the bar's own.
const bar = document.querySelector<HTMLElement>('.proofing-sticky-bar')!;
fireEvent.click(within(bar).getByText('Show All'));
expect(screen.getAllByLabelText('Add favorite')).toHaveLength(3);
});
it('puts the album count into the email subject', async () => {
store.set('folio_fav_wedding', JSON.stringify(['a1', 'gone']));
render(
<PhotoGrid
assets={ASSETS}
proofing
albumName="Wedding"
allowMailto
encodedMailto={encodeEmail('hello@example.com')}
/>,
);
fireEvent.click(await screen.findByText('Share & Export'));
const location = { href: 'http://localhost/wedding' };
vi.stubGlobal('location', location);
fireEvent.click(screen.getByText('✉️ Send Email to Photographer'));
expect(decodeURIComponent(location.href)).toMatch(/Photo Selection \(1 items?\)/);
});
});
describe('proofing counts in an essay', () => {
it('counts only the essay’s photos', async () => {
store.set('folio_fav_story', JSON.stringify(['a3', 'gone']));
const essay = parseEssayMarkdown('---\ntitle: Story\n---\n\n![a1, a3](caption)\n');
render(<EssayView essay={essay} assets={ASSETS} title="Story" proofing />);
expect(await screen.findByText('❤️ 1 Selected')).toBeTruthy();
});
});

View File

@@ -0,0 +1,77 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, within } from '@testing-library/react';
import { PhotoGrid, type PhotoItem } from '../PhotoGrid';
/**
* With "show selected" on, un-hearting the last favourite emptied the grid and
* left nothing between the album header and the footer — no word on why, and
* the only way back a small pill in the corner.
*/
vi.mock('next/image', () => ({
// eslint-disable-next-line @next/next/no-img-element
default: ({ alt }: { alt?: string }) => <img alt={alt ?? ''} />,
}));
vi.mock('@/components/FadeIn', () => ({
FadeIn: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
const photo = (id: string): PhotoItem => ({
id,
type: 'image',
thumbUrl: `/t/${id}`,
previewUrl: `/p/${id}`,
exifUrl: `/e/${id}`,
aspectRatio: 1.5,
});
const ASSETS = [photo('a1'), photo('a2'), photo('a3')];
let store: Map<string, string>;
beforeEach(() => {
// Node 26 defines its own global localStorage (undefined without
// --localstorage-file), which shadows jsdom's; give the test a working one.
store = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, String(v)),
removeItem: (k: string) => void store.delete(k),
clear: () => store.clear(),
});
window.history.replaceState(null, '', '/wedding');
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('favourites filter with nothing left', () => {
it('says nothing is selected and offers the way back to all photos', async () => {
store.set('folio_fav_wedding', JSON.stringify(['a2']));
render(<PhotoGrid assets={ASSETS} proofing albumName="Wedding" />);
fireEvent.click(await screen.findByText('❤️ 1 Selected'));
expect(screen.getAllByLabelText(/^View photo/)).toHaveLength(1);
fireEvent.click(screen.getByLabelText('Remove favorite'));
const empty = screen.getByRole('status');
expect(within(empty).getByText('No photos selected.')).toBeTruthy();
expect(screen.queryAllByLabelText(/^View photo/)).toHaveLength(0);
fireEvent.click(within(empty).getByRole('button', { name: 'Show All' }));
expect(screen.getAllByLabelText(/^View photo/)).toHaveLength(3);
expect(screen.queryByText('No photos selected.')).toBeNull();
});
it('shows no empty state while the filter still has photos', async () => {
store.set('folio_fav_wedding', JSON.stringify(['a1', 'a2']));
render(<PhotoGrid assets={ASSETS} proofing albumName="Wedding" />);
fireEvent.click(await screen.findByText('❤️ 2 Selected'));
expect(screen.getAllByLabelText(/^View photo/)).toHaveLength(2);
expect(screen.queryByText('No photos selected.')).toBeNull();
});
});

View File

@@ -0,0 +1,126 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import { renderToReadableStream } from 'react-dom/server';
import type { ReactNode } from 'react';
import { PhotoGrid, type PhotoItem } from '../PhotoGrid';
import { EssayView } from '../EssayView';
import { parseEssayMarkdown } from '@/lib/essay';
/**
* Proofing is off on most album pages, but its provider, modal and session
* controls used to be imported statically by the grid and the essay, so every
* album, essay and journal page shipped them (~5 KB gzip). They now load
* through `next/dynamic` only where proofing is on.
*
* The mocks below pass every module through unchanged and only note that it
* was loaded: a page without proofing must not load any of them, and a page
* with proofing must still render the hearts — on the server too, so they are
* in the HTML before the chunk arrives.
*/
const loaded = vi.hoisted(() => new Set<string>());
vi.mock('@/components/ProofingContext', async (importOriginal) => {
loaded.add('ProofingContext');
return importOriginal();
});
vi.mock('@/components/ProofingModal', async (importOriginal) => {
loaded.add('ProofingModal');
return importOriginal();
});
vi.mock('@/components/ProofSessionControls', async (importOriginal) => {
loaded.add('ProofSessionControls');
return importOriginal();
});
// The build aliases `next/dynamic` to the App Router implementation in app/;
// the bare package resolves to the Pages Router one.
vi.mock('next/dynamic', async () => ({
default: (await import('next/dist/shared/lib/app-dynamic')).default,
}));
vi.mock('next/image', () => ({
// eslint-disable-next-line @next/next/no-img-element
default: ({ alt }: { alt?: string }) => <img alt={alt ?? ''} />,
}));
vi.mock('@/components/LeafletMap', () => ({
LeafletMap: () => null,
escapeHtml: (s: string) => s,
}));
vi.mock('@/components/FadeIn', () => ({
FadeIn: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
const photo = (id: string): PhotoItem => ({
id,
type: 'image',
thumbUrl: `/t/${id}`,
previewUrl: `/p/${id}`,
exifUrl: `/e/${id}`,
aspectRatio: 1.5,
});
const ASSETS = [photo('a1'), photo('a2'), photo('a3')];
const ESSAY = parseEssayMarkdown('---\ntitle: Story\n---\n\n![a1, a3](caption)\n');
async function serverHtml(node: ReactNode): Promise<string> {
const stream = await renderToReadableStream(node);
await stream.allReady;
return new Response(stream).text();
}
const hearts = (html: string) => html.match(/aria-label="Add favorite"/g)?.length ?? 0;
beforeEach(() => {
// Node defines its own global localStorage (undefined without
// --localstorage-file), which shadows jsdom's; give the test a working one.
const store = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, String(v)),
removeItem: (k: string) => void store.delete(k),
clear: () => store.clear(),
});
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
// Runs first: the set records loads for the whole file.
describe('proofing off', () => {
it('loads neither the provider nor the modal nor the session controls', async () => {
const grid = render(<PhotoGrid assets={ASSETS} albumName="Wedding" />);
const essay = render(<EssayView essay={ESSAY} assets={ASSETS} title="Story" />);
expect(grid.container.querySelectorAll('img')).toHaveLength(3);
expect(essay.container.querySelectorAll('img')).toHaveLength(2);
expect(screen.queryByLabelText('Add favorite')).toBeNull();
await serverHtml(<PhotoGrid assets={ASSETS} albumName="Wedding" />);
expect([...loaded]).toEqual([]);
});
});
describe('proofing on', () => {
it('renders the hearts on the server', async () => {
const html = await serverHtml(<PhotoGrid assets={ASSETS} proofing albumName="Wedding" />);
expect(hearts(html)).toBe(3);
const essay = await serverHtml(
<EssayView essay={ESSAY} assets={ASSETS} title="Story" proofing />,
);
expect(hearts(essay)).toBe(2);
});
it('shares one context between the grid and the lazily loaded provider and modal', async () => {
render(<PhotoGrid assets={ASSETS} proofing albumName="Wedding" />);
const [first] = await screen.findAllByLabelText('Add favorite');
fireEvent.click(first);
// The heart, the selection bar and the modal all read the same state.
expect(screen.getAllByLabelText('Remove favorite')).toHaveLength(1);
fireEvent.click(await screen.findByText('Share & Export'));
expect(await screen.findByText(/You have selected 1 photo\./)).toBeTruthy();
expect(loaded).toContain('ProofingContext');
expect(loaded).toContain('ProofingModal');
});
});

View File

@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';
/**
* The page renders albums and essays from several branches — subpage album,
* essay, single-album subpage, standalone album — and each one passes the
* proofing props by hand. The dialog hides its email button without a
* recipient (#736), so a branch that forwards `allowMailto` but forgets
* `encodedMailto` silently loses the button on that kind of page. #746 missed
* two of the four; this keeps the next branch from doing the same.
*/
const source = fs.readFileSync(path.join(__dirname, '..', 'page.tsx'), 'utf8');
/** Every `<AlbumDetailView … />` and `<EssayView … />` element in the page. */
const elements = source.match(/<(AlbumDetailView|EssayView)\b[\s\S]*?\/>/g) ?? [];
describe('proofing email wiring in the catch-all page', () => {
it('finds the album and essay views', () => {
expect(elements.length).toBeGreaterThanOrEqual(4);
});
it('passes the recipient wherever it passes the email switch', () => {
const missing = elements
.filter((el) => el.includes('allowMailto='))
.filter((el) => !el.includes('encodedMailto='))
.map((el) => el.split('\n').slice(0, 3).join(' ').trim());
expect(missing).toEqual([]);
});
});

View File

@@ -0,0 +1,123 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
/**
* Two gaps in a subpage's `<head>`:
*
* - A `hidden: true` subpage is reachable by link only — off the nav, the
* home page and the sitemap — yet it and its albums inherited the layout's
* `index, follow`, so a crawler that found the link indexed it anyway.
* - A subpage with more than one album had no description of its own and fell
* back to the site-wide one; an album page describes itself by its photo
* count.
*/
vi.mock('next/headers', () => ({
cookies: async () => ({ get: () => undefined }),
}));
vi.mock('@/lib/admin/auth', () => ({ isAdminAuthenticated: async () => false }));
vi.mock('@/lib/i18n/server', async () => {
const { getDictionary } = await import('@/lib/i18n');
return { getServerDictionary: () => getDictionary('en') };
});
vi.mock('@/lib/ogImage', () => ({ ogImageUrl: () => '/api/og?sig=x' }));
vi.mock('../contentPage', () => ({
contentPageAccess: vi.fn(async () => null),
contentPageMetadata: vi.fn(async () => null),
renderContentPage: vi.fn(async () => null),
}));
vi.mock('@/lib/auth', () => ({
isProtected: () => false,
isAuthenticated: () => false,
withoutLockedAlbums: <T>(albums: T[]) => albums,
}));
const config = vi.hoisted(() => ({
subpages: [] as { slug: string; hidden?: boolean }[],
seo: { noFollow: false },
}));
vi.mock('@/lib/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/config')>();
return { ...actual, getConfig: () => config };
});
const album = (id: string, name: string) => ({
id,
albumName: name,
slug: name.toLowerCase(),
assets: [{ id: `${id}-a`, type: 'IMAGE' }],
albumThumbnailAssetId: null,
});
const ICELAND = album('album-1', 'Iceland');
const NORWAY = album('album-2', 'Norway');
const subpages: Record<string, { subtitle?: string; albums: ReturnType<typeof album>[] }> = {
travel: { albums: [ICELAND, NORWAY] },
trips: { subtitle: 'Where the road went', albums: [ICELAND, NORWAY] },
private: { albums: [ICELAND, NORWAY] },
handover: { albums: [ICELAND] },
};
const immich = vi.hoisted(() => ({
isSubpageSlug: vi.fn(),
getSubpageAlbums: vi.fn(),
getAlbumBySlug: vi.fn(),
}));
vi.mock('@/lib/immich', () => ({ immich }));
import { generateMetadata } from '../page';
const meta = (path: string[]) =>
generateMetadata({ params: Promise.resolve({ path }), searchParams: Promise.resolve({}) });
beforeEach(() => {
config.subpages = [
{ slug: 'travel' },
{ slug: 'trips' },
{ slug: 'private', hidden: true },
{ slug: 'handover', hidden: true },
];
config.seo.noFollow = false;
immich.isSubpageSlug.mockImplementation((slug: string) => slug in subpages);
immich.getSubpageAlbums.mockImplementation(async (slug: string) => {
const sp = subpages[slug];
return sp ? { subpage: { slug, name: slug, subtitle: sp.subtitle }, albums: sp.albums } : null;
});
immich.getAlbumBySlug.mockImplementation(
async (slug: string) => [ICELAND, NORWAY].find((a) => a.slug === slug) ?? null,
);
});
describe('hidden subpages stay out of search results', () => {
it.each([
['a hidden multi-album subpage', ['private']],
['a hidden single-album subpage', ['handover']],
['an album below a hidden subpage', ['private', 'iceland']],
])('marks %s noindex', async (_, path) => {
const m = await meta(path);
expect(m.robots).toEqual({ index: false, follow: true });
});
it('keeps the site-wide nofollow on a hidden subpage', async () => {
config.seo.noFollow = true;
expect((await meta(['private'])).robots).toEqual({ index: false, follow: false });
});
it('leaves a visible subpage, its albums and standalone albums to the layout', async () => {
expect((await meta(['travel'])).robots).toBeUndefined();
expect((await meta(['travel', 'iceland'])).robots).toBeUndefined();
expect((await meta(['iceland'])).robots).toBeUndefined();
});
});
describe('multi-album subpage description', () => {
it('describes the subpage by its album count when it has no subtitle', async () => {
const m = await meta(['travel']);
expect(m.description).toBe('travel — 2 albums');
expect(m.openGraph?.description).toBe('travel — 2 albums');
});
it('prefers the subtitle when there is one', async () => {
const m = await meta(['trips']);
expect(m.description).toBe('Where the road went');
});
});

View File

@@ -0,0 +1,118 @@
/**
* Content pages at `/<slug>` (#722): content/pages/<slug>.md rendered through
* the journal's EssayView, without a date, reading time or journal index.
*
* Draft and password behave as on a journal entry: a draft is a 404 for
* everyone but a signed-in admin, and a password shows the gate until the
* `lb_auth_page_<slug>` cookie is set. The site password is enforced before
* any of this runs, in proxy.ts.
*/
import type { Metadata } from 'next';
import { cookies } from 'next/headers';
import { readPage, type PageRecord } from '@/lib/admin/pages-service';
import { isAdminAuthenticated } from '@/lib/admin/auth';
import { isAuthenticated } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { getServerDictionary } from '@/lib/i18n/server';
import { ogImageUrl } from '@/lib/ogImage';
import PasswordGate from '@/components/PasswordGate';
import { EssayView } from './EssayView';
import { buildEssayPayload } from './essayPayload';
export type Access = 'open' | 'draft' | 'locked';
async function accessFor(page: PageRecord): Promise<Access> {
const { draft, password } = page.parsed.frontmatter;
if (draft && !(await isAdminAuthenticated())) return 'draft';
if (password) {
const cookieStore = await cookies();
if (!isAuthenticated(page.slug, (name) => cookieStore.get(name)?.value, 'page')) {
return 'locked';
}
}
return 'open';
}
/**
* How this request may see the page `slug`, or null when there is none. Read
* by routeExists() before the catch-all streams: a draft is a 404 to everyone
* but an admin, and that has to be known while the status can still say so.
*/
export async function contentPageAccess(slug: string): Promise<Access | null> {
const page = await readPage(slug).catch(() => null);
return page ? accessFor(page) : null;
}
/**
* Metadata for `/<slug>` when it is a page, or null when it is not.
*
* A draft is described as not found, and a locked page by a generic
* title only: generateMetadata runs ahead of the page's own gate, so the real
* title and description must not reach the `<head>` (GHSA-fvgv-97g3-wjr7).
*/
export async function contentPageMetadata(slug: string): Promise<Metadata | null> {
const page = await readPage(slug).catch(() => null);
if (!page) return null;
const access = await accessFor(page);
const t = getServerDictionary();
// A draft answers 404 to everyone but an admin, so it gets the not-found
// title, and no robots tag that would contradict Next's own noindex.
if (access === 'draft') return { title: t.error.notFoundTitle, robots: null };
if (access === 'locked') {
return { title: t.password.protectedPage, robots: { index: false } };
}
const { title: rawTitle, description } = page.parsed.frontmatter;
const title = rawTitle || slug;
const ogImage = ogImageUrl(title);
return {
title,
...(description ? { description } : {}),
openGraph: { title, ...(description ? { description } : {}), images: [ogImage] },
twitter: {
card: 'summary_large_image',
title,
...(description ? { description } : {}),
images: [ogImage],
},
};
}
/**
* The rendered page, `'not-found'` for a draft a visitor may not see, or null
* when no page has this slug (the caller then tries a standalone album).
*/
export async function renderContentPage(
slug: string,
): Promise<React.ReactElement | 'not-found' | null> {
const page = await readPage(slug).catch(() => null);
if (!page) return null;
const access = await accessFor(page);
if (access === 'draft') return 'not-found';
const { frontmatter, blocks } = page.parsed;
if (access === 'locked') {
return <PasswordGate slug={slug} title={frontmatter.title || slug} type="page" />;
}
// No map block in v1 (#722): it is dropped before any of its photos are
// fetched, so a hand-written `::map` cannot publish positions either.
const { essay, images } = await buildEssayPayload(blocks, {
logTag: 'page',
slug,
allowMap: false,
});
return (
<div className="content-page" style={{ paddingTop: '2rem' }}>
<EssayView
// Only the title crosses to the client: the frontmatter also holds
// the password, stored hashed but still not the visitor's business.
essay={{ ...essay, frontmatter: { title: frontmatter.title } }}
assets={images}
title={frontmatter.title}
watermark={getConfig().watermark}
/>
</div>
);
}

View File

@@ -0,0 +1,197 @@
/**
* Turn a parsed block-markdown document into what `EssayView` renders: the
* blocks with every asset id replaced by its token, and the photo items those
* tokens resolve to. Shared by journal entries and content pages (#722).
*
* Server only — it talks to Immich and encrypts asset ids.
*/
import {
collectAssetIds,
mapBlockAssetIds,
type JournalBlock,
type MapPin,
type ParsedJournal,
} from '@/lib/journal';
import { entryMapPins } from '@/lib/journalMap';
import { expandAlbumBlocks } from '@/lib/journalAlbum';
import { getConfig } from '@/lib/config';
import { immich, type ImmichAsset } from '@/lib/immich';
import {
imageUrl,
videoUrl,
exifUrl,
assetPlaceholder,
assetAspectRatio,
assetCaption,
assetExifSummary,
} from '@/lib/urls';
import { encodeAssetId } from '@/lib/tokens';
import type { PhotoItem } from './PhotoGrid';
export interface EssayPayload {
/** Blocks and cover carry tokens, never raw asset ids. Frontmatter is left empty. */
essay: ParsedJournal;
images: PhotoItem[];
}
interface EssayPayloadOptions {
/** Prefix for log lines, e.g. `journal` or `page`. */
logTag: string;
slug: string;
/** Map blocks are dropped when false, whatever the site's map setting. */
allowMap: boolean;
/** Raw asset id of a cover photo, shown in the lightbox sequence. */
coverAssetId?: string;
}
/**
* The assets of every album an album block names, keyed by album id, loaded
* concurrently. An album that fails is reported and left out, so its block
* drops rather than the page.
*/
export async function loadAlbumBlocks(
blocks: readonly JournalBlock[],
onError: (albumId: string, error: unknown) => void,
): Promise<Map<string, ImmichAsset[]>> {
const albumIds = [
...new Set(blocks.flatMap((b) => (b.type === 'album' && b.albumId ? [b.albumId] : []))),
];
const loaded = await Promise.all(
albumIds.map(async (albumId) => {
try {
return [albumId, await immich.getAlbumAssetsRaw(albumId)] as const;
} catch (error) {
onError(albumId, error);
return null;
}
}),
);
return new Map(loaded.filter((entry) => entry !== null));
}
export async function buildEssayPayload(
authoredBlocks: JournalBlock[],
{ logTag, slug, allowMap, coverAssetId }: EssayPayloadOptions,
): Promise<EssayPayload & { coverToken?: string }> {
const config = getConfig();
const mapAllowed = allowMap && config.map;
const sourceBlocks = allowMap ? authoredBlocks : authoredBlocks.filter((b) => b.type !== 'map');
const referencedAssetIds = [
...new Set([...(coverAssetId ? [coverAssetId] : []), ...collectAssetIds(sourceBlocks)]),
];
const fetchedAssets = (
await Promise.all(
referencedAssetIds.map((assetId) => immich.getAssetInfo(assetId).catch(() => null)),
)
).filter((a): a is ImmichAsset => a !== null);
// Album blocks become photo blocks here. The raw fetch bypasses the album
// allowlist on purpose: an entry may already show any single photo by id,
// and the author's pick is the gate for a whole album just the same. An
// album Immich cannot deliver drops its block rather than the page.
// All albums at once, not one round trip after the other.
const albumAssetsById = await loadAlbumBlocks(sourceBlocks, (albumId, error) => {
// eslint-disable-next-line no-console
console.warn(`[${logTag}] ${slug}: album ${albumId} could not be loaded:`, error);
});
const { blocks } = expandAlbumBlocks(
sourceBlocks,
(albumId) => albumAssetsById.get(albumId),
config.albumManualOrders,
);
const knownIds = new Set(fetchedAssets.map((a) => a.id));
const rawAssets = [...fetchedAssets];
for (const list of albumAssetsById.values()) {
for (const asset of list) {
if (knownIds.has(asset.id)) continue;
knownIds.add(asset.id);
rawAssets.push(asset);
}
}
// EssayView drops photo blocks it cannot resolve, which is right for visitors
// but leaves no trace of *why* a photo vanished. Legacy positional references
// ("1", "2") are the usual cause: they only resolved against a subpage album.
if (fetchedAssets.length < referencedAssetIds.length) {
const resolved = new Set(fetchedAssets.map((a) => a.id));
const missing = referencedAssetIds.filter((id) => !resolved.has(id));
// eslint-disable-next-line no-console
console.warn(
`[${logTag}] ${slug}: ${missing.length} photo reference(s) could not be resolved and will not render: ${missing.join(', ')}`,
);
}
/*
* EssayView resolves a block's photo through PhotoItem.id, and those ids are
* encrypted tokens — while the blocks coming out of the Markdown carry raw
* asset UUIDs. Translating here also keeps raw UUIDs out of the client
* payload. Unresolvable references collapse to an empty string, which
* EssayView skips.
*/
const tokenByAssetId = new Map(rawAssets.map((a) => [a.id, encodeAssetId(a.id)]));
const toToken = (assetId: string) => tokenByAssetId.get(assetId) ?? '';
// A map block's pins are computed here, from the photos fetched above and
// under each photo's `location:` precision, and only when the site
// publishes a map at all. The client gets pins only — never the items,
// which carry raw asset ids.
const entryPhotoIds = collectAssetIds(blocks.filter((b) => b.type !== 'map'));
const pinsByBlock = new Map<JournalBlock, MapPin[]>();
if (mapAllowed) {
for (const block of blocks) {
if (block.type === 'map') {
pinsByBlock.set(block, await entryMapPins(block.items, rawAssets, entryPhotoIds));
}
}
}
// Photos that only anchor a map pin are fetched for their EXIF but are not
// part of the story, so they stay out of the lightbox sequence.
const shownAssetIds = new Set([...entryPhotoIds, ...(coverAssetId ? [coverAssetId] : [])]);
const essay: ParsedJournal = {
frontmatter: {},
blocks: blocks.flatMap((block): JournalBlock[] => {
if (block.type === 'map') {
const pins = pinsByBlock.get(block);
return pins
? [{ type: 'map', caption: block.caption, line: block.line, items: [], pins }]
: [];
}
return [mapBlockAssetIds(block, toToken)];
}),
referencedAssetIds: rawAssets.map((a) => encodeAssetId(a.id)),
};
const images: PhotoItem[] = rawAssets
.filter((a) => shownAssetIds.has(a.id) && (a.type === 'IMAGE' || a.type === 'VIDEO'))
.map((a) => {
const ph = assetPlaceholder(a);
const exif =
config.exif.onHover && config.exif.camera && a.type === 'IMAGE'
? assetExifSummary(a)
: undefined;
const caption = assetCaption(a, config.exif.caption);
const isVideo = a.type === 'VIDEO';
return {
id: encodeAssetId(a.id),
type: isVideo ? 'video' : 'image',
thumbUrl: imageUrl(a, 'preview'),
previewUrl: imageUrl(a, 'preview'),
...(isVideo ? { videoUrl: videoUrl(a.id) } : {}),
exifUrl: exifUrl(a.id),
...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}),
...(exif ?? {}),
...(caption ? { caption } : {}),
aspectRatio: assetAspectRatio(a),
};
});
return {
essay,
images,
...(coverAssetId ? { coverToken: toToken(coverAssetId) || undefined } : {}),
};
}

View File

@@ -1,22 +0,0 @@
/**
* Instant shell for album and subpage routes. These are force-dynamic and wait
* on a live Immich round-trip — up to IMMICH_TIMEOUT_MS when the server is
* struggling — so without this the viewer stares at a blank page.
*/
import styles from '../loading.module.css';
import { getServerDictionary } from '@/lib/i18n/server';
export default function Loading() {
const t = getServerDictionary();
return (
<>
<div className={styles.heading} aria-hidden="true" />
<div className={styles.grid} role="status" aria-label={t.common.loadingPhotos}>
{Array.from({ length: 9 }).map((_, i) => (
<div key={i} className={styles.tile} />
))}
</div>
</>
);
}

View File

@@ -3,36 +3,33 @@
*
* Single segment:
* - If slug matches a subpage → render subpage album grid
* - If slug matches a content page (content/pages/<slug>.md) → render it
* - If slug matches a standalone album → render album detail
*
* Two segments:
* - Treat as subpage-slug/album-slug → render album detail
* with back-link to the subpage
*
* Three or more segments: 404.
*/
import { Suspense } from 'react';
import { cookies } from 'next/headers';
import type { Metadata } from 'next';
import { immich, type ImmichAsset } from '@/lib/immich';
import { notFound } from 'next/navigation';
import type { PhotoItem } from './PhotoGrid';
import {
imageUrl,
exifUrl,
videoUrl,
assetPlaceholder,
assetCaption,
assetExifSummary,
downloadUrl,
archiveUrl,
assetAspectRatio,
} from '@/lib/urls';
import { toPhotoItems } from './photoItems';
import { imageUrl, assetPlaceholder, archiveUrl } from '@/lib/urls';
import { ogImageUrl } from '@/lib/ogImage';
import { encodeAssetId, decodeAssetId } from '@/lib/tokens';
import { encodeEmail } from '@/lib/emailObfuscation';
import {
buildCoverGridVars,
getConfig,
hasExifPanelContent,
normalizeSlug,
resolveProofing,
resolveZoom,
type GridConfig,
} from '@/lib/config';
import { isProtected, isAuthenticated, withoutLockedAlbums } from '@/lib/auth';
@@ -45,13 +42,17 @@ import { albumStructuredData } from '@/lib/structuredData';
import { absoluteUrl } from '@/lib/siteUrl';
import { SubpageGridView } from './SubpageGridView';
import { EssayView } from './EssayView';
import { parseEssayMarkdown, type EssayBlock } from '@/lib/essay';
import { parseEssayMarkdown, type EssayBlock, type ParsedEssay } from '@/lib/essay';
import { pinsForEntry } from '@/lib/journalMap';
import { expandAlbumBlocks } from '@/lib/journalAlbum';
import { mapBlockAssetIds } from '@/lib/journal';
import { strictestPrecision, type LocationPrecision } from '@/lib/mapPrecision';
import { loadEssayFromFile } from '@/lib/admin/journal-service';
import { resolveEssayFile, generatedEssayCaption } from '@/lib/essaySource';
import { getServerDictionary } from '@/lib/i18n/server';
import { contentPageMetadata, renderContentPage } from './contentPage';
import { loadAlbumBlocks } from './essayPayload';
import { isLocked, routeExists } from './routeExists';
import { PathSkeleton } from './PathSkeleton';
// Render at request time — requires live Immich connection
export const dynamic = 'force-dynamic';
@@ -62,17 +63,21 @@ interface PathPageProps {
}
/**
* Whether `key` is password-protected and this request has not unlocked it.
* generateMetadata runs unconditionally — unlike the page body, nothing
* downstream of it stops a real title, photo count or cover image from
* reaching an unauthenticated `<head>` unless this is checked first
* (GHSA-fvgv-97g3-wjr7).
* Metadata for a path that names nothing this site publishes. The title is
* the dictionary's, never the requested slug: generateMetadata runs for any
* URL a visitor types, and echoing it would put their text in the `<title>`
* and, through a signed share card, in an image served from this domain.
* Leaving `openGraph` out keeps the root layout's site card. `robots: null`
* drops the layout's `index, follow`, which would otherwise contradict the
* `noindex` Next adds to every not-found response.
*/
async function isLocked(key: string, type: 'subpage' | 'album'): Promise<boolean> {
if (!isProtected(key, type)) return false;
const cookieStore = await cookies();
const getCookie = (name: string) => cookieStore.get(name)?.value;
return !isAuthenticated(key, getCookie, type);
function notFoundMetadata(): Metadata {
return { title: getServerDictionary().error.notFoundTitle, robots: null };
}
/** Metadata for a path behind a password this request has not given. */
function lockedMetadata(): Metadata {
return { title: getServerDictionary().password.protectedPage, robots: { index: false } };
}
export async function generateMetadata({ params, searchParams }: PathPageProps): Promise<Metadata> {
@@ -81,7 +86,8 @@ export async function generateMetadata({ params, searchParams }: PathPageProps):
// comparison downstream works on the same form (#522).
const { path: rawPath } = await params;
const path = rawPath?.map(normalizeSlug);
if (!path || path.length === 0) return {};
// Deeper paths are a 404 (see the page below); they name nothing to describe.
if (!path || path.length === 0 || path.length > 2) return notFoundMetadata();
// A shared photo link's whole point is that it reaches the server — unlike
// the #photo-N hash it replaces, a `photo` query param is visible here, so
@@ -92,45 +98,56 @@ export async function generateMetadata({ params, searchParams }: PathPageProps):
const photoAssetId = photoToken ? decodeAssetId(photoToken) : null;
let photoAsset: ImmichAsset | undefined;
// Each branch follows the page body's order of gates and 404s, and takes
// the title only from site-authored text (an album or subpage name). A
// branch that finds nothing, or finds it locked, returns early, so the
// requested slug never becomes a title or share-card text. isLocked() comes
// first because generateMetadata runs unconditionally: nothing downstream
// stops a real title, photo count or cover image from reaching an
// unauthenticated `<head>` otherwise (GHSA-fvgv-97g3-wjr7).
const slug = path[0];
let title = slug;
const t = getServerDictionary();
let title: string;
let subtitle = '';
let description: string | undefined = undefined;
const photoCount = (assets: ImmichAsset[]) =>
t.common.photos(assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length);
if (path.length === 1 && immich.isSubpageSlug(slug)) {
const subpageLocked = await isLocked(slug, 'subpage');
if (await isLocked(slug, 'subpage')) return lockedMetadata();
const result = await immich.getSubpageAlbums(slug);
if (result) {
if (!subpageLocked && result.subpage.subtitle) {
description = result.subpage.subtitle;
}
if (result.albums.length === 1) {
const album = await immich.getAlbumBySlug(result.albums[0].slug, slug);
if (album && !subpageLocked && !(await isLocked(album.id, 'album'))) {
title = album.albumName;
const count = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length;
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
if (photoAssetId) photoAsset = album.assets.find((a) => a.id === photoAssetId);
}
} else if (!subpageLocked) {
title = result.subpage.title || result.subpage.name;
}
if (!result || result.albums.length === 0) return notFoundMetadata();
if (result.subpage.subtitle) description = result.subpage.subtitle;
if (result.albums.length === 1) {
const album = await immich.getAlbumBySlug(result.albums[0].slug, slug);
if (!album) return notFoundMetadata();
if (await isLocked(album.id, 'album')) return lockedMetadata();
title = album.albumName;
subtitle = photoCount(album.assets);
if (photoAssetId) photoAsset = album.assets.find((a) => a.id === photoAssetId);
} else {
title = result.subpage.title || result.subpage.name;
// A cover grid has no photo count to describe it by; without this it
// inherited the site-wide description.
description ??= `${title} — ${t.common.albums(result.albums.length)}`;
}
} else if (path.length === 2) {
if (await isLocked(slug, 'subpage')) return lockedMetadata();
const album = await immich.getAlbumBySlug(path[1], slug);
if (album && !(await isLocked(slug, 'subpage')) && !(await isLocked(album.id, 'album'))) {
title = album.albumName;
const count = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length;
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
if (photoAssetId) photoAsset = album.assets.find((a) => a.id === photoAssetId);
}
if (!album) return notFoundMetadata();
if (await isLocked(album.id, 'album')) return lockedMetadata();
title = album.albumName;
subtitle = photoCount(album.assets);
if (photoAssetId) photoAsset = album.assets.find((a) => a.id === photoAssetId);
} else {
const pageMeta = await contentPageMetadata(slug);
if (pageMeta) return pageMeta;
const album = await immich.getAlbumBySlug(slug);
if (album && !(await isLocked(album.id, 'album'))) {
title = album.albumName;
const count = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length;
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
if (photoAssetId) photoAsset = album.assets.find((a) => a.id === photoAssetId);
}
if (!album) return notFoundMetadata();
if (await isLocked(album.id, 'album')) return lockedMetadata();
title = album.albumName;
subtitle = photoCount(album.assets);
if (photoAssetId) photoAsset = album.assets.find((a) => a.id === photoAssetId);
}
// The title stays the album's — it's still the context a reader wants —
@@ -140,13 +157,19 @@ export async function generateMetadata({ params, searchParams }: PathPageProps):
photoAsset?.exifInfo?.description?.trim() ||
description ||
(subtitle ? `${title} — ${subtitle}` : undefined);
const ogImage = photoAsset
? imageUrl(photoAsset.id, 'preview')
: `/api/og?title=${encodeURIComponent(title)}${subtitle ? `&subtitle=${encodeURIComponent(subtitle)}` : ''}`;
const ogImage = photoAsset ? imageUrl(photoAsset, 'preview') : ogImageUrl(title, subtitle);
// A hidden subpage is reachable by link only: out of the nav, the home page
// and the sitemap. Indexing it, or an album below it, would undo that.
const config = getConfig();
const hidden =
(path.length === 2 || immich.isSubpageSlug(slug)) &&
config.subpages.some((sp) => sp.slug === slug && sp.hidden === true);
return {
title,
description: ogDescription,
...(hidden ? { robots: { index: false, follow: !config.seo.noFollow } } : {}),
openGraph: {
title,
description: ogDescription,
@@ -161,57 +184,28 @@ export async function generateMetadata({ params, searchParams }: PathPageProps):
};
}
/** Map Immich assets to PhotoItem props for the grid/lightbox. */
/**
* `showExif` covers the hover overlay only, and that overlay carries camera,
* lens and focal length — so it follows the `camera` group, not the panel.
*
* `showCaption` follows the `caption` group and decides whether the Immich
* description becomes alt text; see `assetCaption`.
*/
function toPhotoItems(
assets: ImmichAsset[],
showExif: boolean,
showCaption: boolean,
/** The album offering downloads, or undefined when it does not. */
downloadAlbumId?: string,
): PhotoItem[] {
return assets
.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO')
.map((a) => {
const ph = assetPlaceholder(a);
const exif = showExif && a.type === 'IMAGE' ? assetExifSummary(a) : undefined;
const caption = assetCaption(a, showCaption);
const isVideo = a.type === 'VIDEO';
return {
id: encodeAssetId(a.id),
type: isVideo ? 'video' : 'image',
thumbUrl: imageUrl(a.id, 'preview'),
previewUrl: imageUrl(a.id, 'preview'),
...(isVideo ? { videoUrl: videoUrl(a.id) } : {}),
exifUrl: exifUrl(a.id),
...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}),
...(exif ?? {}),
...(caption ? { caption } : {}),
...(downloadAlbumId ? { downloadUrl: downloadUrl(downloadAlbumId, a.id) } : {}),
aspectRatio: assetAspectRatio(a),
};
});
}
/**
* JSON-LD for an album page. Null unless a site URL is configured — structured
* data is a set of claims about absolute URLs, and there is nothing truthful to
* claim without one (#472).
*/
function structuredDataFor(
album: { albumName: string; description?: string; albumThumbnailAssetId?: string | null },
album: {
albumName: string;
description?: string;
albumThumbnailAssetId?: string | null;
assets?: ImmichAsset[];
},
path: string,
photoCount: number,
) {
const config = getConfig();
const cover = album.albumThumbnailAssetId
? absoluteUrl(config.siteUrl, imageUrl(album.albumThumbnailAssetId, 'preview'))
const coverId = album.albumThumbnailAssetId;
// The album's own copy of its cover, when it has one, so an edit made in
// Immich reaches the URL (#831).
const coverAsset = coverId ? album.assets?.find((a) => a.id === coverId) : undefined;
const cover = coverId
? absoluteUrl(config.siteUrl, imageUrl(coverAsset ?? coverId, 'preview'))
: null;
return albumStructuredData({
siteUrl: config.siteUrl,
@@ -242,7 +236,8 @@ async function gateIfProtected(
let title = titleOverride || key;
if (!titleOverride && type === 'subpage') {
const subpageData = await immich.getSubpageAlbums(key);
title = subpageData?.subpage.name ?? key;
const sp = subpageData?.subpage;
title = sp ? sp.title || sp.name : key;
}
return <PasswordGate slug={key} title={title} type={type} />;
@@ -258,12 +253,39 @@ async function getAlbumHeroData(
const asset = await immich.getAssetInfo(heroAssetId);
const ph = asset ? assetPlaceholder(asset) : null;
return {
heroImageUrl: imageUrl(heroAssetId, 'preview'),
heroImageUrl: imageUrl(asset ?? heroAssetId, 'preview'),
heroBlurDataURL: ph?.blurDataURL,
};
}
export default async function PathPage({ params, searchParams }: PathPageProps) {
/**
* Decides whether the path exists before anything streams, then renders the
* content behind the skeleton.
*
* The order is the point. Next sends the status with the first byte, and the
* first byte goes out as soon as a Suspense fallback renders. A notFound()
* thrown after that can only add a noindex tag to a 200 (a "soft 404"), which
* is what every unknown slug got while this page sat inside a loading.tsx
* boundary. routeExists() needs only the config and the album list the header
* nav already waited for, so the check costs no time; the album's photos, the
* slow part, still load behind the skeleton.
*
* PathContent keeps its own notFound() calls as the backstop for what the
* check cannot see, such as an album removed from Immich since the list was
* cached. Those are still soft.
*/
export default async function PathPage(props: PathPageProps) {
const { path: rawPath } = await props.params;
if (!(await routeExists(rawPath?.map(normalizeSlug)))) notFound();
return (
<Suspense fallback={<PathSkeleton />}>
<PathContent {...props} />
</Suspense>
);
}
async function PathContent({ params, searchParams }: PathPageProps) {
// Next hands catch-all segments over percent-encoded, so a non-ASCII slug
// ("/家族相册") would never match a stored one. Decode once, here, and every
// comparison downstream works on the same form (#522).
@@ -280,6 +302,12 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
const config = getConfig();
// The proofing dialog's "email to photographer" needs a recipient: the
// configured `proofing.email`, else the footer contact email (#736). Encoded
// here so the plain address stays out of the HTML and the RSC payload, like
// every other address the site renders.
const encodedMailto = config.proofing.email ? encodeEmail(config.proofing.email) : undefined;
// Build grid CSS custom properties, optionally merging subpage overrides
const buildGridStyle = (overrides?: Partial<GridConfig>): React.CSSProperties => {
const g = { ...config.grid, ...overrides };
@@ -310,6 +338,10 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
// reached without a subpage follow the global setting.
const proofingFor = (subpage?: { proofing?: boolean }) =>
resolveProofing(subpage, config.proofing.enabled);
// Lightbox zoom (#467): album, then the subpage it is shown on, then the
// site setting. The album id goes to toPhotoItems() when it resolves on.
const zoomAlbumFor = (albumId: string, subpage?: { zoom?: boolean }) =>
resolveZoom(config, albumId, subpage) ? albumId : undefined;
// EXPERIMENTAL: per-album grid override — merged over the subpage grid so
// the precedence is global < subpage < album.
@@ -322,7 +354,10 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
return { ...spGrid, ...albumGrid };
};
if (!path || path.length === 0) {
// One segment or two: nothing on this site lives deeper. A third segment
// used to fall through to the one-segment branch below, which reads only
// `path[0]`, so `/travel/iceland/anything` rendered the Travel subpage.
if (!path || path.length === 0 || path.length > 2) {
notFound();
}
@@ -351,13 +386,16 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
// Look up subpage config for grid overrides and back link
const subpageData = await immich.getSubpageAlbums(subpageSlug, forceFresh);
const spGrid = subpageData?.subpage.grid;
const subpageName = subpageData?.subpage.name ?? subpageSlug;
const subpageName = subpageData
? subpageData.subpage.title || subpageData.subpage.name
: subpageSlug;
const images = toPhotoItems(
album.assets,
config.exif.onHover && config.exif.camera,
config.exif.caption,
config.albumDownloads[album.id] ? album.id : undefined,
zoomAlbumFor(album.id, subpageData?.subpage),
);
// Password gate for protected albums
@@ -385,6 +423,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
showGear={config.exif.camera}
proofing={proofingFor(subpageData?.subpage)}
allowMailto={config.proofing.allowMailto}
encodedMailto={encodedMailto}
downloadArchiveUrl={config.albumDownloads[album.id] ? archiveUrl(album.id) : undefined}
{...heroData}
/>
@@ -424,16 +463,33 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
!!result.subpage.essayText;
if (isEssay) {
let essayParsed = result.subpage.essayText
? parseEssayMarkdown(result.subpage.essayText)
: result.subpage.essayFile
? loadEssayFromFile(result.subpage.essayFile)
: null;
const cookieStore = await cookies();
let essayParsed: ParsedEssay | null = null;
if (result.subpage.essayText) {
essayParsed = parseEssayMarkdown(result.subpage.essayText);
} else if (result.subpage.essayFile) {
// A borrowed journal entry keeps its own draft flag and password.
const file = resolveEssayFile(result.subpage.essayFile, {
isAdmin: await isAdminAuthenticated(),
getCookie: (name) => cookieStore.get(name)?.value,
});
if (file.status === 'locked') {
// The subpage's own title, which the nav already shows — not the
// borrowed entry's, which the journal index hides while it is locked.
return (
<PasswordGate
slug={result.subpage.essayFile}
title={result.subpage.title || result.subpage.name}
type="journal"
/>
);
}
if (file.status === 'open') essayParsed = file.parsed;
}
// Fetch assets from the subpage's albums. An essay has no per-album
// gate to pass through, so an album with its own password stays out
// until it has been unlocked.
const cookieStore = await cookies();
const openAlbums = withoutLockedAlbums(albums, (name) => cookieStore.get(name)?.value);
const allAlbums = await Promise.all(
openAlbums.map((a) => immich.getAlbumBySlug(a.slug, slug, forceFresh)),
@@ -444,16 +500,10 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
// does, from a raw fetch, and add those photos to the page's assets.
// Their ids are encoded here because this path hands EssayView tokens.
if (essayParsed?.blocks.some((b) => b.type === 'album')) {
const byAlbum = new Map<string, typeof allAssets>();
for (const b of essayParsed.blocks) {
if (b.type !== 'album' || !b.albumId || byAlbum.has(b.albumId)) continue;
try {
byAlbum.set(b.albumId, await immich.getAlbumAssetsRaw(b.albumId));
} catch (error) {
// eslint-disable-next-line no-console
console.warn(`[essay] ${slug}: album ${b.albumId} could not be loaded:`, error);
}
}
const byAlbum = await loadAlbumBlocks(essayParsed.blocks, (albumId, error) => {
// eslint-disable-next-line no-console
console.warn(`[essay] ${slug}: album ${albumId} could not be loaded:`, error);
});
const expanded = expandAlbumBlocks(
essayParsed.blocks,
(id) => byAlbum.get(id),
@@ -494,7 +544,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
...a.assets.map((asset) => ({
type: 'photo' as const,
assetId: encodeAssetId(asset.id),
caption: asset.exifInfo?.description || undefined,
caption: generatedEssayCaption(asset, config.exif.caption),
layout: 'contained' as const,
})),
]),
@@ -542,6 +592,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
watermark={config.watermark}
proofing={essayProofing}
allowMailto={config.proofing.allowMailto}
encodedMailto={encodedMailto}
/>
);
}
@@ -568,6 +619,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
config.exif.onHover && config.exif.camera,
config.exif.caption,
config.albumDownloads[album.id] ? album.id : undefined,
zoomAlbumFor(album.id, result.subpage),
);
// Password gate for protected albums
@@ -590,6 +642,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
showGear={config.exif.camera}
proofing={proofingFor(result.subpage)}
allowMailto={config.proofing.allowMailto}
encodedMailto={encodedMailto}
downloadArchiveUrl={config.albumDownloads[album.id] ? archiveUrl(album.id) : undefined}
{...heroData}
/>
@@ -611,14 +664,23 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
};
});
// Batch-fetch ThumbHash for album cover placeholders
const coverPlaceholders = await Promise.all(
albumsWithHero.map(async (album) => {
if (!album.albumThumbnailAssetId) return null;
const asset = await immich.getAssetInfo(album.albumThumbnailAssetId);
return asset ? assetPlaceholder(asset) : null;
}),
// Batch-fetch the cover assets: ThumbHash for the placeholders, and
// whether the cover was edited in Immich, which its URL has to say (#831).
const coverAssets = await Promise.all(
albumsWithHero.map((album) =>
album.albumThumbnailAssetId ? immich.getAssetInfo(album.albumThumbnailAssetId) : null,
),
);
const coverPlaceholders = coverAssets.map((asset) => (asset ? assetPlaceholder(asset) : null));
const albumsWithCover = albumsWithHero.map((album, i) => {
const asset = coverAssets[i];
return asset
? {
...album,
cover: { id: asset.id, isEdited: asset.isEdited, updatedAt: asset.updatedAt },
}
: album;
});
// 1-based position among the enabled subpages — drives the header kicker.
const enabledSubpages = config.subpages.filter((sp) => sp.enabled !== false);
@@ -638,18 +700,28 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
slug={slug}
title={result.subpage.title || result.subpage.name}
subtitle={result.subpage.subtitle}
albums={albumsWithHero}
albums={albumsWithCover}
coverPlaceholders={coverPlaceholders}
sections={result.subpage.sections}
gridStyle={buildCoverGridStyle(result.subpage.coverGrid)}
{...(subpageIndex >= 0 ? { index: subpageIndex + 1 } : {})}
{...(nextSubpage
? { nextSubpage: { slug: nextSubpage.slug, name: nextSubpage.name } }
? {
nextSubpage: { slug: nextSubpage.slug, name: nextSubpage.title || nextSubpage.name },
}
: {})}
/>
);
}
// A content page (#722). The admin refuses a page slug that collides with a
// subpage or an album, so the order here only matters for a hand-edited file.
if (path.length === 1) {
const page = await renderContentPage(slug);
if (page === 'not-found') notFound();
if (page) return page;
}
// Otherwise treat as a standalone album slug
const album = await immich.getAlbumBySlug(slug, undefined, forceFresh);
if (!album) {
@@ -669,6 +741,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
config.exif.onHover && config.exif.camera,
config.exif.caption,
config.albumDownloads[album.id] ? album.id : undefined,
zoomAlbumFor(album.id),
);
// Password gate for protected albums
@@ -692,6 +765,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
showGear={config.exif.camera}
proofing={proofingFor()}
allowMailto={config.proofing.allowMailto}
encodedMailto={encodedMailto}
downloadArchiveUrl={config.albumDownloads[album.id] ? archiveUrl(album.id) : undefined}
{...heroData}
/>

View File

@@ -0,0 +1,72 @@
/**
* Immich assets → the PhotoItem props the grid and lightbox render.
*
* Shared by the album routes and the client proofing page, so both render a
* photo exactly the same way.
*/
import type { ImmichAsset } from '@/lib/immich';
import { encodeAssetId } from '@/lib/tokens';
import {
assetAspectRatio,
assetCaption,
assetExifSummary,
assetPlaceholder,
downloadUrl,
exifUrl,
imageUrl,
videoUrl,
zoomUrl,
} from '@/lib/urls';
import { zoomDimensions, zoomSourceFor } from '@/lib/zoomSource';
import type { PhotoItem } from './PhotoGrid';
/**
* `showExif` covers the hover overlay only, and that overlay carries camera,
* lens and focal length — so it follows the `camera` group, not the panel.
*
* `showCaption` follows the `caption` group and decides whether the Immich
* description becomes alt text; see `assetCaption`.
*/
export function toPhotoItems(
assets: ImmichAsset[],
showExif: boolean,
showCaption: boolean,
/** The album offering downloads, or undefined when it does not. */
downloadAlbumId?: string,
/**
* The album whose page offers lightbox zoom, or undefined when zoom resolves
* off for it (#467). Each photo still has to be zoomable on its own.
*/
zoomAlbumId?: string,
): PhotoItem[] {
return assets
.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO')
.map((a) => {
const ph = assetPlaceholder(a);
const exif = showExif && a.type === 'IMAGE' ? assetExifSummary(a) : undefined;
const caption = assetCaption(a, showCaption);
const isVideo = a.type === 'VIDEO';
const zoomSize = zoomAlbumId && zoomSourceFor(a) ? zoomDimensions(a) : undefined;
return {
id: encodeAssetId(a.id),
type: isVideo ? 'video' : 'image',
thumbUrl: imageUrl(a, 'preview'),
previewUrl: imageUrl(a, 'preview'),
...(isVideo ? { videoUrl: videoUrl(a.id) } : {}),
exifUrl: exifUrl(a.id),
...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}),
...(exif ?? {}),
...(caption ? { caption } : {}),
...(downloadAlbumId ? { downloadUrl: downloadUrl(downloadAlbumId, a.id) } : {}),
...(zoomAlbumId && zoomSize
? {
zoomUrl: zoomUrl(zoomAlbumId, a.id),
zoomWidth: zoomSize.width,
zoomHeight: zoomSize.height,
}
: {}),
aspectRatio: assetAspectRatio(a),
};
});
}

View File

@@ -0,0 +1,70 @@
/**
* Whether the catch-all has anything to show for a path — asked before the
* page streams, so that a miss can still answer with a real 404.
*
* Next commits to the status with the first byte. The page used to sit inside
* a loading.tsx Suspense boundary, so the skeleton went out as a 200 and the
* notFound() that followed could only add a noindex tag: every unknown slug, an
* offline subpage, a draft content page and `/<subpage>/<unknown>` were soft
* 404s. The page now calls this first, outside any boundary, and only then
* streams the skeleton around the part that fetches photos.
*
* Cost: it reads the config, the album list and content/pages/. The album list
* is the one the header nav already waits for before the first byte, so this
* adds no Immich request and no latency; the album's assets — the slow part —
* are still fetched behind the skeleton.
*
* It mirrors the order of gates and 404s in the page body, and may be looser
* than the body, never stricter. A path it passes that the body then rejects
* only loses the status (an album that vanished from Immich between the list
* and the asset fetch stays a soft 404); a path it rejects that the body would
* render is a page nobody can open.
*
* Errors propagate. An Immich outage with no stale list to fall back on must
* surface as an error, never as "this album does not exist".
*/
import { cookies } from 'next/headers';
import { immich } from '@/lib/immich';
import { isAuthenticated, isProtected } from '@/lib/auth';
import { isAdminAuthenticated } from '@/lib/admin/auth';
import { contentPageAccess } from './contentPage';
/** Whether `key` is password-protected and this request has not unlocked it. */
export async function isLocked(key: string, type: 'subpage' | 'album'): Promise<boolean> {
if (!isProtected(key, type)) return false;
const cookieStore = await cookies();
const getCookie = (name: string) => cookieStore.get(name)?.value;
return !isAuthenticated(key, getCookie, type);
}
/** `path` is already slug-normalised, as the page hands it over. */
export async function routeExists(path: readonly string[] | undefined): Promise<boolean> {
// One segment or two: nothing on this site lives deeper, for anyone.
if (!path || path.length === 0 || path.length > 2) return false;
if (await exists(path)) return true;
// Where a visitor gets the 404, a signed-in admin gets a diagnostic banner
// (or the draft content page).
return isAdminAuthenticated();
}
async function exists([first, second]: readonly string[]): Promise<boolean> {
if (second !== undefined) {
// A locked subpage shows its gate for any album slug below it. Answering
// 404 for the unknown ones would tell a visitor without the password which
// albums are behind it.
if (await isLocked(first, 'subpage')) return true;
return (await immich.findAlbumBySlug(second, first)) !== null;
}
if (immich.isSubpageSlug(first)) {
if (await isLocked(first, 'subpage')) return true;
const result = await immich.getSubpageAlbums(first);
return !!result && result.albums.length > 0;
}
const page = await contentPageAccess(first);
if (page) return page !== 'draft';
return (await immich.findAlbumBySlug(first)) !== null;
}

View File

@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderToStaticMarkup } from 'react-dom/server';
/**
* A locked site still serves /impressum and /privacy (proxy.ts, UNGATED_PAGES),
* and the gate is where a visitor who cannot get in finds them. A link to a
* page that is switched off would lead to a 404, so each appears only when its
* page exists.
*/
const config = {
siteTitle: 'Test Folio',
lang: 'en',
legal: { enabled: true },
privacy: { enabled: true },
};
let privacy = true;
vi.mock('@/lib/config', () => ({
getConfig: () => config,
getConfigOrNull: () => config,
}));
vi.mock('@/lib/auth', () => ({
isSiteLocked: () => true,
SITE_AUTH_KEY: '__site__',
}));
vi.mock('@/lib/privacy', () => ({
privacyAvailable: () => privacy,
}));
import GatePage from '../gate/page';
const html = () => renderToStaticMarkup(<GatePage />);
describe('site gate page', () => {
beforeEach(() => {
config.lang = 'en';
config.legal.enabled = true;
privacy = true;
});
it('links the legal notice and the privacy policy', () => {
const out = html();
expect(out).toContain('href="/impressum"');
expect(out).toContain('href="/privacy"');
expect(out).toContain('aria-label="Legal information"');
});
it('leaves out a page that is switched off', () => {
config.legal.enabled = false;
expect(html()).not.toContain('href="/impressum"');
expect(html()).toContain('href="/privacy"');
config.legal.enabled = true;
privacy = false;
expect(html()).toContain('href="/impressum"');
expect(html()).not.toContain('href="/privacy"');
});
it('renders no empty link list when neither page exists', () => {
config.legal.enabled = false;
privacy = false;
expect(html()).not.toContain('<nav');
});
it('labels the links in the site language', () => {
config.lang = 'de';
const out = html();
expect(out).toContain('>Impressum</a>');
expect(out).toContain('>Datenschutz</a>');
});
});

View File

@@ -0,0 +1,29 @@
import { describe, it, expect, vi } from 'vitest';
/**
* A 404 decided before streaming — by proxy.ts, or by a page calling
* notFound() outside any Suspense boundary — is rendered from app/not-found.tsx
* inside the root layout, and Next takes its metadata from the layouts and
* that file, never from the page. Without metadata of its own the tab showed
* the bare site title, and the layout's `index, follow` sat next to Next's
* `noindex`; the soft-404 path meanwhile read "Not found".
*/
vi.mock('@/lib/i18n/server', async () => {
const { getDictionary } = await import('@/lib/i18n');
return { getServerDictionary: () => getDictionary('de') };
});
import * as notFoundModule from '../not-found';
import { getDictionary } from '@/lib/i18n';
describe('app/not-found.tsx metadata', () => {
it('titles a hard 404 from the dictionary and drops the layout robots tag', async () => {
const { generateMetadata } = notFoundModule as { generateMetadata?: () => unknown };
expect(typeof generateMetadata).toBe('function');
expect(await generateMetadata!()).toEqual({
title: getDictionary('de').error.notFoundTitle,
robots: null,
});
});
});

View File

@@ -0,0 +1,68 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
/**
* P-17: /about shared with no picture. A page's openGraph replaces the
* layout's whole, so leaving `images` out dropped the site card too.
*/
let aboutMd = '';
vi.mock('fs', async (orig) => {
const actual = await orig<typeof import('fs')>();
return {
...actual,
promises: {
...actual.promises,
readFile: async (path: string, enc: string) =>
String(path).endsWith('about.md')
? aboutMd
: actual.promises.readFile(path, enc as 'utf-8'),
},
};
});
vi.mock('@/lib/config', () => ({
getConfig: () => ({ authSecret: 'test-secret', aboutEnabled: true }),
getConfigOrNull: () => ({ lang: 'en' }),
}));
vi.mock('@/lib/urls', () => ({
imageUrl: (ref: string | { id: string; isEdited?: boolean }, size: string) =>
typeof ref === 'string'
? `/api/image/tok-${ref}?size=${size}`
: `/api/image/tok-${ref.id}?size=${size}${ref.isEdited ? '&e=edit' : ''}`,
assetPlaceholder: () => null,
}));
const portraitAsset = vi.hoisted(() => ({ value: null as null | Record<string, unknown> }));
vi.mock('@/lib/immich', () => ({
immich: { getAssetInfo: async () => portraitAsset.value },
}));
const { generateMetadata } = await import('../page');
describe('/about metadata', () => {
beforeEach(() => {
aboutMd = '';
portraitAsset.value = null;
});
it('shares a portrait edited in Immich under its edited URL (#831)', async () => {
aboutMd = '---\nname: Ada\nportrait: portrait-uuid\n---\nHello.';
portraitAsset.value = { id: 'portrait-uuid', isEdited: true };
const m = await generateMetadata();
expect(m.openGraph?.images).toEqual(['/api/image/tok-portrait-uuid?size=preview&e=edit']);
});
it('uses the portrait as the share image', async () => {
aboutMd = '---\nname: Ada\nportrait: portrait-uuid\n---\nHello.';
const m = await generateMetadata();
expect(m.openGraph?.images).toEqual(['/api/image/tok-portrait-uuid?size=preview']);
expect(m.twitter?.images).toEqual(['/api/image/tok-portrait-uuid?size=preview']);
});
it('falls back to the generated card without a portrait', async () => {
aboutMd = '---\nname: Ada\n---\nHello.';
const m = await generateMetadata();
const [image] = m.openGraph?.images as string[];
expect(image).toMatch(/^\/api\/og\?/);
expect(new URL(image, 'https://x.test').searchParams.get('title')).toBe(m.title);
});
});

View File

@@ -9,6 +9,7 @@ import yaml from 'js-yaml';
import Image from 'next/image';
import type { Metadata } from 'next';
import { imageUrl, assetPlaceholder } from '@/lib/urls';
import { ogImageUrl } from '@/lib/ogImage';
import { immich } from '@/lib/immich';
import { getConfig } from '@/lib/config';
import { getServerDictionary } from '@/lib/i18n/server';
@@ -56,6 +57,17 @@ export async function generateMetadata(): Promise<Metadata> {
const t = getServerDictionary();
const title = meta.name ? t.about.metaTitle(meta.name) : t.about.title;
const description = body ? body.slice(0, 160).replace(/\s+/g, ' ').trim() : undefined;
// A page's openGraph replaces the layout's whole, images included, so
// leaving them out shared /about with no picture at all. The portrait is the
// obvious one (it is a published asset); without it, the generated card.
// The portrait's asset for its edit marker (#831), from the cache the page
// body reads as well.
const portraitAsset = meta.portrait
? await immich.getAssetInfo(meta.portrait).catch(() => null)
: null;
const ogImage = meta.portrait
? imageUrl(portraitAsset ?? meta.portrait, 'preview')
: ogImageUrl(title);
return {
title,
@@ -63,11 +75,13 @@ export async function generateMetadata(): Promise<Metadata> {
openGraph: {
title,
description,
images: [ogImage],
},
twitter: {
card: 'summary_large_image',
title,
description,
images: [ogImage],
},
};
}
@@ -89,7 +103,7 @@ export default async function AboutPage() {
<div className="about__portrait-col">
{meta.portrait ? (
<Image
src={imageUrl(meta.portrait, 'preview')}
src={imageUrl(portraitAsset ?? meta.portrait, 'preview')}
alt={meta.name || t.about.portraitAlt}
className="about__portrait"
fill

View File

@@ -4,7 +4,9 @@ import { useState, useEffect, type ReactNode } from 'react';
import AdminLogin from './components/AdminLogin';
import AdminDashboard from './components/AdminDashboard';
import { SESSION_EXPIRED_EVENT } from './components/sessionExpiry';
import { NotificationProvider } from './components/Notifications';
import './admin.css';
import { ConfirmProvider } from './components/ConfirmDialog';
/**
* Auth gate and panel chrome for every /admin route. Lives in the admin layout
@@ -47,8 +49,9 @@ export default function AdminShell({ children }: { children: ReactNode }) {
if (authenticated === null) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
<div className="admin-loading" role="status">
<div className="admin-spinner" aria-hidden="true" />
<span className="sr-only">Loading admin panel</span>
</div>
);
}
@@ -79,5 +82,11 @@ export default function AdminShell({ children }: { children: ReactNode }) {
);
}
return <AdminDashboard onLogout={() => setAuthenticated(false)}>{children}</AdminDashboard>;
return (
<NotificationProvider>
<ConfirmProvider>
<AdminDashboard onLogout={() => setAuthenticated(false)}>{children}</AdminDashboard>
</ConfirmProvider>
</NotificationProvider>
);
}

View File

@@ -0,0 +1,58 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach, vi } from 'vitest';
import { render, screen, cleanup, waitFor } from '@testing-library/react';
import AdminOverview from '../components/AdminOverview';
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
/** Every admin endpoint answers 404 except gallery.yaml, which holds `gallery`. */
function stubGallery(gallery: unknown) {
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) =>
url === '/api/admin/gallery'
? new Response(JSON.stringify({ gallery, version: 'v' }), { status: 200 })
: new Response(JSON.stringify({ error: 'nope' }), { status: 404 }),
),
);
}
/** The "Pages" tile: its number and the note under it, once loaded. */
async function pagesTile() {
const label = await screen.findByText('Pages');
const tile = label.closest('a')!;
const value = () => tile.querySelector('.overview-stat-value')!.textContent;
await waitFor(() => expect(value()).not.toBe('–'));
return { value: value(), note: tile.querySelector('.overview-stat-note')!.textContent };
}
describe('AdminOverview — Pages tile', () => {
it('counts subpages written in the older map form of gallery.yaml', async () => {
// `subpages:` as a map of name → albums (or name → object) is still a
// valid gallery.yaml — getConfig() and the page builder both read it.
// The overview called .filter() on it, and the first admin screen crashed.
stubGallery({
subpages: {
Weddings: ['11111111-1111-4111-8111-111111111111'],
Travel: { albums: ['22222222-2222-4222-8222-222222222222'], enabled: false },
},
});
render(<AdminOverview />);
expect(await pagesTile()).toEqual({ value: '1', note: '1 switched off' });
});
it('leaves content-page menu entries out of the list form', async () => {
stubGallery({
subpages: [
{ name: 'Weddings', albums: ['11111111-1111-4111-8111-111111111111'] },
{ page: 'pricing' },
{ name: 'Old', albums: [], enabled: false },
],
});
render(<AdminOverview />);
expect(await pagesTile()).toEqual({ value: '1', note: '1 switched off' });
});
});

View File

@@ -0,0 +1,76 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
import { render, screen, cleanup, fireEvent, act } from '@testing-library/react';
import AssetPicker from '../components/AssetPicker';
/**
* A fetch whose answers the test hands out by hand, in any order — the
* situation of a slow request still running when the operator moves on.
*/
function deferredFetch() {
const pending: Array<{ url: string; resolve: (body: unknown) => void }> = [];
const fetchMock = vi.fn(
(url: string) =>
new Promise<Response>((resolve) => {
pending.push({
url,
resolve: (body) =>
resolve({ ok: true, status: 200, json: async () => body } as unknown as Response),
});
}),
);
/** Answer the first open request whose URL contains `match`. */
const answer = async (match: string, body: unknown) => {
const i = pending.findIndex((p) => p.url.includes(match));
if (i === -1) throw new Error(`no pending request for ${match}`);
const [req] = pending.splice(i, 1);
await act(async () => req.resolve(body));
};
return { fetchMock, answer };
}
const asset = (id: string, name: string) => ({
id,
originalFileName: name,
fileCreatedAt: '2024-05-17T10:00:00.000Z',
isFavorite: false,
});
let net: ReturnType<typeof deferredFetch>;
beforeEach(() => {
net = deferredFetch();
vi.stubGlobal('fetch', net.fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('AssetPicker', () => {
it('shows the tab it is on when an older request answers last', async () => {
render(<AssetPicker onSelect={() => {}} onClose={() => {}} />);
// Opens on Favorites; the operator switches to All Photos before it loads.
fireEvent.click(screen.getByRole('button', { name: /All Photos/ }));
await net.answer('favorites=false', { assets: [asset('a1', 'all.jpg')], nextPage: null });
await net.answer('favorites=true', { assets: [asset('f1', 'fav.jpg')], nextPage: null });
expect(screen.getByRole('button', { name: /all\.jpg/ })).toBeTruthy();
expect(screen.queryByRole('button', { name: /fav\.jpg/ })).toBeNull();
});
it('keeps the spinner until the current request answers', async () => {
render(<AssetPicker onSelect={() => {}} onClose={() => {}} />);
fireEvent.click(screen.getByRole('button', { name: /All Photos/ }));
// The stale Favorites answer must not end the All Photos loading state.
await net.answer('favorites=true', { assets: [asset('f1', 'fav.jpg')], nextPage: null });
expect(screen.queryByRole('button', { name: /fav\.jpg/ })).toBeNull();
expect(screen.queryByText(/No favorite photos found/)).toBeNull();
await net.answer('favorites=false', { assets: [asset('a1', 'all.jpg')], nextPage: null });
expect(screen.getByRole('button', { name: /all\.jpg/ })).toBeTruthy();
});
});

View File

@@ -0,0 +1,105 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
import { ConfirmProvider, useChoice, useConfirm } from '../components/ConfirmDialog';
afterEach(cleanup);
/** UX stage 3 (#694 §3): what replaced window.confirm in the admin. */
function Trigger({ onResult }: { onResult: (ok: boolean) => void }) {
const confirm = useConfirm();
return (
<button
onClick={async () =>
onResult(
await confirm({
title: 'Delete it?',
message: 'Gone for good.',
confirmLabel: 'Delete',
danger: true,
}),
)
}
>
go
</button>
);
}
function setup() {
const results: boolean[] = [];
render(
<ConfirmProvider>
<Trigger onResult={(ok) => results.push(ok)} />
</ConfirmProvider>,
);
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]));
});
});
/** The concurrent-edit prompt (#601): more than two ways out. */
function ChoiceTrigger({ onResult }: { onResult: (id: string | null) => void }) {
const choose = useChoice();
return (
<button
onClick={async () =>
onResult(
await choose({
title: 'Changed elsewhere',
actions: [
{ id: 'overwrite', label: 'Overwrite anyway' },
{ id: 'reload', label: 'Reload (discard mine)' },
],
cancelLabel: 'Keep editing',
}),
)
}
>
choose
</button>
);
}
describe('useChoice', () => {
it('resolves to the picked action, or null on cancel', async () => {
const results: (string | null)[] = [];
render(
<ConfirmProvider>
<ChoiceTrigger onResult={(id) => results.push(id)} />
</ConfirmProvider>,
);
fireEvent.click(screen.getByText('choose'));
fireEvent.click(screen.getByRole('button', { name: 'Reload (discard mine)' }));
await waitFor(() => expect(results).toEqual(['reload']));
fireEvent.click(screen.getByText('choose'));
fireEvent.click(screen.getByRole('button', { name: 'Overwrite anyway' }));
await waitFor(() => expect(results).toEqual(['reload', 'overwrite']));
fireEvent.click(screen.getByText('choose'));
fireEvent.click(screen.getByRole('button', { name: 'Keep editing' }));
await waitFor(() => expect(results).toEqual(['reload', 'overwrite', null]));
});
});

View File

@@ -0,0 +1,57 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach, vi } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import NewPageDialog from '../components/page-builder/NewPageDialog';
afterEach(cleanup);
const taken = { subpages: [], albums: [], journal: [] };
function setup() {
const onCreate = vi.fn();
render(
<NewPageDialog
taken={taken}
existingPageSlugs={[]}
creating={false}
onCreate={onCreate}
onClose={() => {}}
/>,
);
return { onCreate, slug: screen.getByLabelText('URL') as HTMLInputElement };
}
/** Type one key at a time into a controlled input, as a person does. */
function typeKeys(input: HTMLInputElement, text: string) {
for (const ch of text) fireEvent.change(input, { target: { value: input.value + ch } });
}
describe('NewPageDialog slug field', () => {
it('takes a hyphen typed between two words', () => {
const { slug, onCreate } = setup();
fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'About' } });
// Select all and retype: the first key replaces the derived slug.
fireEvent.change(slug, { target: { value: 'a' } });
typeKeys(slug, 'bout-us');
expect(slug.value).toBe('about-us');
fireEvent.click(screen.getByRole('button', { name: 'Create page' }));
expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ slug: 'about-us' }));
});
it('creates the finished slug when the field ends in a hyphen', () => {
const { slug, onCreate } = setup();
fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'Pricing' } });
typeKeys(slug, '-');
expect(slug.value).toBe('pricing-');
fireEvent.click(screen.getByRole('button', { name: 'Create page' }));
expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ slug: 'pricing' }));
});
it('derives a readable slug from a German title', () => {
const { slug } = setup();
fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'Preise für Paare' } });
expect(slug.value).toBe('preise-fur-paare');
});
});

View File

@@ -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 <button onClick={() => notify(kind, message)}>go</button>;
}
/** #600: what replaced alert() in the admin. */
describe('admin notifications', () => {
it('announces an error assertively and keeps it until dismissed', () => {
vi.useFakeTimers();
render(
<NotificationProvider>
<Trigger kind="error" message="Save failed" />
</NotificationProvider>,
);
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(
<NotificationProvider>
<Trigger kind="success" message="Saved" />
</NotificationProvider>,
);
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(<Trigger kind="error" message="x" />);
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 (
<button onClick={() => notify('success', 'Removed', { label: 'Undo', run: () => runs++ })}>
go
</button>
);
}
render(
<NotificationProvider>
<WithAction />
</NotificationProvider>,
);
fireEvent.click(screen.getByText('go'));
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
expect(runs).toBe(1);
expect(screen.queryByText('Removed')).toBeNull();
});
});

View File

@@ -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(<SaveBar {...props} saveMessage="Saved!" />);
const { rerender } = render(
<SaveBar {...props} status={{ kind: 'success', message: 'Saved!' }} />,
);
expect(screen.getByRole('button', { name: 'Save' })).toHaveProperty('disabled', true);
rerender(<SaveBar {...props} dirty />);
@@ -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(<SaveBar {...props} saveMessage="Error: Failed to save" />);
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(
<SaveBar {...props} status={{ kind: 'error', message: 'Error: Failed to save' }} />,
);
expect(screen.getByText('Error: Failed to save').className).toContain('error');
rerender(<SaveBar {...props} saveMessage="Saved!" />);
rerender(<SaveBar {...props} status={{ kind: 'success', message: 'Saved!' }} />);
expect(screen.getByText('Saved!').className).toContain('success');
rerender(<SaveBar {...props} saveMessage="Could not reach the server" />);
expect(screen.getByText('Could not reach the server').className).toContain('success');
rerender(
<SaveBar {...props} status={{ kind: 'error', message: 'Could not reach the server' }} />,
);
expect(screen.getByText('Could not reach the server').className).toContain('error');
});
it('offers the preview link only where it belongs', () => {

View File

@@ -0,0 +1,61 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { DndContext } from '@dnd-kit/core';
import { SortableContext } from '@dnd-kit/sortable';
import { SortablePageRow, SortableHeroTile } from '../components/page-builder/SortableTiles';
import { dndAnnouncements } from '../components/dndAnnouncements';
afterEach(cleanup);
/**
* QA A-20: the sortable attributes (role="button", tabindex) sat on the whole
* row, which also holds the row's own button — nested interactive content. They
* belong on the drag handle. And the drag announcements read out internal ids.
*/
describe('sortable rows', () => {
it('put the sortable role on the handle, not around the row button', () => {
render(
<DndContext>
<SortableContext items={['page-pricing']}>
<SortablePageRow id="page-pricing" title="Pricing" isActive={false} onClick={() => {}} />
</SortableContext>
</DndContext>,
);
const handle = screen.getByLabelText('Reorder Pricing');
expect(handle.getAttribute('role')).toBe('button');
for (const button of screen.getAllByRole('button')) {
expect(button.parentElement?.closest('[role="button"]')).toBeNull();
}
});
it('keeps the hero tile remove button out of the handle', () => {
render(
<DndContext>
<SortableContext items={['hero-0']}>
<SortableHeroTile id="abc" index={0} onRemove={() => {}} />
</SortableContext>
</DndContext>,
);
const remove = screen.getByTitle('Remove');
expect(remove.parentElement?.closest('[role="button"]')).toBeNull();
expect(screen.getByLabelText('Reorder hero photo 1').getAttribute('role')).toBe('button');
});
});
describe('dndAnnouncements', () => {
const label = (id: string | number) => (id === 'page-pricing' ? 'page "Pricing"' : 'page "FAQ"');
const a = dndAnnouncements(label);
const active = { id: 'page-pricing' } as never;
const over = { id: 'subpage-3' } as never;
it('speaks the names the list shows, not the ids', () => {
expect(a.onDragStart({ active })).toBe('Picked up page "Pricing".');
expect(a.onDragEnd({ active, over } as never)).toBe(
'Page "Pricing" was dropped at the position of page "FAQ".',
);
expect(a.onDragCancel({ active, over: null } as never)).toBe(
'Moving page "Pricing" was cancelled.',
);
});
});

View File

@@ -35,6 +35,7 @@ const everyOption: Required<Omit<AlbumEntryObject, 'grid'>> & Pick<AlbumEntryObj
assetOrder: ['33333333-3333-3333-3333-333333333333'],
coverPosition: '50% 25%',
download: true,
zoom: false,
location: 'hidden',
grid: { columns: 4, gap: 8, aspectRatio: '3/2', layout: 'masonry' },
};
@@ -47,6 +48,17 @@ describe('album entry round trip', () => {
expect(serialized).toEqual({ [uuid]: everyOption });
});
it('keeps zoom: false, which switches zoom off for the album (#467)', () => {
const parsed = parseAlbumEntries([{ [uuid]: { zoom: false } }]);
expect(parsed[0].zoom).toBe(false);
expect(hasAlbumOptions(parsed[0])).toBe(true);
expect(serializeAlbumEntries(parsed)).toEqual([{ [uuid]: { zoom: false } }]);
});
it('writes no zoom key while the album inherits', () => {
expect(serializeAlbumEntries([{ id: uuid, zoom: undefined }])).toEqual([uuid]);
});
it('keeps map precision across a save', () => {
const parsed = parseAlbumEntries([{ [uuid]: { location: 'city' } }]);

View File

@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { lastDays } from '../components/analyticsDays';
/**
* "Last 14 Days" used to show the last 14 days that had any views, so quiet
* days vanished and the chart could span a month (QA A-16).
*/
describe('lastDays', () => {
const days = {
'2026-08-28': { pageviews: 4 },
'2026-09-20': { pageviews: 6 },
'2026-09-29': { pageviews: 16 },
};
it('returns every calendar day up to today, with 0 for days without views', () => {
const out = lastDays(days, 14, new Date('2026-09-29T12:00:00Z'));
expect(out).toHaveLength(14);
expect(out[0]).toEqual({ date: '2026-09-16', pageviews: 0 });
expect(out[4]).toEqual({ date: '2026-09-20', pageviews: 6 });
expect(out[13]).toEqual({ date: '2026-09-29', pageviews: 16 });
// Older recorded days are outside the window, not pulled into it.
expect(out.find((d) => d.date === '2026-08-28')).toBeUndefined();
});
it('counts days in UTC, the way the tracker keys them', () => {
// 23:30 UTC on the 29th is already the 30th in Berlin.
const out = lastDays(days, 2, new Date('2026-09-29T23:30:00Z'));
expect(out.map((d) => d.date)).toEqual(['2026-09-28', '2026-09-29']);
});
it('crosses a month boundary without skipping or repeating a day', () => {
const out = lastDays({}, 3, new Date('2026-10-01T00:00:00Z'));
expect(out.map((d) => d.date)).toEqual(['2026-09-29', '2026-09-30', '2026-10-01']);
});
});

View File

@@ -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();
});
});

View File

@@ -0,0 +1,176 @@
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 subpage 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);
});
});
describe('menu with content pages (#722)', () => {
const withPages = (): GalleryState => ({
...state(),
// pricing before Japan, faq between Trips and Korea.
pageRefs: [
{ slug: 'pricing', position: 0 },
{ slug: 'faq', position: 2 },
],
});
const labels = (g: GalleryState) =>
ops.menuItems(g).map((i) => (i.kind === 'page' ? `page:${i.slug}` : g.subpages[i.index].name));
it('interleaves pages and subpages', () => {
expect(labels(withPages())).toEqual(['page:pricing', 'Japan', 'Trips', 'page:faq', 'Korea']);
});
it('moves a page between subpages', () => {
const g = ops.moveMenuItem(withPages(), 0, 2);
expect(labels(g)).toEqual(['Japan', 'Trips', 'page:pricing', 'page:faq', 'Korea']);
});
it('moves a subpage past a page and follows the open sheet', () => {
const before = withPages();
// Japan (row 1) to the end (row 4).
expect(ops.followMenuMove(before, 0, 1, 4)).toBe(2);
const g = ops.moveMenuItem(before, 1, 4);
expect(labels(g)).toEqual(['page:pricing', 'Trips', 'page:faq', 'Korea', 'Japan']);
expect(g.subpages.map((s) => s.name)).toEqual(['Trips', 'Korea', 'Japan']);
});
it('puts a page in the menu at the end, or before a row, and takes it out', () => {
let g = ops.setPageInMenu(state(), 'about-me', true);
expect(labels(g)).toEqual(['Japan', 'Trips', 'Korea', 'page:about-me']);
g = ops.setPageInMenu(g, 'rates', true, 1);
expect(labels(g)).toEqual(['Japan', 'page:rates', 'Trips', 'Korea', 'page:about-me']);
g = ops.setPageInMenu(g, 'rates', false);
expect(labels(g)).toEqual(['Japan', 'Trips', 'Korea', 'page:about-me']);
expect(ops.isPageInMenu(g, 'rates')).toBe(false);
});
it('keeps pages in place when a subpage is removed', () => {
const g = ops.removeSubpage(withPages(), 0);
expect(labels(g)).toEqual(['page:pricing', 'Trips', 'page:faq', 'Korea']);
});
it('follows a rename', () => {
const g = ops.renamePageRef(withPages(), 'faq', 'questions');
expect(labels(g)).toContain('page:questions');
expect(labels(g)).not.toContain('page:faq');
});
});
describe('addHeroes (#602)', () => {
it('appends a multi-pick in pick order, skipping duplicates', () => {
expect(ops.addHeroes(state(), ['h9', 'h2', 'h8', 'h9']).hero).toEqual([
'h1',
'h2',
'h3',
'h9',
'h8',
]);
});
});

View File

@@ -0,0 +1,201 @@
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<string, unknown>) => 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<NonNullable<GalleryYaml['subpages']>, unknown[]>[number];
const everySubpageKey: Required<SubpageYaml> = {
name: 'Clients',
title: 'For clients',
subtitle: 'Handover',
albums: [A],
sections: [{ title: 'North', description: 'Cold', albums: [B] }],
password: 'scrypt:aa:bb',
proofing: false,
zoom: true,
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] }],
});
});
});
describe('subpage zoom (#467)', () => {
it('keeps zoom in either direction, and writes nothing when unset', () => {
const off = { name: 'Z', zoom: false, albums: [A] };
const on = { name: 'Z', zoom: true, albums: [A] };
expect(roundTrip({ subpages: [off] })).toEqual({ subpages: [off] });
expect(roundTrip({ subpages: [on] })).toEqual({ subpages: [on] });
expect(roundTrip({ subpages: [{ name: 'Z', albums: [A] }] })).toEqual({
subpages: [{ name: 'Z', albums: [A] }],
});
});
});
describe('content page references (#722)', () => {
it('keeps `- page:` entries where they were among the subpages', () => {
const yaml = {
subpages: [
{ page: 'pricing' },
{ name: 'Japan', albums: [A] },
{ page: 'faq' },
{ name: 'Korea', albums: [B] },
{ page: 'contact-me' },
],
};
expect(roundTrip(yaml)).toEqual(yaml);
});
it('parses page references apart from the subpages', () => {
const g = parseGalleryYaml({ subpages: [{ name: 'Japan', albums: [A] }, { page: 'faq' }] });
expect(g.subpages.map((s) => s.name)).toEqual(['Japan']);
expect(g.pageRefs).toEqual([{ slug: 'faq', position: 1 }]);
});
it('writes a menu of pages only', () => {
const yaml = { subpages: [{ page: 'pricing' }] };
expect(roundTrip(yaml)).toEqual(yaml);
});
});

View File

@@ -1,7 +1,13 @@
import { describe, it, expect } from 'vitest';
import type { JournalBlock } from '@/lib/journal';
import { serializeJournalMarkdown, parseJournalMarkdown } from '@/lib/journal';
import { createBlock, moveBlock, isLegacyAssetRef } from '../components/journal/blockOps';
import {
createBlock,
createPhotoBlocks,
fillAssetSlots,
moveBlock,
isLegacyAssetRef,
} from '../components/journal/blockOps';
import {
clampSplit,
splitFromPointer,
@@ -124,3 +130,21 @@ describe('split pane', () => {
expect(parseStoredSplit('abc')).toBeNull();
});
});
describe('multi-pick into photo blocks (#602)', () => {
it('fills empty slots first, then appends', () => {
expect(fillAssetSlots(['a', '', ''], ['x', 'y', 'z'])).toEqual(['a', 'x', 'y', 'z']);
});
it('never grows past a capacity', () => {
expect(fillAssetSlots(['', ''], ['x', 'y', 'z'], 2)).toEqual(['x', 'y']);
expect(fillAssetSlots(['a', ''], ['x'], 2)).toEqual(['a', 'x']);
});
it('creates one contained photo block per asset', () => {
expect(createPhotoBlocks(['x', 'y'])).toEqual([
{ type: 'photo', assetId: 'x', caption: '', layout: 'contained' },
{ type: 'photo', assetId: 'y', caption: '', layout: 'contained' },
]);
});
});

View File

@@ -0,0 +1,24 @@
import { readFileSync } from 'fs';
import path from 'path';
import { describe, expect, it } from 'vitest';
// `?page=<slug>` in /admin/pages selects a page, and the page panel turns the
// slug into its "open live page" link. A slug such as `/evil.example` would
// make that link `//evil.example`, which leaves the site. Both ends guard it.
const read = (file: string) => readFileSync(path.join(process.cwd(), file), 'utf8');
describe('page link from ?page=', () => {
it('only selects a page for a valid slug', () => {
const src = read('app/admin/components/PageBuilder.tsx');
const fn = src.slice(src.indexOf('function openPageFromLink'));
const body = fn.slice(0, fn.indexOf('\n }\n'));
expect(body).toMatch(/if \(!isValidSlug\(slug\)\) return;/);
expect(body.indexOf('isValidSlug(slug)')).toBeLessThan(body.indexOf('setSelectedPage(slug)'));
});
it('encodes the slug in the live-page link', () => {
const src = read('app/admin/components/page-builder/PagePanel.tsx');
expect(src).toContain('href={`/${encodeURIComponent(slug)}`}');
expect(src).not.toContain('href={`/${slug}`}');
});
});

View File

@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { sameDraft } from '../components/sameDraft';
/**
* "Unsaved changes" stayed up after an edit was typed back to the saved value
* (QA A-19): the editors only ever set their dirty flag. They now also compare
* with the saved state, which has to see through what an undone edit leaves.
*/
describe('sameDraft', () => {
it('is false for a real change', () => {
expect(sameDraft({ title: 'A' }, { title: 'B' })).toBe(false);
expect(sameDraft({ navLinks: [{ label: 'A' }] }, { navLinks: [] })).toBe(false);
expect(sameDraft({ albums: ['a', 'b'] }, { albums: ['b', 'a'] })).toBe(false);
});
it('ignores key order — a deleted key typed again moves to the end', () => {
expect(
sameDraft(
{ theme: { accent: '#fff', preset: 'noir' } },
{
theme: { preset: 'noir', accent: '#fff' },
},
),
).toBe(true);
});
it('ignores the empty parent objects a settings edit creates', () => {
expect(sameDraft({ title: 'A', contact: {} }, { title: 'A' })).toBe(true);
expect(sameDraft({ theme: { fonts: {} } }, {})).toBe(true);
});
it('ignores undefined, which the YAML never holds', () => {
expect(sameDraft({ title: 'A', subtitle: undefined }, { title: 'A' })).toBe(true);
});
});

View File

@@ -50,9 +50,9 @@ describe('systemHealth', () => {
});
});
it('reports a doctor warning as neutral', () => {
it('reports a doctor warning in the warning tone, not the neutral one', () => {
expect(health({ doctorLevel: 'warn' })).toEqual({
tone: 'unknown',
tone: 'warning',
label: 'Check Diagnostics',
});
});
@@ -67,7 +67,7 @@ describe('systemHealth', () => {
'Setup Incomplete': 'unknown',
'System Degraded': 'disconnected',
'Needs Attention': 'disconnected',
'Check Diagnostics': 'unknown',
'Check Diagnostics': 'warning',
'System OK': 'connected',
'Status Unknown': 'unknown',
};

View File

@@ -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] });
});
});

View File

@@ -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');
});
});

View File

@@ -32,3 +32,7 @@
@import './styles/asset-order.css';
@import './styles/favicon.css';
@import './styles/diagnostics.css';
@import './styles/messages.css';
@import './styles/notifications.css';
@import './styles/proofing.css';
@import './styles/redesign.css';

View File

@@ -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,17 +17,112 @@ 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: <Icons.IconHome size={16} />,
},
],
},
{
group: 'Content',
items: [
{
label: 'Pages',
href: '/admin/pages',
match: /^\/admin\/pages(\/|$)/,
icon: <Icons.IconGrid size={16} />,
},
{
label: 'Journal',
href: '/admin/journal',
match: /^\/admin\/journal/,
icon: <Icons.IconBook size={16} />,
},
],
},
{
group: 'Visitors',
items: [
{
label: 'Messages',
href: '/admin/messages',
match: /^\/admin\/messages/,
icon: <Icons.IconFileText size={16} />,
badge: 'messages',
},
{
label: 'Proofing',
href: '/admin/proofing',
match: /^\/admin\/proofing/,
icon: <Icons.IconHeart size={16} />,
},
{
label: 'Analytics',
href: '/admin/analytics',
match: /^\/admin\/analytics/,
icon: <Icons.IconBarChart size={16} />,
},
],
},
{
group: 'Site',
items: [
{
label: 'Settings',
href: '/admin/settings',
match: /^\/admin\/settings/,
icon: <Icons.IconGear size={16} />,
},
],
},
{
group: 'System',
items: [
{
label: 'Diagnostics',
href: '/admin/diagnostics',
match: /^\/admin\/diagnostics/,
icon: <Icons.IconShieldCheck size={16} />,
badge: 'health',
},
{
label: 'Help',
href: '/admin/help',
match: /^\/admin\/help/,
icon: <Icons.IconQuote size={16} />,
},
],
},
];
const SIDEBAR_KEY = 'folio_admin_sidebar';
export default function AdminDashboard({ onLogout, children }: Props) {
const notify = useNotify();
const pathname = usePathname();
// Phones: the sidebar folds into a header row, and the nav opens from a
// menu button (#732) — spread out above the content, it took most of an
// iPhone screen before anything could be edited.
const [menuOpen, setMenuOpen] = useState(false);
useEffect(() => setMenuOpen(false), [pathname]);
const [saving, setSaving] = useState(false);
// Diagnostics & Backup state
@@ -40,6 +137,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<boolean>(() => {
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 +174,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 +232,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,159 +278,246 @@ export default function AdminDashboard({ onLogout, children }: Props) {
const missingCredentials = status?.setup?.credentials === 'missing';
return (
<div className="admin-dashboard">
<header className="admin-header">
<div className="admin-header-left">
<h1>Immich Folio</h1>
<nav className="admin-tabs">
{TABS.map((t) => {
const active = t.match.test(pathname);
return (
<Link
key={t.href}
href={t.href}
className={`admin-tab ${active ? 'active' : ''}`}
aria-current={active ? 'page' : undefined}
>
{t.label}
</Link>
);
})}
</nav>
</div>
<div className="admin-header-right">
{/* Diagnostics Badge */}
<div className="status-indicator-container">
<button
className={`status-badge-btn ${health.tone}`}
onClick={() => {
setShowStatus(!showStatus);
if (!showStatus) fetchStatus();
}}
title="Show system status"
>
<span className="status-dot"></span>
<span className="status-text">{health.label}</span>
</button>
{showStatus && (
<>
<div className="status-dropdown-backdrop" onClick={() => setShowStatus(false)} />
<div className="status-dropdown">
<div className="status-dropdown-header">
<h4>System Diagnostics</h4>
<button
className="status-refresh-btn"
onClick={fetchStatus}
disabled={statusLoading}
title="Refresh diagnostics"
<div
className={`admin-dashboard admin-app${collapsed ? ' is-collapsed' : ''}${
menuOpen ? ' menu-open' : ''
}`}
>
<a href="#admin-main" className="skip-link">
Skip to content
</a>
<aside className="admin-sidebar" aria-label="Admin">
<button
type="button"
className="admin-sidebar-toggle"
onClick={toggleCollapsed}
aria-expanded={!collapsed}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
data-tip={collapsed ? 'Expand' : undefined}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<polyline points={collapsed ? '9 6 15 12 9 18' : '15 6 9 12 15 18'} />
</svg>
</button>
<Link href="/admin" className="admin-brand" data-tip="Immich Folio">
<span className="admin-brand-mark" aria-hidden="true" />
<span className="admin-brand-text">
<span className="admin-brand-name">Immich Folio</span>
<span className="admin-brand-sub">Admin</span>
</span>
</Link>
<button
type="button"
className="admin-menu-toggle"
onClick={() => setMenuOpen((open) => !open)}
aria-expanded={menuOpen}
aria-controls="admin-sidebar-menu"
aria-label={menuOpen ? 'Close menu' : 'Open menu'}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden="true"
>
{menuOpen ? <path d="M6 6l12 12M18 6L6 18" /> : <path d="M4 7h16M4 12h16M4 17h16" />}
</svg>
{unread > 0 && !menuOpen && <span className="admin-menu-dot" aria-hidden="true" />}
</button>
<div className="admin-sidebar-menu" id="admin-sidebar-menu">
<nav className="admin-nav">
{NAV.map((section, i) => (
<div key={section.group ?? i} className="admin-nav-group">
{section.group && <span className="admin-nav-label">{section.group}</span>}
{section.items.map((item) => {
const active = item.match.test(pathname);
const count = item.badge === 'messages' ? unread : 0;
return (
<Link
key={item.href}
href={item.href}
className={`admin-nav-item ${active ? 'active' : ''}`}
data-tip={item.label}
aria-current={active ? 'page' : undefined}
>
{statusLoading ? '...' : '↻'}
</button>
</div>
<div className="status-dropdown-body">
{setupIncomplete && (
<p className="status-setup-note">
{missingCredentials
? 'No Immich URL or API key configured. Set IMMICH_API_URL and IMMICH_API_KEY, or run the '
: 'No content/gallery.yaml yet — the public site has nothing to show. Add a page below, or run the '}
<a href="/install" target="_blank" rel="noopener noreferrer">
setup wizard
</a>
{missingCredentials
? '.'
: ' (the one-time token is printed to the server log).'}
</p>
)}
<div className="status-item">
<span className="status-label">Immich Connection</span>
<span className={`status-val ${immichIndicator.className}`}>
{immichIndicator.label}
</span>
<span className="admin-nav-icon">{item.icon}</span>
<span className="admin-nav-text">{item.label}</span>
{count > 0 && (
<span className="admin-nav-count" aria-label={`${count} unread`}>
{count}
</span>
)}
{item.badge === 'health' && health.tone === 'disconnected' && (
<span
className={`admin-nav-dot ${health.tone}`}
aria-label={health.label}
/>
)}
</Link>
);
})}
</div>
))}
</nav>
<div className="admin-sidebar-foot">
{/* Diagnostics Badge */}
<div className="status-indicator-container">
<button
className={`status-badge-btn sidebar-status ${health.tone}`}
onClick={() => {
setShowStatus(!showStatus);
if (!showStatus) fetchStatus();
}}
title="Show system status"
data-tip={health.label}
>
<span className="status-dot"></span>
<span className="status-text">{health.label}</span>
</button>
{showStatus && (
<>
<div className="status-dropdown-backdrop" onClick={() => setShowStatus(false)} />
<div className="status-dropdown">
<div className="status-dropdown-header">
<h4>System Diagnostics</h4>
<button
className="status-refresh-btn"
onClick={fetchStatus}
disabled={statusLoading}
title="Refresh diagnostics"
>
{statusLoading ? '...' : '↻'}
</button>
</div>
<div className="status-item">
<span className="status-label">Config Integrity</span>
<span className={`status-val ${configIndicator.className}`}>
{configIndicator.label}
</span>
</div>
<div className="status-item">
<span className="status-label">Config Backups</span>
<span className="status-val">{status?.backups?.count ?? 0} Backups</span>
</div>
<div className="status-item">
<span className="status-label">Latest Backup</span>
<span className="status-val" title={status?.backups?.lastBackup || 'None'}>
{status?.backups?.lastBackup
? new Date(status.backups.lastBackup).toLocaleDateString()
: 'None'}
</span>
</div>
<div className="status-item">
<span className="status-label">In-Memory Cache</span>
<span className="status-val">{status?.cache?.size ?? 0} items</span>
</div>
{/*
<div className="status-dropdown-body">
{setupIncomplete && (
<p className="status-setup-note">
{missingCredentials
? 'No Immich URL or API key configured. Set IMMICH_API_URL and IMMICH_API_KEY, or run the '
: 'No content/gallery.yaml yet — the public site has nothing to show. Add a page below, or run the '}
<a href="/install" target="_blank" rel="noopener noreferrer">
setup wizard
</a>
{missingCredentials
? '.'
: ' (the one-time token is printed to the server log).'}
</p>
)}
<div className="status-item">
<span className="status-label">Immich Connection</span>
<span className={`status-val ${immichIndicator.className}`}>
{immichIndicator.label}
</span>
</div>
<div className="status-item">
<span className="status-label">Config Integrity</span>
<span className={`status-val ${configIndicator.className}`}>
{configIndicator.label}
</span>
</div>
<div className="status-item">
<span className="status-label">Config Backups</span>
<span className="status-val">{status?.backups?.count ?? 0} Backups</span>
</div>
<div className="status-item">
<span className="status-label">Latest Backup</span>
<span className="status-val" title={status?.backups?.lastBackup || 'None'}>
{status?.backups?.lastBackup
? new Date(status.backups.lastBackup).toLocaleDateString()
: 'None'}
</span>
</div>
<div className="status-item">
<span className="status-label">In-Memory Cache</span>
<span className="status-val">{status?.cache?.size ?? 0} items</span>
</div>
{/*
Discreet on purpose: a row in the status list, not a
banner. When the check is off or could not run, the row
still names the running version and says nothing about
updates (#496).
*/}
<div className="status-item">
<span className="status-label">Version</span>
{status?.update?.updateAvailable ? (
<a
className="status-val update"
href="https://github.com/ralksta/immich-folio/releases/latest"
target="_blank"
rel="noopener noreferrer"
title={`Version ${status.update.latest} is available`}
>
{status.update.current} → {status.update.latest}
</a>
) : (
<span className="status-val">{status?.update?.current ?? '—'}</span>
)}
<div className="status-item">
<span className="status-label">Version</span>
{status?.update?.updateAvailable ? (
<a
className="status-val update"
href="https://github.com/ralksta/immich-folio/releases/latest"
target="_blank"
rel="noopener noreferrer"
title={`Version ${status.update.latest} is available`}
>
{status.update.current} → {status.update.latest}
</a>
) : (
<span className="status-val">{status?.update?.current ?? '—'}</span>
)}
</div>
</div>
</div>
</div>
</>
)}
</>
)}
</div>
<div className="admin-sidebar-actions">
<a
href="/?fresh=1"
target="_blank"
rel="noopener noreferrer"
className="admin-side-action"
data-tip="View site"
title="Open site in new tab (bypassing cache)"
>
<Icons.IconLink size={14} />
<span className="admin-side-label">View site</span>
</a>
<button
className="admin-side-action"
data-tip="Backups"
onClick={() => setShowBackupModal(true)}
title="Manage config backups & restore"
>
<Icons.IconArchive size={14} />
<span className="admin-side-label">Backups</span>
</button>
<button
className="admin-side-action"
data-tip="Reload"
onClick={handleReload}
disabled={saving}
title="Reload config & clear cache"
>
<Icons.IconRefresh size={14} />
<span className="admin-side-label">Reload</span>
</button>
<button className="admin-side-action" data-tip="Sign out" onClick={handleLogout}>
<Icons.IconX size={14} />
<span className="admin-side-label">Sign out</span>
</button>
</div>
</div>
<span className="admin-header-divider" aria-hidden="true" />
<a
href="/?fresh=1"
target="_blank"
rel="noopener noreferrer"
className="admin-btn admin-btn-ghost"
title="Open site in new tab (bypassing cache)"
>
<Icons.IconLink size={14} /> Site
</a>
<button
className="admin-btn admin-btn-ghost"
onClick={() => setShowBackupModal(true)}
title="Manage config backups & restore"
>
<Icons.IconArchive size={14} /> Backups
</button>
<button
className="admin-btn admin-btn-ghost"
onClick={handleReload}
disabled={saving}
title="Reload config & clear cache"
>
<Icons.IconRefresh size={14} /> Reload
</button>
<button className="admin-btn admin-btn-ghost" onClick={handleLogout}>
Logout
</button>
</div>
</header>
</aside>
<main className="admin-main">{children}</main>
<main id="admin-main" tabIndex={-1} className="admin-main admin-workspace">
{children}
</main>
<BackupManagerModal
isOpen={showBackupModal}

View File

@@ -0,0 +1,42 @@
'use client';
import * as Icons from './Icons';
/**
* The one spinner and the one error-with-retry for admin screens (#609), so a
* failed load looks the same everywhere and always offers a way out.
*
* Renders nothing once there is data and no error; the caller renders the
* content then.
*/
export default function AdminLoadState({
loading,
error,
onRetry,
hasData,
}: {
loading: boolean;
error: string | null;
onRetry: () => void;
/** With data already on screen, a reload shows no spinner over it. */
hasData?: boolean;
}) {
if (error) {
return (
<div className="admin-load-error" role="alert">
<p>{error}</p>
<button type="button" className="admin-btn admin-btn-primary" onClick={onRetry}>
<Icons.IconRefresh size={14} /> Retry
</button>
</div>
);
}
if (loading && !hasData) {
return (
<div className="admin-loading-container" role="status" aria-label="Loading">
<div className="admin-spinner" />
</div>
);
}
return null;
}

View File

@@ -0,0 +1,234 @@
'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';
import { parseGalleryYaml } from './page-builder/galleryYaml';
interface OverviewData {
/** gallery.yaml as stored; `subpages` may be a list or the older name → albums map. */
gallery: Record<string, unknown> | null;
journal: Array<{ frontmatter: { draft?: boolean } }> | null;
messages: Array<{ read: boolean; name: string }> | null;
analytics: { days?: Record<string, { pageviews?: number }> } | null;
doctor: { level: DoctorLevel; findings: DoctorFinding[] } | null;
}
/** Where a doctor finding is fixed; mirrors FIXES in DiagnosticsView. */
const FIX_ROUTE: Record<string, string> = {
legal: '/admin/settings/legal',
contact: '/admin/settings/legal',
privacy: '/admin/settings/legal',
passwords: '/admin/settings/security',
'album-ids': '/admin/pages',
'albums-shared': '/admin/pages',
'content-pages': '/admin/pages',
};
function viewsLastDays(
days: Record<string, { pageviews?: number }> | 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<OverviewData | null>(null);
useEffect(() => {
let live = true;
Promise.all([
adminGet<{ gallery: OverviewData['gallery'] }>('/api/admin/gallery'),
adminGet<{ entries: NonNullable<OverviewData['journal']> }>('/api/admin/journal'),
adminGet<{ messages: NonNullable<OverviewData['messages']> }>('/api/admin/messages'),
adminGet<NonNullable<OverviewData['analytics']>>('/api/admin/analytics'),
adminGet<NonNullable<OverviewData['doctor']>>('/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;
};
}, []);
// Read the way the page builder reads it: both shapes of `subpages:`, and
// `- page:` entries, which are content pages in the menu, not subpages (#722).
const subpages = data?.gallery ? parseGalleryYaml(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 (
<div className="admin-overview">
<header className="overview-head">
<div>
<p className="overview-kicker">Overview</p>
<h1 className="overview-title">{greeting()}.</h1>
<p className="overview-sub">
{data
? attention.length
? `${attention.length} thing${attention.length === 1 ? '' : 's'} could use a look.`
: 'Everything is in order.'
: 'Checking the site…'}
</p>
</div>
<div className="overview-actions">
<a
className="admin-btn admin-btn-ghost"
href="/?fresh=1"
target="_blank"
rel="noopener noreferrer"
>
<Icons.IconLink size={14} /> View site
</a>
<Link className="admin-btn admin-btn-primary" href="/admin/pages">
<Icons.IconPencil size={14} /> Edit pages
</Link>
</div>
</header>
<section aria-labelledby="overview-attention">
<h2 id="overview-attention" className="overview-section-label">
Needs attention
</h2>
{!data ? (
<div className="admin-spinner" />
) : attention.length ? (
<div className="overview-attention">
{attention.map((a, i) => (
<Link key={i} href={a.href} className="overview-attention-item">
<span className={`overview-attention-marker ${a.tone}`} aria-hidden="true" />
<span className="overview-attention-text">
{a.title}
{a.detail && <span className="overview-attention-detail">{a.detail}</span>}
</span>
<span className="overview-attention-go">Open</span>
</Link>
))}
</div>
) : (
<p className="overview-calm">Nothing waiting. New messages and problems show up here.</p>
)}
</section>
<section aria-labelledby="overview-glance">
<h2 id="overview-glance" className="overview-section-label">
At a glance
</h2>
<div className="overview-stats">
<Link href="/admin/pages" className="overview-stat">
<span className="overview-stat-label">Pages</span>
<span className="overview-stat-value">{data ? published : '–'}</span>
<span className="overview-stat-note">
{subpages.length - published > 0
? `${subpages.length - published} switched off`
: 'all published'}
</span>
</Link>
<Link href="/admin/pages" className="overview-stat">
<span className="overview-stat-label">Hero photos</span>
<span className="overview-stat-value">{data ? heroCount : '–'}</span>
<span className="overview-stat-note">on the home page</span>
</Link>
<Link href="/admin/journal" className="overview-stat">
<span className="overview-stat-label">Journal</span>
<span className="overview-stat-value">{data ? entries.length : '–'}</span>
<span className="overview-stat-note">
{drafts ? `${drafts} draft${drafts === 1 ? '' : 's'}` : 'no drafts'}
</span>
</Link>
<Link href="/admin/analytics" className="overview-stat">
<span className="overview-stat-label">Views · 7 days</span>
<span className="overview-stat-value">{data ? views7.toLocaleString() : '–'}</span>
<span className="overview-stat-note">cookieless count</span>
</Link>
</div>
</section>
<section aria-labelledby="overview-next">
<h2 id="overview-next" className="overview-section-label">
Quick actions
</h2>
<div className="overview-actions">
<Link className="admin-btn admin-btn-ghost" href="/admin/journal">
<Icons.IconPlus size={14} /> New journal entry
</Link>
<Link className="admin-btn admin-btn-ghost" href="/admin/messages">
<Icons.IconFileText size={14} /> Messages
</Link>
<Link className="admin-btn admin-btn-ghost" href="/admin/settings/theme">
<Icons.IconPalette size={14} /> Theme
</Link>
<Link className="admin-btn admin-btn-ghost" href="/admin/diagnostics">
<Icons.IconShieldCheck size={14} /> Diagnostics
</Link>
</div>
</section>
</div>
);
}

View File

@@ -52,7 +52,7 @@ export default function AlbumPicker({ albums, onSelect, onClose, usedAlbumIds }:
>
<div className="picker-header">
<h3 id="album-picker-title">Select Album</h3>
<button className="admin-btn-icon" onClick={onClose}>
<button type="button" className="admin-btn-icon" onClick={onClose} aria-label="Close">
×
</button>
</div>
@@ -76,12 +76,16 @@ export default function AlbumPicker({ albums, onSelect, onClose, usedAlbumIds }:
{filtered.map((album) => {
const isUsed = usedAlbumIds.has(album.id);
return (
<div
// A button, not a div: the list has to work from the keyboard.
// An album already on the site is disabled rather than inert.
<button
type="button"
key={album.id}
className={`picker-item ${isUsed ? 'used' : ''}`}
onClick={() => !isUsed && onSelect(album.id)}
disabled={isUsed}
onClick={() => onSelect(album.id)}
>
<div className="picker-item-thumb">
<span className="picker-item-thumb">
{album.thumbnailAssetId ? (
<img
className="picker-thumb-img"
@@ -90,12 +94,12 @@ export default function AlbumPicker({ albums, onSelect, onClose, usedAlbumIds }:
loading="lazy"
/>
) : (
<div className="picker-thumb-placeholder">
<span className="picker-thumb-placeholder">
<IconFolder size={20} />
</div>
</span>
)}
</div>
<div className="picker-item-info">
</span>
<span className="picker-item-info">
<span className="picker-item-name">{album.albumName}</span>
<span className="picker-item-meta">
{album.assetCount} photos
@@ -108,15 +112,15 @@ export default function AlbumPicker({ albums, onSelect, onClose, usedAlbumIds }:
{album.shared === false && (
<span className="picker-item-note">Not shared in Immich</span>
)}
</div>
<div className="picker-item-action">
</span>
<span className="picker-item-action">
{isUsed ? (
<span className="picker-used-badge">In use</span>
) : (
<span className="picker-select-badge">Select</span>
)}
</div>
</div>
</span>
</button>
);
})}
</div>

View File

@@ -1,7 +1,10 @@
'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';
import { lastDays } from './analyticsDays';
interface AnalyticsData {
trackingEnabled?: boolean;
@@ -22,56 +25,25 @@ interface AnalyticsData {
}
export default function AnalyticsView() {
const [data, setData] = useState<AnalyticsData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const analytics = useAdminFetch<AnalyticsData>('/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 (
<div className="admin-loading-container">
<div className="admin-spinner" />
</div>
);
}
if (error || !data) {
return (
<div className="admin-panel" style={{ padding: '2rem', textAlign: 'center' }}>
<p style={{ color: 'var(--admin-text-muted)', marginBottom: '1rem' }}>
{error || 'No analytics data'}
</p>
<button className="admin-btn admin-btn-primary" onClick={fetchAnalytics}>
<Icons.IconRefresh size={14} /> Retry
</button>
</div>
<AdminLoadState
loading={analytics.loading}
error={analytics.error}
onRetry={analytics.reload}
hasData={!!data}
/>
);
}
const totalViews = data.summary?.totalViews || 0;
const maxPageViews = data.topPages[0]?.count || 1;
const daysList = Object.entries(data.days || {})
.sort((a, b) => a[0].localeCompare(b[0]))
.slice(-14); // Last 14 days
const maxDayViews = Math.max(...daysList.map(([, d]) => d.pageviews || 0), 1);
const hasDays = Object.keys(data.days || {}).length > 0;
const daysList = lastDays(data.days || {}, 14, new Date());
const maxDayViews = Math.max(...daysList.map((d) => d.pageviews), 1);
const desktopRatio =
data.devices.desktop + data.devices.mobile > 0
@@ -80,20 +52,16 @@ export default function AnalyticsView() {
return (
<div className="analytics-view">
{/* Header */}
<div className="analytics-header">
<div>
<h2>
<Icons.IconSparkles size={20} /> Visitor Insights &amp; Analytics
</h2>
<p className="analytics-subtitle">
Privacy-first aggregate traffic stats. No personal data collected.
</p>
</div>
<button className="admin-btn admin-btn-ghost admin-btn-sm" onClick={fetchAnalytics}>
<Icons.IconRefresh size={14} /> Refresh
</button>
</div>
<PageHeader
kicker="Visitors"
title="Analytics"
description="Cookieless page counts, kept on this server. No personal data is collected."
actions={
<button className="admin-btn admin-btn-ghost admin-btn-sm" onClick={analytics.reload}>
<Icons.IconRefresh size={14} /> Refresh
</button>
}
/>
{data.trackingEnabled === false && (
<div
@@ -137,27 +105,32 @@ export default function AnalyticsView() {
<Icons.IconGrid size={16} /> Daily Traffic Trend (Last 14 Days)
</h3>
</div>
{daysList.length === 0 ? (
{!hasDays ? (
<p className="analytics-empty">
No pageviews recorded yet. Visit portfolio pages to start tracking!
</p>
) : (
<div className="analytics-bar-chart">
{daysList.map(([dateStr, dayData]) => {
const heightPct = Math.round((dayData.pageviews / maxDayViews) * 100);
const formattedDate = dateStr.slice(5); // MM-DD
{daysList.map(({ date, pageviews }) => {
const heightPct = Math.round((pageviews / maxDayViews) * 100);
const month = date.slice(5, 8); // "MM-"
const day = date.slice(8); // "DD"
return (
<div
key={dateStr}
className="bar-col"
title={`${dateStr}: ${dayData.pageviews} views`}
>
<div key={date} className="bar-col" title={`${date}: ${pageviews} views`}>
<div className="bar-wrapper">
<div className="bar-fill" style={{ height: `${Math.max(heightPct, 8)}%` }}>
<span className="bar-val">{dayData.pageviews}</span>
{/* A day with views keeps a sliver of bar; an empty one has none. */}
<div
className="bar-fill"
style={{ height: `${pageviews > 0 ? Math.max(heightPct, 3) : 0}%` }}
>
<span className="bar-val">{pageviews}</span>
</div>
</div>
<span className="bar-label">{formattedDate}</span>
<span className="bar-label">
{/* Hidden at phone width, where only the day fits (A-18). */}
<span className="bar-label-month">{month}</span>
{day}
</span>
</div>
);
})}

View File

@@ -19,6 +19,7 @@ import {
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useScrollLock } from './useScrollLock';
import { useModalDialog } from '@/hooks/useModalDialog';
interface AssetInfo {
id: string;
@@ -65,8 +66,10 @@ function SortableAssetTile({
<img src={`/api/admin/thumbnail/${asset.id}`} alt={asset.originalFileName} loading="lazy" />
<span className="order-tile-index">{index + 1}</span>
<button
type="button"
className="order-tile-action"
title="Unpin — let this photo follow automatically"
aria-label={`Unpin ${asset.originalFileName}`}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
@@ -94,6 +97,7 @@ export default function AssetOrderEditor({
onClose,
}: Props) {
useScrollLock(true);
const cardRef = useModalDialog(onClose);
const [assets, setAssets] = useState<AssetInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -164,10 +168,17 @@ export default function AssetOrderEditor({
return (
<div className="picker-overlay" onClick={onClose}>
<div className="asset-picker-modal" onClick={(e) => e.stopPropagation()}>
<div
className="asset-picker-modal"
ref={cardRef}
role="dialog"
aria-modal="true"
aria-labelledby="order-editor-title"
onClick={(e) => e.stopPropagation()}
>
<div className="picker-header">
<h3>Photo order — {albumName}</h3>
<button className="admin-btn-icon" onClick={onClose}>
<h3 id="order-editor-title">Photo order — {albumName}</h3>
<button type="button" className="admin-btn-icon" onClick={onClose} aria-label="Close">
×
</button>
</div>
@@ -240,19 +251,19 @@ export default function AssetOrderEditor({
</p>
<div className="order-editor-grid">
{unpinned.map((asset) => (
<div
<button
type="button"
key={asset.id}
className="order-tile order-tile-unpinned"
title={asset.originalFileName}
aria-label={`Pin ${asset.originalFileName}`}
onClick={() => setPinnedIds([...pinnedIds, asset.id])}
>
<img
src={`/api/admin/thumbnail/${asset.id}`}
alt={asset.originalFileName}
loading="lazy"
/>
<span className="order-tile-action">+</span>
</div>
<img src={`/api/admin/thumbnail/${asset.id}`} alt="" loading="lazy" />
<span className="order-tile-action" aria-hidden="true">
+
</span>
</button>
))}
</div>
</div>

View File

@@ -1,8 +1,9 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useModalDialog } from '@/hooks/useModalDialog';
import { IconCheck, IconFolder, IconImage, IconStar } from './Icons';
import { canSelectMore, matchesQuery, toggleSelection } from '@/lib/admin/assetSelection';
import { IconCheck, IconFolder, IconImage, IconSearch, IconStar } from './Icons';
import { useScrollLock } from './useScrollLock';
interface AssetInfo {
@@ -13,7 +14,15 @@ interface AssetInfo {
}
interface Props {
onSelect: (assetId: string) => void;
/** Single-select: called with the one asset picked; the caller closes the picker. */
onSelect?: (assetId: string) => void;
/**
* Multi-select (#602): tiles toggle, and a confirm button hands over every
* picked asset in the order they were picked. Takes precedence over `onSelect`.
*/
onSelectMany?: (assetIds: string[]) => void;
/** Most assets `onSelectMany` accepts; unlimited when omitted. */
max?: number;
onClose: () => void;
currentAssetIds?: string[];
albumId?: string;
@@ -22,8 +31,12 @@ interface Props {
type Tab = 'album' | 'favorites' | 'all';
const SEARCH_DEBOUNCE_MS = 300;
export default function AssetPicker({
onSelect,
onSelectMany,
max,
onClose,
currentAssetIds = [],
albumId,
@@ -31,21 +44,50 @@ export default function AssetPicker({
}: Props) {
useScrollLock(true);
const cardRef = useModalDialog(onClose);
const multi = onSelectMany !== undefined;
const [tab, setTab] = useState<Tab>(albumId ? 'album' : 'favorites');
const [assets, setAssets] = useState<AssetInfo[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(false);
const [queryInput, setQueryInput] = useState('');
const [query, setQuery] = useState('');
const [selected, setSelected] = useState<string[]>([]);
const [uuidInput, setUuidInput] = useState('');
const [uuidError, setUuidError] = useState('');
const listRef = useRef<HTMLDivElement>(null);
const currentIds = new Set(currentAssetIds);
/**
* Bumped each time the list starts over (tab, album or search changed). A
* request is only allowed to touch the list while its generation is current:
* without this, a slow Favorites answer landing after a switch to All Photos
* replaced that tab's photos, and ended its spinner early.
*/
const generation = useRef(0);
const currentIds = useMemo(() => new Set(currentAssetIds), [currentAssetIds]);
// Debounce typing into the query the fetch runs on.
useEffect(() => {
const t = setTimeout(() => setQuery(queryInput.trim()), SEARCH_DEBOUNCE_MS);
return () => clearTimeout(t);
}, [queryInput]);
// The album tab loads the album whole and filters locally; the library tabs
// search server-side, since they are paged.
const serverQuery = tab === 'album' ? '' : query;
const loadAssets = useCallback(
async (pageNum: number, append: boolean = false) => {
if (pageNum === 1) setLoading(true);
else setLoadingMore(true);
// A fresh list starts a generation; a further page belongs to the current one.
const gen = append ? generation.current : ++generation.current;
const current = () => gen === generation.current;
if (append) {
setLoadingMore(true);
} else {
setLoading(true);
// A "load more" of the previous list will not clear this itself.
setLoadingMore(false);
}
try {
let res: Response;
@@ -56,22 +98,31 @@ export default function AssetPicker({
page: pageNum.toString(),
favorites: tab === 'favorites' ? 'true' : 'false',
});
if (serverQuery) params.set('q', serverQuery);
res = await fetch(`/api/admin/assets?${params}`);
}
if (res.ok) {
const data = await res.json();
setAssets((prev) => (append ? [...prev, ...data.assets] : data.assets));
if (!current()) return;
setAssets((prev) => {
if (!append) return data.assets;
// Merged filename + description pages can overlap across pages.
const seen = new Set(prev.map((a) => a.id));
return [...prev, ...data.assets.filter((a: AssetInfo) => !seen.has(a.id))];
});
setHasMore(data.nextPage !== null);
setPage(pageNum);
}
} catch (err) {
console.error('Failed to load assets:', err);
if (current()) console.error('Failed to load assets:', err);
} finally {
setLoading(false);
setLoadingMore(false);
if (current()) {
setLoading(false);
setLoadingMore(false);
}
}
},
[tab, albumId],
[tab, albumId, serverQuery],
);
useEffect(() => {
@@ -79,7 +130,9 @@ export default function AssetPicker({
setPage(1);
setHasMore(false);
loadAssets(1);
}, [tab, loadAssets]);
}, [loadAssets]);
const visible = tab === 'album' ? assets.filter((a) => matchesQuery(a, query)) : assets;
// Infinite scroll
function handleScroll() {
@@ -90,6 +143,16 @@ export default function AssetPicker({
}
}
function pick(id: string) {
if (currentIds.has(id)) return;
if (multi) setSelected((s) => toggleSelection(s, id, { disabled: currentIds, max }));
else onSelect?.(id);
}
function confirm() {
if (multi && selected.length > 0) onSelectMany(selected);
}
function handleUuidSubmit(e: React.FormEvent) {
e.preventDefault();
const uuid = uuidInput.trim();
@@ -99,7 +162,12 @@ export default function AssetPicker({
return;
}
setUuidError('');
onSelect(uuid);
if (multi) {
setSelected((s) => toggleSelection(s, uuid.toLowerCase(), { disabled: currentIds, max }));
setUuidInput('');
} else {
onSelect?.(uuid);
}
}
function formatDate(dateStr: string): string {
@@ -114,6 +182,8 @@ export default function AssetPicker({
}
}
const full = multi && !canSelectMore(selected, max);
return (
<div className="picker-overlay" onClick={onClose}>
<div
@@ -126,7 +196,7 @@ export default function AssetPicker({
>
<div className="picker-header">
<h3 id="asset-picker-title">{title || 'Select Hero Image'}</h3>
<button className="admin-btn-icon" onClick={onClose}>
<button className="admin-btn-icon" onClick={onClose} aria-label="Close">
×
</button>
</div>
@@ -136,6 +206,7 @@ export default function AssetPicker({
{albumId && (
<button
className={`asset-picker-tab ${tab === 'album' ? 'active' : ''}`}
aria-pressed={tab === 'album'}
onClick={() => setTab('album')}
>
<IconFolder size={14} />
@@ -144,6 +215,7 @@ export default function AssetPicker({
)}
<button
className={`asset-picker-tab ${tab === 'favorites' ? 'active' : ''}`}
aria-pressed={tab === 'favorites'}
onClick={() => setTab('favorites')}
>
<IconStar size={14} />
@@ -151,6 +223,7 @@ export default function AssetPicker({
</button>
<button
className={`asset-picker-tab ${tab === 'all' ? 'active' : ''}`}
aria-pressed={tab === 'all'}
onClick={() => setTab('all')}
>
<IconImage size={14} />
@@ -158,45 +231,91 @@ export default function AssetPicker({
</button>
</div>
{/* Search */}
<div className="asset-picker-search">
<IconSearch size={14} />
<input
type="search"
value={queryInput}
onChange={(e) => setQueryInput(e.target.value)}
onKeyDown={(e) => {
// Enter in the field searches at once instead of submitting anything.
if (e.key === 'Enter') {
e.preventDefault();
setQuery(queryInput.trim());
}
}}
maxLength={100}
placeholder={
tab === 'album'
? 'Filter this album by filename…'
: 'Search filename, description, or a date (2024-05)…'
}
aria-label="Search photos"
className="asset-picker-search-input"
/>
</div>
{/* Asset Grid */}
<div className="asset-picker-grid" ref={listRef} onScroll={handleScroll}>
<div
className="asset-picker-grid"
ref={listRef}
onScroll={handleScroll}
role="group"
aria-label="Photos"
>
{loading ? (
<div className="asset-picker-loading">
<div className="admin-spinner" />
</div>
) : assets.length === 0 ? (
) : visible.length === 0 ? (
<p className="empty-hint">
{tab === 'favorites'
? 'No favorite photos found. Star photos in Immich first, or switch to "All Photos".'
: 'No photos found.'}
{query
? `No photos match “${query}”.`
: tab === 'favorites'
? 'No favorite photos found. Star photos in Immich first, or switch to "All Photos".'
: 'No photos found.'}
</p>
) : (
<>
{assets.map((asset) => {
{visible.map((asset) => {
const isUsed = currentIds.has(asset.id);
const order = selected.indexOf(asset.id);
const isSelected = order !== -1;
const blocked = isUsed || (full && !isSelected);
const label = `${asset.originalFileName}, ${formatDate(asset.fileCreatedAt)}${
isUsed ? ' (already used)' : ''
}`;
return (
<div
<button
type="button"
key={asset.id}
className={`asset-picker-tile ${isUsed ? 'used' : ''}`}
onClick={() => !isUsed && onSelect(asset.id)}
className={`asset-picker-tile ${isUsed ? 'used' : ''} ${
isSelected ? 'selected' : ''
}`}
onClick={() => pick(asset.id)}
disabled={blocked}
aria-pressed={multi ? isSelected : undefined}
aria-label={label}
title={`${asset.originalFileName}\n${formatDate(asset.fileCreatedAt)}`}
>
<img
src={`/api/admin/thumbnail/${asset.id}`}
alt={asset.originalFileName}
loading="lazy"
/>
<img src={`/api/admin/thumbnail/${asset.id}`} alt="" loading="lazy" />
{isUsed && (
<div className="asset-picker-used-badge">
<span className="asset-picker-used-badge" aria-hidden="true">
<IconCheck size={11} />
</div>
</span>
)}
{isSelected && (
<span className="asset-picker-order-badge" aria-hidden="true">
{max === 1 ? <IconCheck size={11} /> : order + 1}
</span>
)}
{asset.isFavorite && !isUsed && (
<div className="asset-picker-fav-badge" title="Favorite in Immich">
<span className="asset-picker-fav-badge" aria-hidden="true">
<IconStar size={14} />
</div>
</span>
)}
</div>
</button>
);
})}
{loadingMore && (
@@ -208,6 +327,36 @@ export default function AssetPicker({
)}
</div>
{/* Selection bar (multi-select) */}
{multi && (
<div className="asset-picker-selection">
<span aria-live="polite">
{selected.length === 0
? max && max > 1
? `Pick up to ${max} photos`
: 'Pick photos'
: `${selected.length}${max && max > 1 ? ` of ${max}` : ''} selected`}
</span>
{selected.length > 0 && (
<button
type="button"
className="admin-btn admin-btn-sm"
onClick={() => setSelected([])}
>
Clear
</button>
)}
<button
type="button"
className="admin-btn admin-btn-sm admin-btn-primary"
disabled={selected.length === 0}
onClick={confirm}
>
{selected.length > 1 ? `Add ${selected.length} photos` : 'Add photo'}
</button>
</div>
)}
{/* UUID Fallback */}
<div className="asset-picker-uuid-section">
<form onSubmit={handleUuidSubmit} className="asset-picker-uuid-form">
@@ -219,6 +368,7 @@ export default function AssetPicker({
setUuidError('');
}}
placeholder="Or paste asset UUID directly..."
aria-label="Asset UUID"
className="asset-picker-uuid-input"
/>
<button type="submit" className="admin-btn admin-btn-sm" disabled={!uuidInput.trim()}>

View File

@@ -1,10 +1,12 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect } from 'react';
import type { BackupItem, BackupTarget } from '@/app/api/admin/backups/route';
import * as Icons from './Icons';
import { useScrollLock } from './useScrollLock';
import { useModalDialog } from '@/hooks/useModalDialog';
import { reportContentRestored } from './contentRestored';
import { useAdminFetch } from './useAdminFetch';
interface Props {
isOpen: boolean;
@@ -16,48 +18,49 @@ const TABS: { target: BackupTarget; label: string; file: string }[] = [
{ target: 'gallery', label: 'Gallery', file: 'gallery.yaml' },
{ target: 'settings', label: 'Settings', file: 'settings.yaml' },
{ target: 'about', label: 'About', file: 'about.md' },
{ target: 'privacy', label: 'Privacy', file: 'privacy.md' },
{ target: 'journal', label: 'Journal', file: 'journal entries' },
{ target: 'pages', label: 'Content pages', file: 'content pages' },
];
type BackupLists = Record<BackupTarget, BackupItem[]>;
const EMPTY_LISTS: BackupLists = { gallery: [], settings: [], about: [], journal: [] };
const EMPTY_LISTS: BackupLists = {
gallery: [],
settings: [],
about: [],
privacy: [],
journal: [],
pages: [],
};
export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }: Props) {
const [activeTab, setActiveTab] = useState<BackupTarget>('gallery');
const [showAllBackups, setShowAllBackups] = useState(false);
const [backups, setBackups] = useState<BackupLists>(EMPTY_LISTS);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const list = useAdminFetch<{ backups: Partial<BackupLists> }>(
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<string | null>(null);
const error = list.error ?? restoreError;
const loading = list.loading;
const reloadList = list.reload;
const [restoringFilename, setRestoringFilename] = useState<string | null>(null);
const [confirmItem, setConfirmItem] = useState<BackupItem | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(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 +71,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 {
@@ -86,14 +89,16 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
setSuccessMsg(
item.slug
? `Journal entry "${item.slug}" restored.`
? `${item.target === 'pages' ? 'Page' : 'Journal entry'} "${item.slug}" restored.`
: `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);
}
@@ -118,8 +123,8 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
<div>
<h2 id="backup-modal-title">Backup History &amp; Restoration</h2>
<p className="backup-modal-subtitle">
Restore an earlier version of your pages, settings, about page or journal — including
deleted journal entries.
Restore an earlier version of your pages, settings, about page, journal or content
pages — including deleted journal entries and pages.
</p>
</div>
<button className="backup-modal-close-btn" onClick={onClose} aria-label="Close">
@@ -191,7 +196,7 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
const dateObj = item.timestamp ? new Date(item.timestamp) : null;
const formattedDate =
dateObj && !isNaN(dateObj.getTime())
? dateObj.toLocaleString('de-DE', {
? dateObj.toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'medium',
})
@@ -248,8 +253,8 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
}}
/>
{showAllBackups
? 'Ältere Einträge einklappen'
: `Ältere Einträge anzeigen (${hiddenCount} weitere)`}
? 'Hide older backups'
: `Show ${hiddenCount} older ${hiddenCount === 1 ? 'backup' : 'backups'}`}
</button>
)}
</div>

View File

@@ -0,0 +1,178 @@
'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<boolean>;
/** One button of a choice; `id` is what the promise resolves to. */
export interface ChoiceAction<Id extends string> {
id: Id;
label: string;
danger?: boolean;
}
/**
* More than yes or no — the concurrent-edit prompt (#601) offers three ways
* out. The cancel button (and Esc, and the backdrop) resolves to null.
*/
export interface ChoiceOptions<Id extends string> {
title: string;
message?: ReactNode;
/** Left to right; the last one is the primary button. */
actions: ChoiceAction<Id>[];
cancelLabel?: string;
}
type Choose = <Id extends string>(options: ChoiceOptions<Id>) => Promise<Id | null>;
interface DialogContext {
confirm: Confirm;
choose: Choose;
}
/** Outside the provider (a component tested on its own) it falls back to window.confirm. */
const ConfirmContext = createContext<DialogContext>({
confirm: async (o) => (typeof window === 'undefined' ? false : window.confirm(o.title)),
choose: async (o) =>
typeof window !== 'undefined' && window.confirm(o.title)
? (o.actions[o.actions.length - 1]?.id ?? null)
: null,
});
export function useConfirm(): Confirm {
return useContext(ConfirmContext).confirm;
}
export function useChoice(): Choose {
return useContext(ConfirmContext).choose;
}
const CONFIRM_ID = 'confirm';
export function ConfirmProvider({ children }: { children: ReactNode }) {
const [pending, setPending] = useState<ChoiceOptions<string> | null>(null);
const resolver = useRef<((id: string | null) => void) | null>(null);
const choose = useCallback(
(options: ChoiceOptions<string>) =>
new Promise<string | null>((resolve) => {
resolver.current?.(null);
resolver.current = resolve;
setPending(options);
}),
[],
) as Choose;
const confirm = useCallback<Confirm>(
async (options) =>
(await choose({
title: options.title,
message: options.message,
cancelLabel: options.cancelLabel,
actions: [
{
id: CONFIRM_ID,
label: options.confirmLabel ?? 'Confirm',
danger: options.danger,
},
],
})) === CONFIRM_ID,
[choose],
);
const settle = useCallback((id: string | null) => {
resolver.current?.(id);
resolver.current = null;
setPending(null);
}, []);
return (
<ConfirmContext.Provider value={{ confirm, choose }}>
{children}
{pending && <Dialog options={pending} onSettle={settle} />}
</ConfirmContext.Provider>
);
}
function Dialog({
options,
onSettle,
}: {
options: ChoiceOptions<string>;
onSettle: (id: string | null) => void;
}) {
// Esc cancels, focus is trapped and returned (the same hook the modals use).
const cardRef = useModalDialog(() => onSettle(null));
const last = options.actions.length - 1;
return (
<div className="confirm-backdrop" onClick={() => onSettle(null)}>
<div
ref={cardRef}
className="confirm-card"
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-title"
aria-describedby={options.message ? 'confirm-message' : undefined}
onClick={(e) => e.stopPropagation()}
>
<h2 id="confirm-title" className="confirm-title">
{options.title}
</h2>
{options.message && (
<p id="confirm-message" className="confirm-message">
{options.message}
</p>
)}
<div className="confirm-actions">
<button
type="button"
className="admin-btn admin-btn-ghost"
onClick={() => onSettle(null)}
>
{options.cancelLabel ?? 'Cancel'}
</button>
{options.actions.map((action, i) => (
<button
key={action.id}
type="button"
className={`admin-btn ${
action.danger
? 'confirm-danger'
: i === last
? 'admin-btn-primary'
: 'admin-btn-ghost'
}`}
onClick={() => onSettle(action.id)}
>
{action.label}
</button>
))}
</div>
</div>
</div>
);
}

View File

@@ -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,26 @@ 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', 'download-metadata'],
},
{
id: 'content',
title: 'Content',
checks: [
'albums-shared',
'content-pages',
'content-writable',
'alt-text',
'legal',
'contact',
'privacy',
'settings-values',
'zoom-renditions',
],
},
];
const LEVEL_LABEL: Record<DoctorLevel, string> = { ok: 'OK', warn: 'Check', error: 'Problem' };
@@ -56,10 +75,22 @@ const FIXES: Record<string, { label: string; href: string }> = {
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' },
'content-pages': { label: 'Open pages', href: '/admin/pages' },
'download-metadata': { 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' },
// The href is replaced by the finding's own settingsSection.
'settings-values': { label: 'Open settings', href: '/admin/settings/general' },
'content-writable': { label: 'How to fix', href: `${DOCS}/docs/admin-panel.md#docker-usage` },
'zoom-renditions': {
label: 'How to set it up',
href: `${DOCS}/docs/gallery-config.md#photo-zoom`,
},
'immich-api': {
label: 'API key permissions',
href: `${DOCS}/README.md#immich-api-key-permissions`,
@@ -311,8 +342,12 @@ export default function DiagnosticsView() {
}
return null; // The photo list toggle is rendered separately.
}
const fix = FIXES[id];
if (!fix) return null;
const known = FIXES[id];
if (!known) return null;
// A finding that knows which settings section holds its fix goes there.
const fix = f.settingsSection
? { ...known, href: `/admin/settings/${f.settingsSection}` }
: known;
return fix.href.startsWith('/') ? (
<Link href={fix.href} className="admin-btn admin-btn-sm">
{fix.label}
@@ -371,27 +406,26 @@ export default function DiagnosticsView() {
return (
<div className="diag-page">
<div className="diag-head">
<div>
<h2>{headline}</h2>
<p className="diag-sub">
{ranAt ? `Last run ${ranAt.toLocaleString()}` : 'Running the checks…'}
</p>
</div>
<div className="diag-actions">
<button className="admin-btn admin-btn-sm" onClick={run} disabled={loading}>
<Icons.IconRefresh size={14} /> {loading ? 'Checking…' : 'Run again'}
</button>
<button
className="admin-btn admin-btn-sm"
onClick={copyReport}
disabled={loading || !all.length}
title="Markdown, ready to paste into an issue"
>
<Icons.IconCopy size={14} /> {copied ? 'Copied' : 'Copy report'}
</button>
</div>
</div>
<PageHeader
kicker="System"
title={headline}
description={ranAt ? `Last run ${ranAt.toLocaleString()}` : 'Running the checks…'}
actions={
<>
<button className="admin-btn admin-btn-sm" onClick={run} disabled={loading}>
<Icons.IconRefresh size={14} /> {loading ? 'Checking…' : 'Run again'}
</button>
<button
className="admin-btn admin-btn-sm"
onClick={copyReport}
disabled={loading || !all.length}
title="Markdown, ready to paste into an issue"
>
<Icons.IconCopy size={14} /> {copied ? 'Copied' : 'Copy report'}
</button>
</>
}
/>
{error && <div className="admin-error">{error}</div>}

View File

@@ -29,6 +29,7 @@ import {
IconX,
} from './Icons';
import { BlockBadge } from './BlockBadge';
import { InlineMarkdownField } from './InlineMarkdownField';
interface EssayBlockEditorProps {
markdown: string;
@@ -291,19 +292,17 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
{/* 2. PARAGRAPH BLOCK
The textarea shows `html` raw, as the journal editor does. It used
to strip the tags for display — `<strong>` to `**`, everything else
to nothing — while `onChange` wrote the stripped text straight back
into `html`. Italics, links, anything the markdown had produced was
destroyed by the act of editing the paragraph, and the two editors
disagreed about the same file. serializeJournalMarkdown() turns the
HTML back into markdown on save and handles `<em>` too, so there
was nothing to convert here in the first place. */}
The field shows the markdown source and writes the rendered HTML
back, as the journal editor does (InlineMarkdownField). An older
version stripped the tags for display and wrote the stripped text
straight back into `html`, destroying italics and links; the one
after that showed the raw HTML. */}
{block.type === 'paragraph' && (
<textarea
<InlineMarkdownField
multiline
rows={3}
value={block.html}
onChange={(e) => handleUpdateBlock(idx, { ...block, html: e.target.value })}
html={block.html}
onChange={(html) => handleUpdateBlock(idx, { ...block, html })}
placeholder="Write text paragraph... (**bold**, *italic* markdown supported)"
/>
)}
@@ -319,10 +318,11 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
paddingLeft: '10px',
}}
>
<textarea
<InlineMarkdownField
multiline
rows={2}
value={block.text}
onChange={(e) => handleUpdateBlock(idx, { ...block, text: e.target.value })}
html={block.text}
onChange={(text) => handleUpdateBlock(idx, { ...block, text })}
placeholder="Quote text..."
style={{ fontStyle: 'italic', fontFamily: 'serif', fontSize: '1rem' }}
/>
@@ -411,11 +411,10 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
</div>
</div>
<input
type="text"
value={block.caption || ''}
onChange={(e) =>
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
<InlineMarkdownField
html={block.caption}
onChange={(caption) =>
handleUpdateBlock(idx, { ...block, caption: caption || undefined })
}
placeholder="Photo caption (optional)"
/>
@@ -573,11 +572,10 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
</div>
</div>
<input
type="text"
value={block.caption || ''}
onChange={(e) =>
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
<InlineMarkdownField
html={block.caption}
onChange={(caption) =>
handleUpdateBlock(idx, { ...block, caption: caption || undefined })
}
placeholder="Side-by-side caption (optional)"
/>
@@ -693,11 +691,10 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
<IconPlus size={12} /> Add photo
</button>
<input
type="text"
value={block.caption || ''}
onChange={(e) =>
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
<InlineMarkdownField
html={block.caption}
onChange={(caption) =>
handleUpdateBlock(idx, { ...block, caption: caption || undefined })
}
placeholder="Grid caption (optional)"
/>
@@ -729,16 +726,13 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
})
}
/>
<input
type="text"
value={item.value}
<InlineMarkdownField
html={item.value}
placeholder="Value, e.g. 21 km"
onChange={(e) =>
onChange={(value) =>
handleUpdateBlock(idx, {
...block,
items: block.items.map((it, i) =>
i === fIdx ? { ...it, value: e.target.value } : it,
),
items: block.items.map((it, i) => (i === fIdx ? { ...it, value } : it)),
})
}
/>
@@ -828,13 +822,12 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
<option value="wide">Wide (one per row)</option>
</select>
</div>
<input
type="text"
value={block.caption || ''}
placeholder="Caption for the set (optional)"
onChange={(e) =>
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
<InlineMarkdownField
html={block.caption}
onChange={(caption) =>
handleUpdateBlock(idx, { ...block, caption: caption || undefined })
}
placeholder="Caption for the set (optional)"
/>
<span style={{ fontSize: '0.75rem', color: 'var(--admin-text-secondary)' }}>
Expanded into photo blocks when the page renders — the album&apos;s order, a
@@ -846,11 +839,10 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
{/* 8. MAP BLOCK */}
{block.type === 'map' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<input
type="text"
value={block.caption || ''}
onChange={(e) =>
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
<InlineMarkdownField
html={block.caption}
onChange={(caption) =>
handleUpdateBlock(idx, { ...block, caption: caption || undefined })
}
placeholder="Caption, e.g. Busan → Seoul (optional)"
/>

View File

@@ -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 (
<div className="settings-panel">
<div className="settings-section-header">
<h3>
<Icons.IconFileText size={18} /> Help
</h3>
<p className="settings-section-sub">
What your portfolio can do that has no button of its own.
</p>
</div>
<PageHeader
kicker="System"
title="Help"
description="What your portfolio can do that has no button of its own."
/>
<div className="settings-group">
<div className="settings-group-head">

View File

@@ -0,0 +1,63 @@
'use client';
import { useState, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react';
import { inlineHtmlToMarkdown, renderInlineMarkdown } from '@/lib/journal';
type SharedProps = {
/**
* The block field as the parser stores it: HTML from renderInlineMarkdown
* (a paragraph's `html`, a quote's `text`, a caption, a fact value).
*/
html: string | undefined;
/** Receives the field as HTML again, rendered from what was typed. */
onChange: (html: string) => void;
};
type InputProps = SharedProps & {
multiline?: false;
} & Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'type'>;
type TextareaProps = SharedProps & {
multiline: true;
} & Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'value' | 'onChange'>;
/**
* A text field for an inline-markdown block field (A-5).
*
* Blocks hold rendered HTML, since that is what the public page and the live
* preview show. The editors used to put that HTML straight into the field: a
* reloaded entry showed `<strong>` and `<a href="…">` in the textarea, text
* typed into a tag was lost on save, and typed `**bold**` stayed literal in
* the preview until a save re-parsed the file.
*
* The field shows the markdown source instead and hands every edit back
* rendered, so the preview is right while typing and the serializer writes
* the same markdown for an untouched block. What was typed is kept as typed:
* deriving the field from the HTML on every keystroke would rewrite `__x__`
* to `**x**`, or drop a half-typed link, under the cursor. The field re-reads
* the HTML only when it changes from outside (markdown mode, a reorder, a
* restore).
*/
export function InlineMarkdownField(props: InputProps | TextareaProps) {
const html = props.html ?? '';
const [text, setText] = useState(() => inlineHtmlToMarkdown(html));
const [shownHtml, setShownHtml] = useState(html);
if (html !== shownHtml) {
setShownHtml(html);
setText(inlineHtmlToMarkdown(html));
}
const update = (next: string) => {
const nextHtml = renderInlineMarkdown(next);
setText(next);
setShownHtml(nextHtml);
props.onChange(nextHtml);
};
if (props.multiline) {
const { html: _html, onChange: _onChange, multiline: _multiline, ...rest } = props;
return <textarea {...rest} value={text} onChange={(e) => update(e.target.value)} />;
}
const { html: _html, onChange: _onChange, multiline: _multiline, ...rest } = props;
return <input type="text" {...rest} value={text} onChange={(e) => update(e.target.value)} />;
}

View File

@@ -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<InboxData>('/api/admin/messages');
const data = inbox.data;
const [open, setOpen] = useState<string | null>(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 (
<AdminLoadState
loading={inbox.loading}
error={inbox.error}
onRetry={inbox.reload}
hasData={!!data}
/>
);
}
const unread = data.messages.filter((m) => !m.read).length;
return (
<div className="messages-view">
<PageHeader
kicker="Visitors"
title={
<>
Messages
{unread > 0 && <span className="messages-count">{unread} unread</span>}
</>
}
description={`Sent through the contact form at /contact. Stored on this server and deleted after ${data.retentionDays} days.`}
actions={
<button className="admin-btn admin-btn-ghost admin-btn-sm" onClick={inbox.reload}>
<Icons.IconRefresh size={14} /> Refresh
</button>
}
/>
{!data.enabled && (
<div className="messages-notice">
The contact form is switched off, so no new messages arrive.{' '}
<Link href="/admin/settings/legal">Turn it on in Settings → Legal</Link>.
</div>
)}
{data.enabled && !data.notifyConfigured && (
<div className="messages-notice">
No notification is set up, so new messages only show up here.{' '}
<Link href="/admin/settings/legal">Add an ntfy URL in Settings → Legal</Link>.
</div>
)}
{data.messages.length === 0 ? (
<div className="analytics-panel messages-empty">No messages.</div>
) : (
<ul className="messages-list">
{data.messages.map((m) => {
const expanded = open === m.id;
return (
<li
key={m.id}
className={`messages-item${m.read ? '' : ' messages-item--unread'}${expanded ? ' messages-item--open' : ''}`}
>
<button
type="button"
className="messages-summary"
onClick={() => toggle(m)}
aria-expanded={expanded}
>
<span className="messages-dot" aria-hidden="true" />
<span className="messages-from">
{m.name}
{!m.read && <span className="sr-only"> (unread)</span>}
</span>
<span className="messages-preview">{m.message.slice(0, 120)}</span>
<time className="messages-date" dateTime={m.receivedAt}>
{new Date(m.receivedAt).toLocaleString()}
</time>
</button>
{expanded && (
<div className="messages-body">
<p className="messages-meta">
{m.name} &lt;{m.email}&gt;
</p>
<p className="messages-text">{m.message}</p>
<div className="messages-actions">
<a className="admin-btn admin-btn-primary admin-btn-sm" href={replyHref(m)}>
Reply by email
</a>
<button
type="button"
className="admin-btn admin-btn-ghost admin-btn-sm"
onClick={() => markRead(m, false)}
>
Mark unread
</button>
<button
type="button"
className="admin-btn admin-btn-ghost admin-btn-sm"
onClick={() => remove(m)}
>
<Icons.IconTrash size={14} /> Delete
</button>
</div>
</div>
)}
</li>
);
})}
</ul>
)}
</div>
);
}

View File

@@ -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<Notify>(() => {});
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<Notification[]>([]);
const nextId = useRef(1);
const dismiss = useCallback((id: number) => {
setItems((list) => list.filter((n) => n.id !== id));
}, []);
const notify = useCallback<Notify>(
(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) => (
<div key={n.id} className={`admin-notification admin-notification--${n.kind}`}>
<span className="admin-notification__text">{n.message}</span>
{n.action && (
<button
type="button"
className="admin-notification__action"
onClick={() => {
n.action!.run();
dismiss(n.id);
}}
>
{n.action.label}
</button>
)}
<button
type="button"
className="admin-notification__close"
onClick={() => dismiss(n.id)}
aria-label="Dismiss"
>
<IconX size={14} />
</button>
</div>
));
return (
<NotifyContext.Provider value={notify}>
{children}
<div className="admin-notifications">
{/* Two regions, because politeness belongs to the region, not the message. */}
<div role="alert" aria-live="assertive" className="admin-notifications__group">
{render('error')}
</div>
<div role="status" aria-live="polite" className="admin-notifications__group">
{render('success')}
</div>
</div>
</NotifyContext.Provider>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -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 (
<header className="page-header">
<div className="page-header-text">
<p className="page-header-kicker">{kicker}</p>
<h1 className="page-header-title">{title}</h1>
{description && <p className="page-header-sub">{description}</p>}
</div>
{actions && <div className="page-header-actions">{actions}</div>}
</header>
);
}

View File

@@ -0,0 +1,228 @@
'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 { useCallback, useEffect, useRef, useState } from 'react';
import * as Icons from './Icons';
import { reportIfSessionExpired } from './sessionExpiry';
import { useUnsavedGuard } from './useUnsavedGuard';
import { useContentRestored } from './contentRestored';
import { useVersionedSave } from './useVersionedSave';
import { useLatest } from './useLatest';
import { useDraft } from './useDraft';
import DraftNotice from './DraftNotice';
import type { ProcessingFact } from '@/lib/privacy';
interface PrivacyData {
body: string;
enabled: boolean;
facts: ProcessingFact[];
starter: string;
version?: string;
}
type Status = { kind: 'success' | 'error'; message: string } | null;
export default function PrivacyEditor() {
const [data, setData] = useState<PrivacyData | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [body, setBody] = useState('');
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState<Status>(null);
useUnsavedGuard(dirty);
// The editor lives in the Legal section and unmounts when another section
// opens; without a draft an unsaved policy was simply gone (#554, #592).
const draft = useDraft<string>('privacy', body, dirty);
const loadDraft = draft.load;
/** privacy.md as loaded, sent back on save so a change elsewhere is caught (#601). */
const versionRef = useRef<string | null>(null);
const versionedSave = useVersionedSave();
/** The text as last rendered: tells a finished save whether typing went on meanwhile. */
const latestBody = useLatest(body);
const dirtyRef = useRef(dirty);
useEffect(() => {
dirtyRef.current = dirty;
});
const load = useCallback(async (): Promise<void> => {
try {
const res = await fetch('/api/admin/privacy');
if (!res.ok) {
reportIfSessionExpired(res);
throw new Error(`HTTP ${res.status}`);
}
const d = (await res.json()) as PrivacyData;
versionRef.current = d.version ?? null;
setData(d);
const restored = loadDraft(d.body);
setBody(restored ?? d.body);
setDirty(restored !== null);
} catch (err) {
setLoadError(err instanceof Error ? err.message : 'Could not load.');
}
}, [loadDraft]);
useEffect(() => {
void load();
}, [load]);
// A restored privacy.md replaces what this editor shows — unless there are
// unsaved edits, which stay; their save then meets the conflict prompt.
useContentRestored(({ target }) => {
if (target === 'privacy' && !dirtyRef.current) void load();
});
async function save() {
setSaving(true);
setStatus(null);
const sent = body;
try {
const result = await versionedSave(
'/api/admin/privacy',
{ body: sent },
versionRef,
'The privacy policy',
);
if (result.kind === 'reload') {
draft.discard();
await load();
return;
}
if (result.kind === 'keep') {
setStatus({ kind: 'error', message: 'Not saved — the policy changed elsewhere.' });
return;
}
if (result.kind === 'failed') {
if (!reportIfSessionExpired(result.res)) {
setStatus({ kind: 'error', message: `Could not save (HTTP ${result.res.status}).` });
}
return;
}
// Typed while the request was out: that part is not saved yet.
const editedMeanwhile = latestBody.current !== sent;
setData((d) => (d ? { ...d, body: sent } : d));
draft.saved(sent, editedMeanwhile);
if (!editedMeanwhile) 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 (
<p className="admin-field-hint admin-field-hint--error">
The privacy policy could not be loaded ({loadError}). Reload the page to try again.
</p>
);
}
if (!data) return <div className="admin-spinner" />;
return (
<div className="privacy-editor">
<DraftNotice
status={draft.status}
subject="privacy policy"
onDiscard={() => {
draft.discard();
setBody(data.body);
setDirty(false);
setStatus(null);
}}
onRestore={() => {
const value = draft.takeConflicting();
if (value === null) return;
setBody(value);
setDirty(true);
}}
onDismiss={draft.dismiss}
/>
<div className="privacy-editor__facts">
<h4>What this site processes</h4>
<p className="admin-field-hint">
Read off your current configuration. A checklist for writing the policy, not text for the
page.
</p>
<ul>
{data.facts.map((f) => (
<li key={f.topic}>
<strong>{f.topic}</strong>
{f.thirdParty && <span className="privacy-editor__badge">third party</span>}
<span>{f.detail}</span>
</li>
))}
</ul>
</div>
<div className="admin-field">
<label htmlFor="privacy-body">Privacy policy (Markdown)</label>
<textarea
id="privacy-body"
value={body}
onChange={(e) => {
setBody(e.target.value);
setDirty(true);
}}
rows={18}
placeholder="## Controller&#10;&#10;Name, address, email…"
/>
<p className="admin-field-hint">
Headings with <code>##</code>, lists with <code>-</code>, <code>**bold**</code> and{' '}
<code>[links](https://…)</code>. Shown at /privacy and linked in the footer once there is
text. Earlier versions are kept under Backups.
</p>
</div>
<div className="privacy-editor__actions">
<button type="button" className="admin-btn admin-btn-ghost" onClick={insertHeadings}>
<Icons.IconPlus size={14} /> Insert headings
</button>
{body.trim() && data.enabled && (
<a
className="admin-btn admin-btn-ghost"
href="/privacy"
target="_blank"
rel="noopener noreferrer"
>
View /privacy ↗
</a>
)}
<button
type="button"
className="admin-btn admin-btn-primary"
onClick={save}
disabled={!dirty || saving}
>
{saving ? 'Saving…' : 'Save privacy policy'}
</button>
{status && (
<span className={`save-message ${status.kind}`} role="status">
{status.message}
</span>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,732 @@
'use client';
/**
* Admin → Proofing: client links, their selections and the export.
*
* One card per link. Creating a link picks a client name and an album — any
* Immich album, published or not — plus optional expiry and download rights.
* A card expands into the selected photos and the export buttons.
*/
import { useCallback, useEffect, useState } from 'react';
import PageHeader from './PageHeader';
import * as Icons from './Icons';
import { reportIfSessionExpired } from './sessionExpiry';
import type { AdminProofPick, AdminProofSession } from '@/lib/admin/proofing-view';
interface AlbumOption {
id: string;
albumName: string;
assetCount: number;
}
const STATE_LABEL: Record<AdminProofSession['state'], string> = {
open: 'Open',
submitted: 'Submitted',
expired: 'Expired',
};
function formatDate(iso: string | null): string {
if (!iso) return '—';
const date = iso.length === 10 ? new Date(`${iso}T12:00:00`) : new Date(iso);
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
async function api<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init);
if (reportIfSessionExpired(res)) throw new Error('Session expired');
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((data as { error?: string }).error || `HTTP ${res.status}`);
return data as T;
}
function CreateForm({ onCreated }: { onCreated: () => void }) {
const [albums, setAlbums] = useState<AlbumOption[] | null>(null);
const [clientName, setClientName] = useState('');
const [albumId, setAlbumId] = useState('');
const [alias, setAlias] = useState('');
const [expiresOn, setExpiresOn] = useState('');
const [downloadsAllowed, setDownloadsAllowed] = useState(false);
const [limit, setLimit] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api<{ albums: AlbumOption[] }>('/api/admin/albums')
.then((data) =>
setAlbums([...data.albums].sort((a, b) => a.albumName.localeCompare(b.albumName))),
)
.catch((err: Error) => setError(err.message));
}, []);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
try {
await api('/api/admin/proofing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
clientName,
alias: alias.trim() || null,
albumId,
expiresOn: expiresOn || null,
download: downloadsAllowed ? 'selection' : 'none',
downloadLimit: downloadsAllowed && limit ? Number(limit) : null,
}),
});
setClientName('');
setAlias('');
setAlbumId('');
setExpiresOn('');
setDownloadsAllowed(false);
setLimit('');
onCreated();
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
};
return (
<form className="analytics-panel proofing-create" onSubmit={submit}>
<div className="analytics-section-title">
<h3>
<Icons.IconPlus size={16} /> New client link
</h3>
</div>
<div className="proofing-create-grid">
<div className="admin-field">
<label htmlFor="proof-client">Client</label>
<input
id="proof-client"
value={clientName}
onChange={(e) => setClientName(e.target.value)}
placeholder="Anna & Ben"
maxLength={120}
required
/>
</div>
<div className="admin-field">
<label htmlFor="proof-alias">Client URL alias</label>
<input
id="proof-alias"
value={alias}
onChange={(e) =>
setAlias(
e.target.value
.replace(/\s+/g, '-')
.replace(/[^A-Za-z0-9-]/g, ''),
)
}
placeholder="Client (Optional)"
maxLength={64}
/>
</div>
<div className="admin-field">
<label htmlFor="proof-album">Album</label>
<select
id="proof-album"
value={albumId}
onChange={(e) => setAlbumId(e.target.value)}
required
disabled={!albums}
>
<option value="">
{albums ? 'Choose an Immich album…' : 'Loading albums…'}
</option>
{albums?.map((album) => (
<option key={album.id} value={album.id}>
{album.albumName} ({album.assetCount})
</option>
))}
</select>
</div>
<div className="admin-field">
<label htmlFor="proof-expires">Valid until (optional)</label>
<input
id="proof-expires"
type="date"
value={expiresOn}
onChange={(e) => setExpiresOn(e.target.value)}
/>
</div>
<div className="admin-field">
<label htmlFor="proof-download">Downloads</label>
<select
id="proof-download"
value={downloadsAllowed ? 'allowed' : 'none'}
onChange={(e) =>
setDownloadsAllowed(e.target.value === 'allowed')
}
>
<option value="none">Downloads disabled</option>
<option value="allowed">Downloads allowed</option>
</select>
</div>
{downloadsAllowed && (
<div className="admin-field">
<label htmlFor="proof-limit">Download limit (optional)</label>
<input
id="proof-limit"
type="number"
min={1}
max={1000}
value={limit}
onChange={(e) => setLimit(e.target.value)}
placeholder="Unlimited"
/>
</div>
)}
</div>
{error && <p className="admin-error">{error}</p>}
<div>
<button
type="submit"
className="admin-btn admin-btn-primary"
disabled={busy || !clientName.trim() || !albumId}
>
<Icons.IconLink size={14} /> {busy ? 'Creating…' : 'Create link'}
</button>
</div>
</form>
);
}
function SessionCard({
session,
expanded,
onToggle,
onChanged,
}: {
session: AdminProofSession;
expanded: boolean;
onToggle: () => void;
onChanged: () => void;
}) {
const [picks, setPicks] = useState<AdminProofPick[] | null>(null);
const [copied, setCopied] = useState<'link' | 'lightroom' | null>(null);
const [error, setError] = useState<string | null>(null);
const [editing, setEditing] = useState(false);
const [albums, setAlbums] = useState<AlbumOption[] | null>(null);
const [editClientName, setEditClientName] = useState(session.clientName);
const [editAlias, setEditAlias] = useState(session.alias ?? '');
const [editAlbumId, setEditAlbumId] = useState(session.albumId);
const [editExpiresOn, setEditExpiresOn] = useState(session.expiresOn ?? '');
const [editDownloadsAllowed, setEditDownloadsAllowed] = useState(
session.download !== 'none',
);
const [editLimit, setEditLimit] = useState(
session.downloadLimit !== null
? String(session.downloadLimit)
: '',
);
const [editBusy, setEditBusy] = useState(false);
const beginEdit = async () => {
setEditClientName(session.clientName);
setEditAlias(session.alias ?? '');
setEditAlbumId(session.albumId);
setEditExpiresOn(session.expiresOn ?? '');
setEditDownloadsAllowed(session.download !== 'none');
setEditLimit(
session.downloadLimit !== null
? String(session.downloadLimit)
: '',
);
setError(null);
if (!albums) {
try {
const data = await api<{ albums: AlbumOption[] }>('/api/admin/albums');
setAlbums(
[...data.albums].sort((a, b) =>
a.albumName.localeCompare(b.albumName),
),
);
} catch (err) {
setError((err as Error).message);
return;
}
}
setEditing(true);
};
const saveEdit = async () => {
setEditBusy(true);
setError(null);
try {
await api(`/api/admin/proofing/${session.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
clientName: editClientName.trim(),
alias: editAlias.trim() || null,
albumId: editAlbumId,
expiresOn: editExpiresOn || null,
// We keep "selection" internally as our generic
// "downloads allowed" state.
download: editDownloadsAllowed
? 'selection'
: 'none',
downloadLimit:
editDownloadsAllowed && editLimit
? Number(editLimit)
: null,
}),
});
setEditing(false);
onChanged();
} catch (err) {
setError((err as Error).message);
} finally {
setEditBusy(false);
}
};
const linkUrl = typeof window !== 'undefined' ? `${window.location.origin}${session.link}` : '';
useEffect(() => {
if (!expanded) return;
api<{ picks: AdminProofPick[] }>(`/api/admin/proofing/${session.id}`)
.then((data) => setPicks(data.picks))
.catch((err: Error) => setError(err.message));
}, [expanded, session.id, session.selected, session.submittedAt]);
const copy = (kind: 'link' | 'lightroom', text: string) => {
if (!navigator.clipboard) {
window.prompt('Copy:', text);
return;
}
navigator.clipboard.writeText(text).then(
() => {
setCopied(kind);
setTimeout(() => setCopied(null), 2000);
},
() => window.prompt('Copy:', text),
);
};
const patch = async (body: Record<string, unknown>) => {
setError(null);
try {
await api(`/api/admin/proofing/${session.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
onChanged();
} catch (err) {
setError((err as Error).message);
}
};
const remove = async () => {
if (!window.confirm(`Delete the link for ${session.clientName}? It stops working at once.`)) {
return;
}
try {
await api(`/api/admin/proofing/${session.id}`, { method: 'DELETE' });
onChanged();
} catch (err) {
setError((err as Error).message);
}
};
const copyLightroom = async () => {
try {
const res = await fetch(`/api/admin/proofing/${session.id}/export?format=lightroom`);
if (reportIfSessionExpired(res)) return;
if (!res.ok) throw new Error(`HTTP ${res.status}`);
copy('lightroom', await res.text());
} catch (err) {
setError((err as Error).message);
}
};
const downloadsText =
session.download === 'none'
? 'Downloads disabled'
: `Downloads allowed · ${session.downloadsUsed}${
session.downloadLimit !== null
? ` / ${session.downloadLimit}`
: ''
} used`;
return (
<div className={`analytics-panel proofing-card proofing-card--${session.state}`}>
<div className="proofing-card-head">
<div>
<h3 className="proofing-card-title">
{session.clientName}
<span className={`proofing-badge proofing-badge--${session.state}`}>
{STATE_LABEL[session.state]}
</span>
</h3>
<p className="analytics-subtitle">
{session.albumName ?? 'Album not found in Immich'} · {session.selected}
{session.albumAssetCount !== null ? ` of ${session.albumAssetCount}` : ''} selected ·
created {formatDate(session.createdAt)}
{session.expiresOn ? ` · valid until ${formatDate(session.expiresOn)}` : ''}
</p>
<p className="analytics-subtitle">
{downloadsText}
{session.submittedAt ? ` · submitted ${formatDate(session.submittedAt)}` : ''}
</p>
</div>
<div className="proofing-card-actions">
<button
type="button"
className="admin-btn admin-btn-ghost admin-btn-sm"
onClick={() => copy('link', linkUrl)}
>
{copied === 'link' ? <Icons.IconCheck size={14} /> : <Icons.IconCopy size={14} />}{' '}
{copied === 'link' ? 'Copied' : 'Copy link'}
</button>
<button
type="button"
className="admin-btn admin-btn-ghost admin-btn-sm"
onClick={() => void beginEdit()}
>
<Icons.IconLink size={14} /> Edit link
</button>
<button
type="button"
className="admin-btn admin-btn-ghost admin-btn-sm"
onClick={() => {
const next = window.prompt(
'Client URL alias (leave blank to remove):',
session.alias ?? '',
);
if (next === null) return;
const cleaned = next
.trim()
.replace(/\s+/g, '-')
.replace(/[^A-Za-z0-9-]/g, '');
void patch({
alias: cleaned || null,
});
}}
>
<Icons.IconLink size={14} />
{session.alias ? ' Change alias' : ' Set alias'}
</button>
<a
className="admin-btn admin-btn-ghost admin-btn-sm"
href={session.link}
target="_blank"
rel="noreferrer"
>
<Icons.IconEye size={14} /> Open
</a>
<button
type="button"
className="admin-btn admin-btn-ghost admin-btn-sm"
onClick={onToggle}
aria-expanded={expanded}
>
{expanded ? <Icons.IconChevronUp size={14} /> : <Icons.IconChevronDown size={14} />}{' '}
Selection
</button>
</div>
</div>
{error && <p className="admin-error">{error}</p>}
{editing && (
<div className="analytics-panel" style={{ marginTop: '1rem' }}>
<div className="proofing-create-grid">
<div className="admin-field">
<label>Client</label>
<input
value={editClientName}
onChange={(e) => setEditClientName(e.target.value)}
maxLength={120}
/>
</div>
<div className="admin-field">
<label>Client URL alias</label>
<input
value={editAlias}
onChange={(e) =>
setEditAlias(
e.target.value
.replace(/\s+/g, '-')
.replace(/[^A-Za-z0-9-]/g, ''),
)
}
maxLength={64}
placeholder="Mita"
/>
<small>
/c/{editAlias || 'Mita'}
</small>
</div>
<div className="admin-field">
<label>Album</label>
<select
value={editAlbumId}
onChange={(e) => setEditAlbumId(e.target.value)}
disabled={!albums}
>
{albums?.map((album) => (
<option key={album.id} value={album.id}>
{album.albumName} ({album.assetCount})
</option>
))}
</select>
</div>
<div className="admin-field">
<label>Valid until (optional)</label>
<input
type="date"
value={editExpiresOn}
onChange={(e) => setEditExpiresOn(e.target.value)}
/>
</div>
<div className="admin-field">
<label>Downloads</label>
<select
value={editDownloadsAllowed ? 'allowed' : 'none'}
onChange={(e) =>
setEditDownloadsAllowed(
e.target.value === 'allowed',
)
}
>
<option value="none">Downloads disabled</option>
<option value="allowed">Downloads allowed</option>
</select>
</div>
{editDownloadsAllowed && (
<div className="admin-field">
<label>Download limit (optional)</label>
<input
type="number"
min={1}
max={1000}
value={editLimit}
onChange={(e) => setEditLimit(e.target.value)}
placeholder="Unlimited"
/>
</div>
)}
</div>
{editAlbumId !== session.albumId && (
<p className="analytics-subtitle">
Changing the album will clear this client's current selection,
submission state and download counter.
</p>
)}
<div
className="proofing-card-actions"
style={{ marginTop: '1rem' }}
>
<button
type="button"
className="admin-btn admin-btn-primary admin-btn-sm"
onClick={() => void saveEdit()}
disabled={
editBusy ||
!editClientName.trim() ||
!editAlbumId
}
>
{editBusy ? 'Saving…' : 'Save changes'}
</button>
<button
type="button"
className="admin-btn admin-btn-ghost admin-btn-sm"
onClick={() => setEditing(false)}
disabled={editBusy}
>
Cancel
</button>
</div>
</div>
)}
{expanded && (
<div className="proofing-card-body">
<div className="proofing-card-actions">
<button
type="button"
className="admin-btn admin-btn-primary admin-btn-sm"
onClick={copyLightroom}
disabled={session.selected === 0}
title="File names without extension, comma-separated — paste into Lightroom's or Capture One's filename filter to find the RAW files too"
>
{copied === 'lightroom' ? (
<Icons.IconCheck size={14} />
) : (
<Icons.IconCopy size={14} />
)}{' '}
{copied === 'lightroom' ? 'Copied' : 'Copy file names (Lightroom)'}
</button>
<a
className="admin-btn admin-btn-ghost admin-btn-sm"
href={`/api/admin/proofing/${session.id}/export?format=csv`}
>
<Icons.IconFileText size={14} /> CSV
</a>
<a
className="admin-btn admin-btn-ghost admin-btn-sm"
href={`/api/admin/proofing/${session.id}/export?format=txt`}
>
<Icons.IconFileText size={14} /> TXT
</a>
{session.submittedAt && (
<button
type="button"
className="admin-btn admin-btn-ghost admin-btn-sm"
onClick={() => patch({ reopen: true })}
>
<Icons.IconRefresh size={14} /> Reopen for changes
</button>
)}
{session.download !== 'none' && session.downloadsUsed > 0 && (
<button
type="button"
className="admin-btn admin-btn-ghost admin-btn-sm"
onClick={() => patch({ resetDownloads: true })}
>
<Icons.IconRefresh size={14} /> Reset downloads
</button>
)}
<button
type="button"
className="admin-btn admin-btn-ghost admin-btn-sm admin-btn-icon-danger"
onClick={remove}
>
<Icons.IconTrash size={14} /> Delete link
</button>
</div>
{picks === null ? (
<div className="admin-spinner" />
) : picks.length === 0 ? (
<p className="analytics-empty">No photos selected yet.</p>
) : (
<ul className="proofing-picks">
{picks.map((pick) => (
<li key={pick.assetId} className="proofing-pick">
{/* eslint-disable-next-line @next/next/no-img-element -- admin thumbnails, served by our own proxy */}
<img src={pick.thumbUrl} alt="" loading="lazy" />
<span className="proofing-pick-name" title={pick.fileName}>
#{pick.position} · {pick.fileName}
</span>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
export default function ProofingView() {
const [sessions, setSessions] = useState<AdminProofSession[] | null>(null);
const [webhookConfigured, setWebhookConfigured] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState<string | null>(null);
const load = useCallback(async () => {
try {
const data = await api<{ sessions: AdminProofSession[]; webhookConfigured: boolean }>(
'/api/admin/proofing',
);
setSessions(data.sessions);
setWebhookConfigured(data.webhookConfigured);
setError(null);
} catch (err) {
setError((err as Error).message);
}
}, []);
useEffect(() => {
// The webhook's admin link lands here with ?session=<id>: open that card.
const wanted = new URLSearchParams(window.location.search).get('session');
// eslint-disable-next-line react-hooks/set-state-in-effect -- one-time read of the URL on mount
if (wanted) setExpanded(wanted);
void load();
}, [load]);
return (
<div className="analytics-view proofing-view">
<PageHeader
kicker="Visitors"
title="Client proofing"
description="A private link per client. Picks are saved as they go; you see them here once they submit."
actions={
<button className="admin-btn admin-btn-ghost admin-btn-sm" onClick={() => void load()}>
<Icons.IconRefresh size={14} /> Refresh
</button>
}
/>
{!webhookConfigured && (
<p className="analytics-subtitle">
Tip: set <code>PROOFING_WEBHOOK_URL</code> to be notified (Discord, Slack, Gotify, n8n, …)
when a client submits.
</p>
)}
<CreateForm onCreated={() => void load()} />
{error && <p className="admin-error">{error}</p>}
{sessions === null && !error ? (
<div className="admin-loading-container">
<div className="admin-spinner" />
</div>
) : sessions && sessions.length === 0 ? (
<p className="analytics-empty">No client links yet.</p>
) : (
sessions?.map((session) => (
<SessionCard
key={session.id}
session={session}
expanded={expanded === session.id}
onToggle={() => setExpanded((cur) => (cur === session.id ? null : session.id))}
onChanged={() => void load()}
/>
))
)}
</div>
);
}

View File

@@ -1,9 +1,16 @@
'use client';
/**
* The outcome of the last save. Typed rather than read off the wording: the bar
* used to call a message an error when it started with "Error", so a failure
* worded any other way was shown as a success (#600).
*/
export type SaveStatus = { kind: 'success' | 'error'; message: string } | null;
interface SaveBarProps {
dirty: boolean;
saving: boolean;
saveMessage: string;
status: SaveStatus;
onSave: () => void;
label: string;
/** Show the cache-bypassing preview link (page builder only). */
@@ -21,20 +28,18 @@ interface SaveBarProps {
export default function SaveBar({
dirty,
saving,
saveMessage,
status,
onSave,
label,
showPreview = false,
}: SaveBarProps) {
if (!dirty && !saving && !saveMessage) return null;
if (!dirty && !saving && !status) return null;
return (
<div className={`floating-save-bar ${dirty ? 'dirty' : ''}`} role="status">
<div className="save-bar-left">
{saveMessage ? (
<span className={`save-message ${saveMessage.startsWith('Error') ? 'error' : 'success'}`}>
{saveMessage}
</span>
{status ? (
<span className={`save-message ${status.kind}`}>{status.message}</span>
) : (
<span className="unsaved-badge">
<span className="badge-pulse-dot" aria-hidden="true" />

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
import AdminDashboard from '../AdminDashboard';
import { SESSION_EXPIRED_EVENT } from '../sessionExpiry';
import { NotificationProvider } from '../Notifications';
vi.mock('next/navigation', () => ({
usePathname: () => '/admin',
@@ -16,8 +17,6 @@ afterEach(cleanup);
*/
describe('AdminDashboard reload failure reporting', () => {
it('reports a non-ok, non-401 reload failure', async () => {
const alertMock = vi.fn();
vi.stubGlobal('alert', alertMock);
vi.stubGlobal(
'fetch',
vi.fn(async (url: string, init?: RequestInit) => {
@@ -29,19 +28,22 @@ describe('AdminDashboard reload failure reporting', () => {
);
render(
<AdminDashboard onLogout={() => {}}>
<div>Panel</div>
</AdminDashboard>,
<NotificationProvider>
<AdminDashboard onLogout={() => {}}>
<div>Panel</div>
</AdminDashboard>
</NotificationProvider>,
);
fireEvent.click(screen.getByTitle('Reload config & clear cache'));
await waitFor(() => expect(alertMock).toHaveBeenCalledWith('Reload failed (HTTP 500).'));
// Reported through the notification surface (#600), not alert().
await waitFor(() =>
expect(screen.getByRole('alert').textContent).toContain('Reload failed (HTTP 500).'),
);
});
it('raises the session-expired event instead of alerting on a 401', async () => {
const alertMock = vi.fn();
vi.stubGlobal('alert', alertMock);
it('raises the session-expired event instead of reporting an error on a 401', async () => {
const listener = vi.fn();
window.addEventListener(SESSION_EXPIRED_EVENT, listener);
vi.stubGlobal(
@@ -55,21 +57,21 @@ describe('AdminDashboard reload failure reporting', () => {
);
render(
<AdminDashboard onLogout={() => {}}>
<div>Panel</div>
</AdminDashboard>,
<NotificationProvider>
<AdminDashboard onLogout={() => {}}>
<div>Panel</div>
</AdminDashboard>
</NotificationProvider>,
);
fireEvent.click(screen.getByTitle('Reload config & clear cache'));
await waitFor(() => expect(listener).toHaveBeenCalledOnce());
expect(alertMock).not.toHaveBeenCalled();
expect(screen.getByRole('alert').textContent).toBe('');
window.removeEventListener(SESSION_EXPIRED_EVENT, listener);
});
it('does not alert when the reload succeeds', async () => {
const alertMock = vi.fn();
vi.stubGlobal('alert', alertMock);
it('reports no error when the reload succeeds', async () => {
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
if (url === '/api/admin/reload' && init?.method === 'POST') {
return new Response(null, { status: 200 });
@@ -79,9 +81,11 @@ describe('AdminDashboard reload failure reporting', () => {
vi.stubGlobal('fetch', fetchMock);
render(
<AdminDashboard onLogout={() => {}}>
<div>Panel</div>
</AdminDashboard>,
<NotificationProvider>
<AdminDashboard onLogout={() => {}}>
<div>Panel</div>
</AdminDashboard>
</NotificationProvider>,
);
fireEvent.click(screen.getByTitle('Reload config & clear cache'));
@@ -93,6 +97,6 @@ describe('AdminDashboard reload failure reporting', () => {
),
).toHaveLength(1),
);
expect(alertMock).not.toHaveBeenCalled();
expect(screen.getByRole('alert').textContent).toBe('');
});
});

View File

@@ -0,0 +1,94 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
import { JournalEditor } from '../journal/JournalEditor';
import { NotificationProvider } from '../Notifications';
import { ConfirmProvider } from '../ConfirmDialog';
/**
* The page editor's Publish button flips the Draft flag and saves at once
* (#722). When that save fails, the flipped flag is an unsaved edit: the editor
* has to say so and keep it saveable, not show "Saved" over a page that is
* still a draft on disk.
*/
const MARKDOWN = '---\ntitle: "FAQ"\ndraft: true\n---\n\nHello\n';
let putStatus = 500;
beforeEach(() => {
putStatus = 500;
vi.stubGlobal(
'fetch',
vi.fn(async (_url: string, init?: RequestInit) => {
if (init?.method === 'PUT') {
if (putStatus !== 200) {
return new Response(JSON.stringify({ error: 'Failed to save page' }), {
status: putStatus,
});
}
const sent = JSON.parse(init.body as string) as { rawMarkdown: string };
return new Response(
JSON.stringify({
success: true,
page: { slug: 'faq', rawMarkdown: sent.rawMarkdown },
version: 'v2',
}),
{ status: 200 },
);
}
return new Response(
JSON.stringify({ page: { slug: 'faq', rawMarkdown: MARKDOWN }, version: 'v1' }),
{ status: 200 },
);
}),
);
// Node 26 defines its own global localStorage (undefined without
// --localstorage-file), which shadows jsdom's; give the editor a working one.
const store = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, String(v)),
removeItem: (k: string) => void store.delete(k),
clear: () => store.clear(),
});
// Drafts live in sessionStorage; each test starts without one.
window.sessionStorage.clear();
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
function renderEditor() {
return render(
<NotificationProvider>
<ConfirmProvider>
<JournalEditor slug="faq" kind="page" onBack={() => {}} />
</ConfirmProvider>
</NotificationProvider>,
);
}
describe('JournalEditor page mode: Publish', () => {
it('leaves a failed publish as an unsaved change', async () => {
renderEditor();
fireEvent.click(await screen.findByRole('button', { name: 'Publish' }));
await waitFor(() =>
expect(screen.getByRole('alert').textContent).toContain('Failed to save page'),
);
const save = screen.getByRole('button', { name: 'Save Changes' });
expect((save as HTMLButtonElement).disabled).toBe(false);
});
it('reads Saved after a publish that went through', async () => {
putStatus = 200;
renderEditor();
fireEvent.click(await screen.findByRole('button', { name: 'Publish' }));
await screen.findByRole('button', { name: 'Unpublish' });
await waitFor(() => expect(screen.getByRole('button', { name: /Saved/ })).toBeTruthy());
});
});

View File

@@ -0,0 +1,91 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
import PrivacyEditor from '../PrivacyEditor';
import { NotificationProvider } from '../Notifications';
import { ConfirmProvider } from '../ConfirmDialog';
/**
* The privacy editor sits in Settings → Legal and unmounts when another
* section opens. An unsaved policy used to go with it (A-7); it now keeps a
* draft like the About editor (#554, #592).
*/
let serverBody = 'Saved policy';
beforeEach(() => {
serverBody = 'Saved policy';
window.sessionStorage.clear();
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
new Response(
JSON.stringify({
body: serverBody,
enabled: true,
facts: [],
starter: '## Controller',
version: 'v1',
}),
{ status: 200 },
),
),
);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
function mount() {
return render(
<NotificationProvider>
<ConfirmProvider>
<PrivacyEditor />
</ConfirmProvider>
</NotificationProvider>,
);
}
const editor = async () =>
(await screen.findByLabelText('Privacy policy (Markdown)')) as HTMLTextAreaElement;
describe('PrivacyEditor draft (A-7)', () => {
it('brings back unsaved text after the editor was unmounted', async () => {
const first = mount();
fireEvent.change(await editor(), { target: { value: 'Unsaved policy' } });
first.unmount();
mount();
expect((await editor()).value).toBe('Unsaved policy');
expect(screen.getByText(/unsaved changes to the privacy policy were restored/)).toBeTruthy();
const save = screen.getByRole('button', { name: 'Save privacy policy' }) as HTMLButtonElement;
expect(save.disabled).toBe(false);
});
it('discarding the draft shows the saved policy again', async () => {
const first = mount();
fireEvent.change(await editor(), { target: { value: 'Unsaved policy' } });
first.unmount();
mount();
await editor();
fireEvent.click(screen.getByRole('button', { name: 'Discard changes' }));
expect((await editor()).value).toBe('Saved policy');
await waitFor(() => expect(window.sessionStorage.getItem('folio_draft_privacy')).toBeNull());
});
it('holds a draft back when the file changed since, and asks', async () => {
const first = mount();
fireEvent.change(await editor(), { target: { value: 'Unsaved policy' } });
first.unmount();
serverBody = 'Changed in another tab';
mount();
expect((await editor()).value).toBe('Changed in another tab');
fireEvent.click(screen.getByRole('button', { name: 'Restore my changes' }));
expect((await editor()).value).toBe('Unsaved policy');
});
});

View File

@@ -0,0 +1,176 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import { useModalDialog } from '@/hooks/useModalDialog';
import AlbumPicker from '../AlbumPicker';
import AlbumDrawer from '../page-builder/AlbumDrawer';
import { StorySettingsModal } from '../journal/StorySettingsModal';
afterEach(() => {
cleanup();
});
function Dialog({
name,
onClose,
children,
}: {
name: string;
onClose: () => void;
children?: React.ReactNode;
}) {
const ref = useModalDialog(onClose);
return (
<div ref={ref} role="dialog" aria-modal="true" aria-label={name}>
<button type="button">{name} first</button>
{children}
<button type="button">{name} last</button>
</div>
);
}
const escape = () => fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
describe('useModalDialog with nested dialogs (A-9)', () => {
it('Escape closes only the innermost dialog', () => {
const closeOuter = vi.fn();
const closeInner = vi.fn();
const { rerender } = render(<Dialog name="outer" onClose={closeOuter} />);
rerender(
<Dialog name="outer" onClose={closeOuter}>
<Dialog name="inner" onClose={closeInner} />
</Dialog>,
);
escape();
expect(closeInner).toHaveBeenCalledTimes(1);
expect(closeOuter).not.toHaveBeenCalled();
// Once the inner one is gone, the outer one answers again.
rerender(<Dialog name="outer" onClose={closeOuter} />);
escape();
expect(closeOuter).toHaveBeenCalledTimes(1);
});
it('leaves Escape to an expanded combobox', () => {
const close = vi.fn();
render(
<Dialog name="d" onClose={close}>
<button type="button" role="combobox" aria-expanded="true" aria-controls="x">
sort
</button>
</Dialog>,
);
screen.getByRole('combobox').focus();
escape();
expect(close).not.toHaveBeenCalled();
});
it('leaves Escape to an item picked up with the keyboard (dnd-kit)', () => {
const close = vi.fn();
render(
<Dialog name="d" onClose={close}>
<div role="button" tabIndex={0} aria-roledescription="sortable" aria-pressed="true">
tile
</div>
</Dialog>,
);
screen.getByText('tile').focus();
escape();
expect(close).not.toHaveBeenCalled();
});
});
describe('AlbumPicker from the keyboard (A-10)', () => {
const albums = [
{
id: 'a1',
albumName: 'Alps',
description: '',
thumbnailAssetId: null,
assetCount: 3,
isConfigured: false,
},
{
id: 'a2',
albumName: 'Baltic',
description: '',
thumbnailAssetId: null,
assetCount: 5,
isConfigured: true,
},
];
it('offers each free album as a button and disables the ones in use', () => {
const onSelect = vi.fn();
render(
<AlbumPicker
albums={albums}
onSelect={onSelect}
onClose={() => {}}
usedAlbumIds={new Set(['a2'])}
/>,
);
const alps = screen.getByRole('button', { name: /Alps/ });
const baltic = screen.getByRole('button', { name: /Baltic/ }) as HTMLButtonElement;
expect(baltic.disabled).toBe(true);
fireEvent.click(alps);
expect(onSelect).toHaveBeenCalledWith('a1');
expect(screen.getByRole('button', { name: 'Close' })).toBeTruthy();
});
it('closes on Escape', () => {
const onClose = vi.fn();
render(
<AlbumPicker
albums={albums}
onSelect={() => {}}
onClose={onClose}
usedAlbumIds={new Set()}
/>,
);
escape();
expect(onClose).toHaveBeenCalledTimes(1);
});
});
describe('AlbumDrawer as a dialog (A-9)', () => {
it('is a labelled modal dialog that closes on Escape', () => {
const onClose = vi.fn();
render(
<AlbumDrawer
album={{ id: 'a1' }}
name="Alps"
count={3}
thumbnailId={null}
onUpdate={() => {}}
onRemove={() => {}}
onClose={onClose}
onPickHero={() => {}}
onEditOrder={() => {}}
/>,
);
const dialog = screen.getByRole('dialog', { name: /Edit Album Details/ });
expect(dialog.getAttribute('aria-modal')).toBe('true');
expect(dialog.contains(document.activeElement)).toBe(true);
escape();
expect(onClose).toHaveBeenCalledTimes(1);
});
});
describe('StorySettingsModal as a dialog (A-9)', () => {
it('is a labelled modal dialog that closes on Escape', () => {
const onClose = vi.fn();
render(
<StorySettingsModal
frontmatter={{ title: 'Trip' }}
onChange={() => {}}
onPickAsset={() => {}}
onClose={onClose}
/>,
);
const dialog = screen.getByRole('dialog', { name: /Story Settings/ });
expect(dialog.getAttribute('aria-modal')).toBe('true');
escape();
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,266 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { render, screen, cleanup, fireEvent, waitFor, act } from '@testing-library/react';
import { JournalEditor } from '../journal/JournalEditor';
import PrivacyEditor from '../PrivacyEditor';
import SettingsEditor from '../SettingsEditor';
import PageBuilder from '../PageBuilder';
import { NotificationProvider } from '../Notifications';
import { ConfirmProvider } from '../ConfirmDialog';
const nav = vi.hoisted(() => ({ section: 'general' }));
vi.mock('next/navigation', () => ({
useRouter: () => ({ refresh: () => {}, push: () => {}, replace: () => {} }),
useParams: () => ({ section: nav.section }),
usePathname: () => '/admin/settings/general',
}));
/**
* A save handler closes over the state it started with. Typing on while the
* request is out used to be marked saved when the answer came back: the Save
* button went to "Saved" and disabled, the unsaved-changes guard and the draft
* let go, and leaving the editor threw those edits away. Where the server
* rewrote the text (a hashed password line), the newer text was also replaced
* by the file as written.
*/
/** A PUT that answers only when the test says so. */
let releasePut: ((written?: string) => void) | null = null;
let putBodies: unknown[] = [];
function stubFetch(
loadResponse: unknown,
putResponse: (sent: Record<string, unknown>, written?: string) => unknown,
) {
vi.stubGlobal(
'fetch',
vi.fn(async (_url: string, init?: RequestInit) => {
if (init?.method === 'PUT') {
const sent = JSON.parse(init.body as string) as Record<string, unknown>;
putBodies.push(sent);
const written = await new Promise<string | undefined>((resolve) => {
releasePut = resolve;
});
return new Response(JSON.stringify(putResponse(sent, written)), { status: 200 });
}
const body = typeof loadResponse === 'function' ? loadResponse(_url) : loadResponse;
return new Response(JSON.stringify(body), { status: 200 });
}),
);
}
beforeEach(() => {
nav.section = 'general';
releasePut = null;
putBodies = [];
// Node 26 defines its own global localStorage (undefined without
// --localstorage-file), which shadows jsdom's; give the editor a working one.
const store = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, String(v)),
removeItem: (k: string) => void store.delete(k),
clear: () => store.clear(),
});
window.sessionStorage.clear();
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
const ENTRY = '---\ntitle: "Trip"\n---\n\nHello\n';
async function renderJournal() {
stubFetch({ entry: { slug: 'trip', rawMarkdown: ENTRY }, version: 'v1' }, (sent, written) => ({
success: true,
entry: { slug: 'trip', rawMarkdown: written ?? sent.rawMarkdown },
version: 'v2',
}));
render(
<NotificationProvider>
<ConfirmProvider>
<JournalEditor slug="trip" onBack={() => {}} />
</ConfirmProvider>
</NotificationProvider>,
);
fireEvent.click(await screen.findByRole('button', { name: 'Raw Markdown' }));
return screen.getByPlaceholderText('Write Markdown here...') as HTMLTextAreaElement;
}
describe('JournalEditor: edits made while a save is in flight', () => {
it('stay unsaved and saveable', async () => {
const editor = await renderJournal();
fireEvent.change(editor, { target: { value: `${ENTRY}First\n` } });
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
await waitFor(() => expect(releasePut).not.toBeNull());
fireEvent.change(editor, { target: { value: `${ENTRY}First\nSecond\n` } });
await act(async () => releasePut!());
await waitFor(() => expect(screen.queryByRole('button', { name: 'Saving...' })).toBeNull());
expect(editor.value).toBe(`${ENTRY}First\nSecond\n`);
const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement;
expect(save.disabled).toBe(false);
// Still kept as a draft, so leaving the editor does not lose it.
expect(window.sessionStorage.getItem('folio_draft_journal-trip')).toContain('Second');
});
it('are not replaced by the file as written', async () => {
const editor = await renderJournal();
fireEvent.change(editor, { target: { value: `${ENTRY}First\n` } });
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
await waitFor(() => expect(releasePut).not.toBeNull());
fireEvent.change(editor, { target: { value: `${ENTRY}First\nSecond\n` } });
// The server rewrote what it was sent, as it does when it hashes a password.
await act(async () => releasePut!(`${ENTRY}First (as written)\n`));
await waitFor(() => expect(screen.queryByRole('button', { name: 'Saving...' })).toBeNull());
expect(editor.value).toBe(`${ENTRY}First\nSecond\n`);
});
it('a save with no edits meanwhile still ends clean, showing the file as written', async () => {
const editor = await renderJournal();
fireEvent.change(editor, { target: { value: `${ENTRY}First\n` } });
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
await waitFor(() => expect(releasePut).not.toBeNull());
await act(async () => releasePut!(`${ENTRY}First (as written)\n`));
await screen.findByRole('button', { name: /Saved/ });
expect(editor.value).toBe(`${ENTRY}First (as written)\n`);
expect(window.sessionStorage.getItem('folio_draft_journal-trip')).toBeNull();
});
});
describe('PrivacyEditor: edits made while a save is in flight', () => {
it('stay unsaved and saveable', async () => {
stubFetch(
{ body: 'Old', enabled: true, facts: [], starter: '## Controller', version: 'v1' },
() => ({ success: true, version: 'v2' }),
);
render(
<NotificationProvider>
<ConfirmProvider>
<PrivacyEditor />
</ConfirmProvider>
</NotificationProvider>,
);
const editor = (await screen.findByLabelText(
'Privacy policy (Markdown)',
)) as HTMLTextAreaElement;
fireEvent.change(editor, { target: { value: 'First' } });
fireEvent.click(screen.getByRole('button', { name: 'Save privacy policy' }));
await waitFor(() => expect(releasePut).not.toBeNull());
fireEvent.change(editor, { target: { value: 'First and second' } });
await act(async () => releasePut!());
await screen.findByText('Privacy policy saved.');
const save = screen.getByRole('button', { name: 'Save privacy policy' }) as HTMLButtonElement;
expect(save.disabled).toBe(false);
expect(putBodies).toEqual([{ body: 'First' }]);
});
});
describe('SettingsEditor: edits made while a save is in flight', () => {
it('stay unsaved and saveable', async () => {
stubFetch({ settings: { title: 'Old' }, version: 'v1', envLocks: {} }, () => ({
success: true,
version: 'v2',
}));
render(
<NotificationProvider>
<ConfirmProvider>
<SettingsEditor />
</ConfirmProvider>
</NotificationProvider>,
);
const title = (await screen.findByLabelText('Site Title')) as HTMLInputElement;
fireEvent.change(title, { target: { value: 'First' } });
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
await waitFor(() => expect(releasePut).not.toBeNull());
fireEvent.change(title, { target: { value: 'First and second' } });
await act(async () => releasePut!());
await screen.findByText('Saved!');
expect(title.value).toBe('First and second');
const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement;
expect(save.disabled).toBe(false);
expect(window.sessionStorage.getItem('folio_draft_settings')).toContain('First and second');
});
});
describe('About editor: edits made while a save is in flight', () => {
it('stay unsaved and saveable', async () => {
nav.section = 'about';
stubFetch(
(url: string) =>
url === '/api/admin/about'
? { meta: { name: 'Ada' }, body: 'Old', version: 'v1' }
: { settings: {}, version: 'v1', envLocks: {} },
() => ({ success: true, version: 'v2' }),
);
render(
<NotificationProvider>
<ConfirmProvider>
<SettingsEditor />
</ConfirmProvider>
</NotificationProvider>,
);
const bio = (await screen.findByLabelText('Biography (Markdown)')) as HTMLTextAreaElement;
fireEvent.change(bio, { target: { value: 'First' } });
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
await waitFor(() => expect(releasePut).not.toBeNull());
fireEvent.change(bio, { target: { value: 'First and second' } });
await act(async () => releasePut!());
await screen.findByText('Saved!');
expect(bio.value).toBe('First and second');
const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement;
expect(save.disabled).toBe(false);
expect(window.sessionStorage.getItem('folio_draft_about')).toContain('First and second');
});
});
describe('PageBuilder: edits made while a save is in flight', () => {
it('stay unsaved and saveable, and are not replaced by the file as written', async () => {
stubFetch(
(url: string) =>
url === '/api/admin/gallery'
? { gallery: { hero: [], albums: [], subpages: [] }, version: 'v1' }
: url === '/api/admin/albums'
? { albums: [] }
: { pages: [], taken: { subpages: [], albums: [], journal: [] } },
// As written: hashed passwords make the server copy differ from the one sent.
(sent) => ({
success: true,
version: 'v2',
gallery: { ...(sent.gallery as object), hero: ['written-by-server'] },
}),
);
render(
<NotificationProvider>
<ConfirmProvider>
<PageBuilder />
</ConfirmProvider>
</NotificationProvider>,
);
fireEvent.click(await screen.findByRole('button', { name: 'New subpage' }));
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
await waitFor(() => expect(releasePut).not.toBeNull());
fireEvent.click(screen.getByRole('button', { name: 'New subpage' }));
await act(async () => releasePut!());
await screen.findByText('Saved successfully!');
const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement;
expect(save.disabled).toBe(false);
const kept = JSON.parse(window.sessionStorage.getItem('folio_draft_page-builder') ?? 'null');
expect(kept?.value?.subpages).toHaveLength(2);
expect((putBodies[0] as { gallery: { subpages: unknown[] } }).gallery.subpages).toHaveLength(1);
});
});

View File

@@ -0,0 +1,216 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { useState } from 'react';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import { JournalEditor } from '../journal/JournalEditor';
import { EssayBlockEditor } from '../EssayBlockEditor';
import { InlineMarkdownField } from '../InlineMarkdownField';
import { NotificationProvider } from '../Notifications';
import { ConfirmProvider } from '../ConfirmDialog';
/**
* Text blocks are edited as markdown, not as the HTML the parser stores (A-5).
* A reloaded entry used to show `<strong>` and `<a href=…>` in the textarea,
* text typed into a tag was lost on save, and typed `**bold**` stayed literal
* in the live preview until the first save.
*/
const ID = '3f1c9a52-8d4e-4b7a-9c1d-0e2f3a4b5c6d';
const ENTRY = [
'---',
'title: "Trip"',
'---',
'',
'Hello **bold** and [Immich](https://immich.app).',
'',
'> A *quoted* line -- Someone',
'',
`![${ID}:wide](A **nice** view)`,
'',
'::facts',
'Distance: **21** km',
].join('\n');
beforeEach(() => {
// The live preview's FadeIn reveals at once under reduced motion instead of
// needing IntersectionObserver.
window.matchMedia = ((query: string) => ({
matches: query.includes('reduce'),
media: query,
addEventListener: () => {},
removeEventListener: () => {},
})) as unknown as typeof window.matchMedia;
vi.stubGlobal(
'fetch',
vi.fn(async (_url: string, init?: RequestInit) => {
if (init?.method === 'PUT') {
const sent = JSON.parse(init.body as string) as { rawMarkdown: string };
return new Response(
JSON.stringify({
success: true,
entry: { slug: 'trip', rawMarkdown: sent.rawMarkdown },
version: 'v2',
}),
{ status: 200 },
);
}
return new Response(
JSON.stringify({ entry: { slug: 'trip', rawMarkdown: ENTRY }, version: 'v1' }),
{ status: 200 },
);
}),
);
// Node 26 defines its own global localStorage (undefined without
// --localstorage-file), which shadows jsdom's; give the editor a working one.
const store = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, String(v)),
removeItem: (k: string) => void store.delete(k),
clear: () => store.clear(),
});
window.sessionStorage.clear();
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
async function renderJournal() {
render(
<NotificationProvider>
<ConfirmProvider>
<JournalEditor slug="trip" onBack={() => {}} />
</ConfirmProvider>
</NotificationProvider>,
);
return (await screen.findByLabelText('Text')) as HTMLTextAreaElement;
}
/** The entry as the editor would save it, read from the Raw Markdown view. */
function rawMarkdown(): string {
fireEvent.click(screen.getByRole('button', { name: 'Raw Markdown' }));
const value = (screen.getByPlaceholderText('Write Markdown here...') as HTMLTextAreaElement)
.value;
fireEvent.click(screen.getByRole('button', { name: 'Visual Blocks' }));
return value;
}
describe('journal editor: text blocks as markdown (A-5)', () => {
it('shows the markdown source, not the stored HTML, after a load', async () => {
const text = await renderJournal();
expect(text.value).toBe('Hello **bold** and [Immich](https://immich.app).');
expect(screen.getByDisplayValue('A *quoted* line')).toBeTruthy();
expect(screen.getByDisplayValue('A **nice** view')).toBeTruthy();
expect(screen.getByDisplayValue('**21** km')).toBeTruthy();
expect(document.body.querySelector('textarea, input')).toBeTruthy();
for (const field of document.body.querySelectorAll('textarea, input')) {
expect((field as HTMLInputElement).value).not.toMatch(/<(strong|em|a)\b/);
}
});
it('renders typed markdown in the live preview before any save', async () => {
const text = await renderJournal();
fireEvent.change(text, { target: { value: 'Now **strong** text.' } });
const preview = document.querySelector('.journal-preview-frame')!;
expect(preview.innerHTML).toContain('<strong>strong</strong>');
expect(preview.textContent).not.toContain('**strong**');
// What was typed stays as typed.
expect(text.value).toBe('Now **strong** text.');
});
it('keeps text typed inside a link label or bold run', async () => {
const text = await renderJournal();
fireEvent.change(text, {
target: { value: 'Hello **bold and more** and [Immich Photos](https://immich.app).' },
});
expect(rawMarkdown()).toContain(
'Hello **bold and more** and [Immich Photos](https://immich.app).',
);
});
it('writes the same file when the content is unchanged', async () => {
const text = await renderJournal();
const original = text.value;
fireEvent.change(text, { target: { value: `${original} extra` } });
fireEvent.change(screen.getByLabelText('Text'), { target: { value: original } });
expect(rawMarkdown()).toBe(ENTRY);
});
it('keeps `__` as typed while the file gets the canonical form', async () => {
const text = await renderJournal();
fireEvent.change(text, { target: { value: 'An __underscored__ word' } });
expect(text.value).toBe('An __underscored__ word');
expect(rawMarkdown()).toContain('An **underscored** word');
});
});
describe('journal editor: mode toggles (A-18)', () => {
// Rendered as admin-btn-xs, the active mode carried admin-btn-primary, whose
// background the size class overrode: both modes looked the same.
it('mark the active mode, starting on Visual Blocks and Desktop', async () => {
await renderJournal();
const pressed = (name: string) =>
screen.getByRole('button', { name }).getAttribute('aria-pressed');
expect(pressed('Visual Blocks')).toBe('true');
expect(pressed('Raw Markdown')).toBe('false');
expect(pressed('Desktop')).toBe('true');
expect(pressed('Mobile')).toBe('false');
fireEvent.click(screen.getByRole('button', { name: 'Raw Markdown' }));
expect(pressed('Visual Blocks')).toBe('false');
expect(pressed('Raw Markdown')).toBe('true');
expect(screen.getByRole('button', { name: 'Raw Markdown' }).className).toContain('active');
});
});
describe('InlineMarkdownField', () => {
function Harness({ initial }: { initial: string }) {
const [html, setHtml] = useState(initial);
return (
<>
<InlineMarkdownField aria-label="Field" html={html} onChange={setHtml} />
<button type="button" onClick={() => setHtml('<em>replaced</em>')}>
Replace
</button>
<output data-testid="html">{html}</output>
</>
);
}
it('hands back HTML and re-reads it only on an outside change', () => {
render(<Harness initial="<strong>x</strong>" />);
const field = screen.getByLabelText('Field') as HTMLInputElement;
expect(field.value).toBe('**x**');
fireEvent.change(field, { target: { value: 'a [half](http' } });
expect(field.value).toBe('a [half](http');
fireEvent.change(field, { target: { value: 'a & b <c>' } });
expect(screen.getByTestId('html').textContent).toBe('a &amp; b &lt;c&gt;');
expect(field.value).toBe('a & b <c>');
fireEvent.click(screen.getByRole('button', { name: 'Replace' }));
expect(field.value).toBe('*replaced*');
});
});
describe('essay block editor: text blocks as markdown (A-5)', () => {
it('shows markdown and serializes an edit through the renderer', () => {
const onChange = vi.fn();
render(
<EssayBlockEditor
markdown={'Some *italic* and [a link](https://immich.app).'}
onChange={onChange}
/>,
);
const text = screen.getByDisplayValue(
'Some *italic* and [a link](https://immich.app).',
) as HTMLTextAreaElement;
fireEvent.change(text, {
target: { value: 'Some *italic* and [a longer link](https://immich.app).' },
});
expect(onChange).toHaveBeenLastCalledWith(
'Some *italic* and [a longer link](https://immich.app).',
);
});
});

View File

@@ -0,0 +1,74 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import SettingsEditor from '../SettingsEditor';
import { NotificationProvider } from '../Notifications';
import { ConfirmProvider } from '../ConfirmDialog';
const nav = vi.hoisted(() => ({ section: 'grid' }));
vi.mock('next/navigation', () => ({
useRouter: () => ({ refresh: () => {}, push: () => {}, replace: () => {} }),
useParams: () => ({ section: nav.section }),
usePathname: () => `/admin/settings/${nav.section}`,
}));
/**
* A value the site would ignore is named next to its input as it is typed,
* not only after a refused save (QA A-14).
*/
beforeEach(() => {
window.sessionStorage.clear();
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
new Response(
JSON.stringify({ settings: { title: 'Folio' }, version: 'v1', envLocks: {} }),
{
status: 200,
},
),
),
);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
function mount() {
return render(
<NotificationProvider>
<ConfirmProvider>
<SettingsEditor />
</ConfirmProvider>
</NotificationProvider>,
);
}
describe('Settings form: values the site would ignore', () => {
it('marks grid columns outside 1–6 at the input', async () => {
nav.section = 'grid';
mount();
const columns = (await screen.findByLabelText(/Columns/)) as HTMLInputElement;
expect(columns.getAttribute('aria-invalid')).toBeNull();
fireEvent.change(columns, { target: { value: '9' } });
expect(columns.getAttribute('aria-invalid')).toBe('true');
const error = document.getElementById(columns.getAttribute('aria-describedby')!);
expect(error?.textContent).toBe('Must be a whole number from 1 to 6.');
fireEvent.change(columns, { target: { value: '4' } });
expect(columns.getAttribute('aria-invalid')).toBeNull();
});
it('marks an accent that is not hex', async () => {
nav.section = 'theme';
mount();
const accent = (await screen.findByLabelText('Accent colour (hex)')) as HTMLInputElement;
fireEvent.change(accent, { target: { value: 'rot' } });
expect(accent.getAttribute('aria-invalid')).toBe('true');
expect(screen.getByText('Must be a hex colour such as #e60012.')).toBeTruthy();
});
});

View File

@@ -0,0 +1,67 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import GeneralSection from '../settings/GeneralSection';
import ZoomOverrideField from '../fields/ZoomOverrideField';
/**
* The admin switches for the lightbox zoom (#467): one site-wide toggle in
* Settings › General, and an inherit / on / off choice per page and album.
* The YAML they produce is covered by the page builder round-trip tests.
*/
afterEach(cleanup);
describe('Settings › General › Photo zoom', () => {
const renderWith = (settings: Record<string, unknown>) => {
const update = vi.fn();
render(<GeneralSection settings={settings} update={update} updateMany={vi.fn()} />);
return { update, row: screen.getByRole('button', { name: /Photo zoom/ }) };
};
it('is shown off when settings.yaml says nothing, and switches it on', () => {
const { update, row } = renderWith({});
expect(row.getAttribute('aria-pressed')).toBe('false');
fireEvent.click(row);
expect(update).toHaveBeenCalledWith('zoom', true);
});
it('switches it off again', () => {
const { update, row } = renderWith({ zoom: true });
expect(row.getAttribute('aria-pressed')).toBe('true');
fireEvent.click(row);
expect(update).toHaveBeenCalledWith('zoom', false);
});
});
describe('ZoomOverrideField', () => {
const renderField = (value: boolean | undefined) => {
const onChange = vi.fn();
render(
<ZoomOverrideField
id="z"
value={value}
onChange={onChange}
inheritLabel="Inherit"
hint="hint"
/>,
);
return { onChange, select: screen.getByLabelText('Photo zoom') as HTMLSelectElement };
};
it('shows the three states', () => {
expect(renderField(undefined).select.value).toBe('');
cleanup();
expect(renderField(true).select.value).toBe('on');
cleanup();
expect(renderField(false).select.value).toBe('off');
});
it('reports true, false, and undefined for inherit', () => {
const { onChange, select } = renderField(undefined);
fireEvent.change(select, { target: { value: 'on' } });
fireEvent.change(select, { target: { value: 'off' } });
fireEvent.change(select, { target: { value: '' } });
expect(onChange.mock.calls).toEqual([[true], [false], [undefined]]);
});
});

View File

@@ -0,0 +1,24 @@
/**
* The days the analytics chart shows: the last `count` calendar days up to
* `today`, each one present, with 0 for a day nobody visited.
*
* The chart used to take the last 14 *recorded* days, so a quiet week simply
* vanished and "Last 14 Days" could span a month (QA A-16).
*
* Keys are UTC dates (`YYYY-MM-DD`), the way /api/analytics/track writes them,
* so `today` is read in UTC too — a local date would be a day off either side
* of midnight.
*/
export function lastDays(
days: Record<string, { pageviews?: number } | undefined>,
count: number,
today: Date,
): Array<{ date: string; pageviews: number }> {
const end = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate());
const out: Array<{ date: string; pageviews: number }> = [];
for (let i = count - 1; i >= 0; i--) {
const date = new Date(end - i * 86_400_000).toISOString().slice(0, 10);
out.push({ date, pageviews: days[date]?.pageviews ?? 0 });
}
return out;
}

View File

@@ -0,0 +1,40 @@
'use client';
import { useEffect, useRef } from 'react';
import type { BackupTarget } from '@/app/api/admin/backups/route';
/**
* A backup restore rewrote a content file behind the editors' backs.
*
* The page builder, settings and journal editors stay mounted in their
* layouts, holding what they loaded before the restore. Left alone, the next
* save wrote that older state straight over the restored file. So the backup
* manager announces the restore and each editor reloads the file it owns —
* through its draft, so unsaved edits come back as a `conflict` the operator
* decides on instead of vanishing or winning silently.
*
* Same pattern as SESSION_EXPIRED_EVENT in sessionExpiry.ts.
*/
export const CONTENT_RESTORED_EVENT = 'folio:admin-content-restored';
export interface ContentRestored {
target: BackupTarget;
/** The journal entry, for `journal`. */
slug?: string;
}
export function reportContentRestored(detail: ContentRestored): void {
window.dispatchEvent(new CustomEvent<ContentRestored>(CONTENT_RESTORED_EVENT, { detail }));
}
export function useContentRestored(handler: (detail: ContentRestored) => void): void {
const handlerRef = useRef(handler);
useEffect(() => {
handlerRef.current = handler;
});
useEffect(() => {
const onRestored = (e: Event) => handlerRef.current((e as CustomEvent<ContentRestored>).detail);
window.addEventListener(CONTENT_RESTORED_EVENT, onRestored);
return () => window.removeEventListener(CONTENT_RESTORED_EVENT, onRestored);
}, []);
}

View File

@@ -0,0 +1,28 @@
import type { Announcements, UniqueIdentifier } from '@dnd-kit/core';
/**
* Screen reader announcements for a sortable list, in words (QA A-20).
*
* dnd-kit's defaults read out the sortable ids — "Picked up draggable item
* subpage-3. Draggable item page-pricing was moved over droppable area
* subpage-1." Those ids are internal. `label` turns one into what the list
* shows, e.g. `page "Pricing"`.
*/
export function dndAnnouncements(label: (id: UniqueIdentifier) => string): Announcements {
return {
onDragStart: ({ active }) => `Picked up ${label(active.id)}.`,
onDragOver: ({ active, over }) =>
over
? `${capitalise(label(active.id))} is at the position of ${label(over.id)}.`
: `${capitalise(label(active.id))} is no longer over the list.`,
onDragEnd: ({ active, over }) =>
over
? `${capitalise(label(active.id))} was dropped at the position of ${label(over.id)}.`
: `${capitalise(label(active.id))} was dropped.`,
onDragCancel: ({ active }) => `Moving ${label(active.id)} was cancelled.`,
};
}
function capitalise(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1);
}

View File

@@ -0,0 +1,20 @@
import { IconLock } from '../Icons';
/**
* The note under a field an environment variable overrides (#605). The field
* itself is rendered disabled by its section; this names the variable, so the
* operator knows where the value actually lives. Never shows the value.
*/
export default function EnvLockNote({ id, variable }: { id?: string; variable: string }) {
return (
<p id={id} className="admin-env-lock">
<span className="admin-env-lock-pill">
<IconLock size={11} /> Locked
</span>
<span>
Set by <code>{variable}</code> in the environment. Change or unset it there; edits here
would have no effect.
</span>
</p>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import type { ReactNode } from 'react';
import { useId, type ReactNode } from 'react';
type GridOverride = { columns?: number; gap?: number };
@@ -50,16 +50,20 @@ export default function GridOverrideFields({
});
};
const labelId = useId();
return (
<div className="admin-field" style={{ marginTop: '1rem' }}>
<label>{label}</label>
<div style={{ display: 'flex', gap: '12px' }}>
<span className="admin-field-label" id={labelId}>
{label}
</span>
<div style={{ display: 'flex', gap: '12px' }} role="group" aria-labelledby={labelId}>
<input
type="number"
min={columnsMin}
max={columnsMax}
value={value?.columns ?? ''}
placeholder="Columns (site default)"
aria-label="Columns"
onChange={(e) => patch('columns', e.target.value, columnsMin, columnsMax)}
style={NUMBER_INPUT_STYLE}
/>
@@ -69,6 +73,7 @@ export default function GridOverrideFields({
max={gapMax}
value={value?.gap ?? ''}
placeholder="Gap in px (theme default)"
aria-label="Gap in px"
onChange={(e) => patch('gap', e.target.value, 0, gapMax)}
style={NUMBER_INPUT_STYLE}
/>

View File

@@ -1,6 +1,6 @@
'use client';
import type { CSSProperties, ReactNode } from 'react';
import { useId, type CSSProperties, type ReactNode } from 'react';
export type CardOption = { value: string; label: string; desc: string };
@@ -55,10 +55,14 @@ export default function OptionGrid({
renderSpecs?: (option: CardOption) => ReactNode;
cardStyle?: (option: CardOption) => CSSProperties | undefined;
}) {
// A group of buttons, not one control, so it is named as a group (#694 §4).
const labelId = useId();
return (
<div className="admin-field">
<label>{label}</label>
<div className="preset-card-grid">
<span className="admin-field-label" id={labelId}>
{label}
</span>
<div className="preset-card-grid" role="group" aria-labelledby={labelId}>
{options.map((option) => {
const isActive = value === option.value;
return (

View File

@@ -0,0 +1,120 @@
'use client';
import { useId, useState } from 'react';
import { IconEye, IconEyeOff, IconLock } from '../Icons';
/**
* One password input for every place the admin sets one: subpage, album,
* journal entry and site password (#690).
*
* The save routes store passwords as scrypt hashes (lib/admin/passwordHashing.ts),
* so a saved password comes back as `scrypt:…`. That value is never put into
* the input: showing it would invite editing a hash, and saving an edited hash
* would lock the page with the hash string. Instead the field reads
* "Protected", with Change and Remove.
*
* A plaintext value from a hand-written file is shown in the input as before;
* the next save stores it hashed.
*/
export default function PasswordField({
value,
onChange,
placeholder = 'Leave empty for public access',
disabled,
label,
id: idProp,
}: {
value: string | undefined;
/** `undefined` removes the password. */
onChange: (value: string | undefined) => void;
placeholder?: string;
disabled?: boolean;
/** Accessible name when there is no visible <label> for the field. */
label?: string;
/** For a visible <label htmlFor>; generated otherwise. */
id?: string;
}) {
const generatedId = useId();
const id = idProp ?? generatedId;
/** The stored hash while it is being changed, so "Keep" can put it back. */
const [original, setOriginal] = useState<string | null>(null);
const [revealed, setRevealed] = useState(false);
const isHash = !!value && value.startsWith('scrypt:');
// Derived rather than stored: a save that brings back a new hash ends the
// change without the field having to be told.
const changing = original !== null && (value === original || !isHash);
if (isHash && !changing) {
return (
<div className="password-field password-field--set">
<span className="password-field__status">
<IconLock size={12} /> Protected
</span>
<button
type="button"
className="admin-btn admin-btn-ghost admin-btn-sm"
// The hash stays in the form until something is typed, so opening
// the field and saving is not a way to lose the password.
onClick={() => setOriginal(value)}
disabled={disabled}
>
Change
</button>
<button
type="button"
className="admin-btn admin-btn-ghost admin-btn-sm"
onClick={() => onChange(undefined)}
disabled={disabled}
>
Remove
</button>
</div>
);
}
return (
<div className="password-field">
<div className="password-input-wrapper">
<span className="password-icon" aria-hidden="true">
<IconLock size={12} />
</span>
<input
id={id}
type={revealed ? 'text' : 'password'}
value={changing && value === original ? '' : (value ?? '')}
// Emptying the field while changing falls back to the stored hash;
// only Remove takes a password away.
onChange={(e) => onChange(e.target.value || (changing ? original! : undefined))}
placeholder={changing ? 'New password' : placeholder}
aria-label={label}
autoComplete="new-password"
disabled={disabled}
autoFocus={changing}
/>
<button
type="button"
className="password-field__reveal"
onClick={() => setRevealed((r) => !r)}
aria-label={revealed ? 'Hide password' : 'Show password'}
aria-pressed={revealed}
aria-controls={id}
disabled={disabled}
>
{revealed ? <IconEyeOff size={14} /> : <IconEye size={14} />}
</button>
</div>
{changing && (
<button
type="button"
className="admin-btn admin-btn-ghost admin-btn-sm password-field__cancel"
onClick={() => {
onChange(original);
setOriginal(null);
}}
>
Keep current password
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,46 @@
'use client';
import type { ReactNode } from 'react';
/**
* Lightbox zoom for one page or album (#467): inherit, on, or off.
*
* Three states rather than a switch, because `zoom: false` is a setting of its
* own — it switches zoom off here while the site (or the page) has it on —
* and a switch can only say true or absent. A select, like the layout
* override beside it, with inheriting as the empty value, so the YAML keeps no
* key until someone picks one.
*/
export default function ZoomOverrideField({
id,
value,
onChange,
inheritLabel,
hint,
}: {
id: string;
value: boolean | undefined;
onChange: (value: boolean | undefined) => void;
inheritLabel: string;
hint: ReactNode;
}) {
const current = value === true ? 'on' : value === false ? 'off' : '';
return (
<div className="admin-field">
<label htmlFor={id}>Photo zoom</label>
<select
id={id}
value={current}
onChange={(e) => {
const next = e.target.value;
onChange(next === 'on' ? true : next === 'off' ? false : undefined);
}}
>
<option value="">{inheritLabel}</option>
<option value="on">On — visitors can zoom to full resolution</option>
<option value="off">Off</option>
</select>
<span className="admin-field-hint">{hint}</span>
</div>
);
}

View File

@@ -13,12 +13,17 @@ import {
IconFolder,
IconX,
} from '../Icons';
import { isLegacyAssetRef } from './blockOps';
import { InlineMarkdownField } from '../InlineMarkdownField';
import { fillAssetSlots, isLegacyAssetRef } from './blockOps';
/** What the asset picker is opened for: its title, and where the pick goes. */
export interface AssetPickTarget {
title: string;
onSelect: (assetId: string) => void;
onSelect?: (assetId: string) => void;
/** Multi-select: several picks confirmed at once (#602). */
onSelectMany?: (assetIds: string[]) => void;
/** Most assets `onSelectMany` takes. */
max?: number;
}
interface BlockFieldsProps {
@@ -71,22 +76,26 @@ export function BlockFields({
)}
{block.type === 'paragraph' && (
<textarea
<InlineMarkdownField
multiline
aria-label="Text"
className="admin-input"
rows={3}
value={block.html}
onChange={(e) => onChange({ ...block, html: e.target.value })}
placeholder="Text (**bold**, *italic*, [link](https://…))"
html={block.html}
onChange={(html) => onChange({ ...block, html })}
/>
)}
{block.type === 'quote' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<textarea
<InlineMarkdownField
multiline
className="admin-input"
rows={2}
value={block.text}
html={block.text}
placeholder="Quote text..."
onChange={(e) => onChange({ ...block, text: e.target.value })}
onChange={(text) => onChange({ ...block, text })}
/>
<input
type="text"
@@ -107,12 +116,19 @@ export function BlockFields({
{block.type === 'photo' && (
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<div
{/* A button, not a clickable div, so the keyboard reaches it. */}
<button
type="button"
aria-label={block.assetId ? 'Change photo' : 'Pick a photo'}
style={{
width: '140px',
height: '96px',
flexShrink: 0,
background: 'rgba(0,0,0,0.3)',
border: 'none',
padding: 0,
color: 'inherit',
font: 'inherit',
borderRadius: '6px',
overflow: 'hidden',
display: 'flex',
@@ -131,13 +147,13 @@ export function BlockFields({
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/admin/thumbnail/${block.assetId}`}
alt="Thumb"
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<span style={{ fontSize: '0.8rem', opacity: 0.6 }}>+ Pick</span>
)}
</div>
</button>
<div
style={{
@@ -176,12 +192,11 @@ export function BlockFields({
Change Photo
</button>
</div>
<input
type="text"
<InlineMarkdownField
className="admin-input"
placeholder="Caption (optional)"
value={block.caption || ''}
onChange={(e) => onChange({ ...block, caption: e.target.value })}
html={block.caption}
onChange={(caption) => onChange({ ...block, caption })}
/>
</div>
</div>
@@ -205,10 +220,16 @@ export function BlockFields({
}}
>
{[0, 1].map((pIdx) => (
<div
<button
type="button"
key={pIdx}
aria-label={`${block.assetIds[pIdx] ? 'Change' : 'Pick'} photo ${pIdx + 1} of the pair`}
style={{
height: '90px',
width: '100%',
padding: 0,
color: 'inherit',
font: 'inherit',
background: 'rgba(0,0,0,0.3)',
borderRadius: '6px',
overflow: 'hidden',
@@ -233,21 +254,40 @@ export function BlockFields({
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/admin/thumbnail/${block.assetIds[pIdx]}`}
alt="Thumb"
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<span style={{ fontSize: '0.8rem', opacity: 0.6 }}>+ Pick Photo #{pIdx + 1}</span>
)}
</div>
</button>
))}
</div>
<input
type="text"
<div>
<button
type="button"
className="admin-btn admin-btn-xs"
onClick={() =>
onPickAsset({
title: 'Select Photos for Pair',
max: 2,
onSelectMany: (ids) => {
// Two picks replace the pair; a single pick fills a gap.
const base = ids.length >= 2 ? ['', ''] : [...block.assetIds];
const [a, b] = fillAssetSlots(base, ids, 2);
onChange({ ...block, assetIds: [a ?? '', b ?? ''] });
},
})
}
>
<IconCamera size={12} /> Pick both photos
</button>
</div>
<InlineMarkdownField
className="admin-input"
placeholder="Shared caption for pair (optional)"
value={block.caption || ''}
onChange={(e) => onChange({ ...block, caption: e.target.value })}
html={block.caption}
onChange={(caption) => onChange({ ...block, caption })}
/>
</div>
)}
@@ -316,16 +356,28 @@ export function BlockFields({
>
<IconPlus size={12} /> Add photo
</button>
<button
type="button"
className="admin-btn admin-btn-xs"
onClick={() =>
onPickAsset({
title: 'Select Photos for Grid',
onSelectMany: (ids) =>
onChange({ ...block, assetIds: fillAssetSlots(block.assetIds, ids) }),
})
}
>
<IconCamera size={12} /> Pick several
</button>
<span style={{ fontSize: '0.75rem', opacity: 0.6 }}>
Three or more photos, laid out in rows of three.
</span>
</div>
<input
type="text"
<InlineMarkdownField
className="admin-input"
placeholder="Shared caption for grid (optional)"
value={block.caption || ''}
onChange={(e) => onChange({ ...block, caption: e.target.value })}
html={block.caption}
onChange={(caption) => onChange({ ...block, caption })}
/>
</div>
)}
@@ -348,17 +400,14 @@ export function BlockFields({
})
}
/>
<input
type="text"
<InlineMarkdownField
className="admin-input"
placeholder="Value, e.g. 21 km"
value={item.value}
onChange={(e) =>
html={item.value}
onChange={(value) =>
onChange({
...block,
items: block.items.map((it, i) =>
i === fIdx ? { ...it, value: e.target.value } : it,
),
items: block.items.map((it, i) => (i === fIdx ? { ...it, value } : it)),
})
}
/>
@@ -467,17 +516,11 @@ export function BlockFields({
</select>
</label>
</div>
<input
type="text"
<InlineMarkdownField
className="admin-input"
placeholder="Caption for the set (optional)"
value={block.caption || ''}
onChange={(e) =>
onChange({
...block,
caption: e.target.value || undefined,
})
}
html={block.caption}
onChange={(caption) => onChange({ ...block, caption: caption || undefined })}
/>
<span style={{ fontSize: '0.75rem', opacity: 0.6 }}>
Photos follow the album&apos;s order (a manual order in the gallery comes first). Count
@@ -494,12 +537,11 @@ export function BlockFields({
enabled.
</p>
)}
<input
type="text"
<InlineMarkdownField
className="admin-input"
placeholder="Caption, e.g. Busan → Seoul (optional)"
value={block.caption || ''}
onChange={(e) => onChange({ ...block, caption: e.target.value })}
html={block.caption}
onChange={(caption) => onChange({ ...block, caption })}
/>
{block.items.map((item, mIdx) => {

View File

@@ -28,22 +28,37 @@ import type { AlbumAssetRef } from '@/lib/journalAlbum';
import { BlockBadge } from '../BlockBadge';
import { useUnsavedGuard } from '../useUnsavedGuard';
import { useDraft } from '../useDraft';
import { useLatest } from '../useLatest';
import DraftNotice from '../DraftNotice';
import { reportIfSessionExpired } from '../sessionExpiry';
import { useContentRestored } from '../contentRestored';
import { useVersionedSave } from '../useVersionedSave';
import './journal-studio.css';
import { BlockFields, type AssetPickTarget } from './BlockFields';
import { StorySettingsModal } from './StorySettingsModal';
import { PageSettingsPanel } from './PageSettingsPanel';
import { JournalPreview } from './JournalPreview';
import { createBlock, moveBlock } from './blockOps';
import { createBlock, createPhotoBlocks, moveBlock } from './blockOps';
import { useSplitPane, SPLIT_MIN, SPLIT_MAX } from './splitPane';
import { useNotify } from '../Notifications';
interface JournalEditorProps {
slug: string;
mapEnabled?: boolean;
onBack: () => void;
/**
* `page` edits a content page (#722) instead of a journal entry: its own
* API and live URL, no map block, a settings side panel instead of the
* story settings, and a Publish button that saves the Draft flag at once.
*/
kind?: 'journal' | 'page';
}
export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps) {
export function JournalEditor({ slug, mapEnabled, onBack, kind = 'journal' }: JournalEditorProps) {
const isPage = kind === 'page';
const apiUrl = isPage ? `/api/admin/pages/${slug}` : `/api/admin/journal/${slug}`;
const liveUrl = isPage ? `/${slug}` : `/journal/${slug}`;
const notify = useNotify();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
@@ -60,6 +75,8 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
} = useSplitPane();
const [rawMarkdown, setRawMarkdown] = useState('');
/** The markdown as last rendered: tells a finished save whether typing went on meanwhile. */
const latestMarkdown = useLatest(rawMarkdown);
const [parsed, setParsed] = useState<ParsedJournal>(() => ({
frontmatter: {},
blocks: [],
@@ -78,9 +95,18 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
// Unsaved edits survive leaving the editor — the entry list, a tab link,
// back, Reload (#592). The markdown is the whole entry; blocks and
// frontmatter are parsed from it. `serverMarkdown` is what a discard restores.
const draft = useDraft<string>(`journal-${slug}`, rawMarkdown, dirty);
const draft = useDraft<string>(`${isPage ? 'page' : 'journal'}-${slug}`, rawMarkdown, dirty);
const loadDraft = draft.load;
const serverMarkdown = useRef('');
/** The file as loaded, sent back on save so a change elsewhere is caught (#601). */
const versionRef = useRef<string | null>(null);
const versionedSave = useVersionedSave();
// Bumped when this entry is restored from a backup, to load it again.
const [reloadKey, setReloadKey] = useState(0);
useContentRestored(({ target, slug: restored }) => {
if (target === (isPage ? 'pages' : 'journal') && restored === slug) setReloadKey((k) => k + 1);
});
// Load entry
useEffect(() => {
@@ -88,7 +114,7 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
setLoading(true);
setLoadError(null);
try {
const res = await fetch(`/api/admin/journal/${slug}`);
const res = await fetch(apiUrl);
if (!res.ok) {
throw new Error(
res.status === 401
@@ -97,7 +123,8 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
);
}
const data = await res.json();
const md: string = data.entry.rawMarkdown;
versionRef.current = typeof data.version === 'string' ? data.version : null;
const md: string = (isPage ? data.page : data.entry).rawMarkdown;
serverMarkdown.current = md;
const restored = loadDraft(md);
setRawMarkdown(restored ?? md);
@@ -113,7 +140,7 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
}
}
load();
}, [slug, loadDraft]);
}, [slug, apiUrl, isPage, loadDraft, reloadKey]);
// Update markdown and sync blocks
const handleMarkdownChange = (newMd: string) => {
@@ -150,31 +177,61 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
setDirty(true);
};
// Save
const handleSave = async () => {
// Save. `override` is markdown to save instead of the editor's — the page
// editor's Publish button changes the Draft flag and saves in one go.
const handleSave = async (override?: string) => {
// The editor is not rendered in this state, but Cmd+S still reaches here.
if (loadError) return;
// Nothing to save: every save rotates a backup, so repeated Cmd+S on an
// unchanged entry pushed real history out of the ten kept per file.
if ((!dirty && override === undefined) || saving) return;
const toSave = override ?? rawMarkdown;
setSaving(true);
try {
const res = await fetch(`/api/admin/journal/${slug}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rawMarkdown,
}),
});
const result = await versionedSave(
apiUrl,
{ rawMarkdown: toSave },
versionRef,
isPage ? 'This page' : 'This entry',
);
if (res.ok) {
serverMarkdown.current = rawMarkdown;
draft.saved(rawMarkdown);
setDirty(false);
} else if (!reportIfSessionExpired(res)) {
const data = await res.json();
alert(data.error || 'Failed to save');
if (result.kind === 'reload') {
draft.discard();
setReloadKey((k) => k + 1);
} else if (result.kind === 'keep') {
notify('error', 'Not saved — it changed elsewhere. Your edits are still here.');
} else if (result.kind === 'saved') {
// The file as written, not as sent: a password line is hashed on the
// way to disk (#690). Taking the sent text as the base would make the
// next draft look like a conflicting edit from elsewhere.
const data = result.data as {
page?: { rawMarkdown?: unknown };
entry?: { rawMarkdown?: unknown };
};
const record = isPage ? data.page : data.entry;
const written: string =
typeof record?.rawMarkdown === 'string' ? record.rawMarkdown : toSave;
serverMarkdown.current = written;
// Edited while the request was out: those edits are not saved yet, so
// they stay dirty and are not replaced by the file as written.
const editedMeanwhile = latestMarkdown.current !== toSave;
draft.saved(written, editedMeanwhile);
if (!editedMeanwhile) {
if (written !== toSave) {
setRawMarkdown(written);
setParsed(parseJournalMarkdown(written));
}
setDirty(false);
}
} else if (!reportIfSessionExpired(result.res)) {
notify('error', result.data?.error || 'Failed to save');
}
} catch {
alert('Error saving entry');
notify(
'error',
`Could not save the ${isPage ? 'page' : 'entry'}. Check the connection and try again.`,
);
} finally {
setSaving(false);
}
@@ -256,6 +313,22 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
};
// Block manipulation
/** Page mode: publish or unpublish, saved at once. Same flag as the Draft pill. */
const handleTogglePublish = () => {
const updated: ParsedJournal = {
...parsed,
frontmatter: { ...parsed.frontmatter, draft: !parsed.frontmatter.draft },
};
const serialized = serializeJournalMarkdown(updated);
setParsed(updated);
setRawMarkdown(serialized);
// An edit like any other until the save lands: a failed or refused save
// must leave the flipped flag unsaved, not shown as "Saved" (a success
// clears it again).
setDirty(true);
handleSave(serialized);
};
const handleAddBlock = (type: JournalBlock['type']) => {
handleBlocksChange([...parsed.blocks, createBlock(type)]);
};
@@ -282,7 +355,7 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
if (loading) {
return (
<div style={{ padding: '4rem', textAlign: 'center', opacity: 0.6 }}>
Opening Journal Studio...
{isPage ? 'Opening page editor...' : 'Opening Journal Studio...'}
</div>
);
}
@@ -294,13 +367,13 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
if (loadError) {
return (
<div className="admin-error" role="alert">
<strong>This entry could not be loaded.</strong> {loadError}
<strong>This {isPage ? 'page' : 'entry'} could not be loaded.</strong> {loadError}
<p>
Nothing has been changed. Saving stays disabled until it loads, so an empty editor cannot
overwrite the entry.
overwrite it.
</p>
<button className="admin-btn admin-btn-secondary" onClick={onBack}>
Back to entries
{isPage ? 'Back to pages' : 'Back to entries'}
</button>
</div>
);
@@ -316,36 +389,38 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
className="admin-btn admin-btn-sm admin-btn-secondary"
onClick={onBack}
>
← All Entries
{isPage ? '← Pages' : '← All Entries'}
</button>
<input
type="text"
className="journal-editor-title-input"
value={parsed.frontmatter.title || ''}
placeholder="Story Title..."
placeholder={isPage ? 'Page title...' : 'Story Title...'}
aria-label={isPage ? 'Page title' : 'Story title'}
onChange={(e) => handleFrontmatterChange({ title: e.target.value })}
/>
{/* The admin's segmented control (QA A-18). As `admin-btn-xs` buttons the
active one never showed: `.admin-btn-xs` sets its own background
after `.admin-btn-primary`, so both modes looked identical. */}
<div
style={{
display: 'flex',
gap: '4px',
background: 'rgba(255,255,255,0.06)',
borderRadius: '6px',
padding: '2px',
}}
className="segmented-control segmented-control--sm"
role="group"
aria-label="Editor mode"
>
<button
type="button"
className={`admin-btn admin-btn-xs ${editorMode === 'blocks' ? 'admin-btn-primary' : ''}`}
className={`segment-btn ${editorMode === 'blocks' ? 'active' : ''}`}
aria-pressed={editorMode === 'blocks'}
onClick={() => setEditorMode('blocks')}
>
Visual Blocks
</button>
<button
type="button"
className={`admin-btn admin-btn-xs ${editorMode === 'markdown' ? 'admin-btn-primary' : ''}`}
className={`segment-btn ${editorMode === 'markdown' ? 'active' : ''}`}
aria-pressed={editorMode === 'markdown'}
onClick={() => setEditorMode('markdown')}
>
Raw Markdown
@@ -359,11 +434,27 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
className="admin-btn admin-btn-sm admin-btn-secondary"
onClick={() => setShowMetaModal(true)}
>
<IconGear size={14} /> Story Settings
<IconGear size={14} /> {isPage ? 'Page Settings' : 'Story Settings'}
</button>
{isPage && (
<button
type="button"
className="admin-btn admin-btn-sm admin-btn-secondary"
onClick={handleTogglePublish}
disabled={saving}
title={
parsed.frontmatter.draft
? 'Publish the page and save it now'
: 'Turn the page back into a draft and save it now'
}
>
{parsed.frontmatter.draft ? 'Publish' : 'Unpublish'}
</button>
)}
<a
href={`/journal/${slug}`}
href={liveUrl}
target="_blank"
rel="noopener noreferrer"
className="admin-btn admin-btn-sm admin-btn-secondary"
@@ -374,8 +465,8 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
<button
type="button"
className="admin-btn admin-btn-sm admin-btn-primary"
onClick={handleSave}
disabled={saving}
onClick={() => handleSave()}
disabled={saving || !dirty}
>
{saving ? (
'Saving...'
@@ -392,7 +483,7 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
<DraftNotice
status={draft.status}
subject="entry"
subject={isPage ? 'page' : 'entry'}
onDiscard={discardDraft}
onRestore={restoreConflictingDraft}
onDismiss={draft.dismiss}
@@ -454,6 +545,19 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
>
<IconCamera size={13} /> + Photo
</button>
<button
type="button"
className="admin-btn admin-btn-xs admin-btn-primary"
onClick={() =>
setAssetPickerTarget({
title: 'Add Photos (one block each)',
onSelectMany: (ids) =>
handleBlocksChange([...parsed.blocks, ...createPhotoBlocks(ids)]),
})
}
>
<IconCamera size={13} /> + Photos
</button>
<button
type="button"
className="admin-btn admin-btn-xs admin-btn-primary"
@@ -475,13 +579,16 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
>
<IconFolder size={13} /> + Album
</button>
<button
type="button"
className="admin-btn admin-btn-xs"
onClick={() => handleAddBlock('map')}
>
<IconMap size={13} /> + Map
</button>
{/* No map block on content pages in v1 (#722). */}
{!isPage && (
<button
type="button"
className="admin-btn admin-btn-xs"
onClick={() => handleAddBlock('map')}
>
<IconMap size={13} /> + Map
</button>
)}
</div>
{/* Blocks List */}
@@ -568,8 +675,17 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
<JournalPreview parsed={parsed} albumAssets={albumAssets} />
</div>
{/* Page settings: a side panel, so editing never means going back (#722). */}
{showMetaModal && isPage && (
<PageSettingsPanel
frontmatter={parsed.frontmatter}
onChange={handleFrontmatterChange}
onClose={() => setShowMetaModal(false)}
/>
)}
{/* Metadata Modal */}
{showMetaModal && (
{showMetaModal && !isPage && (
<StorySettingsModal
frontmatter={parsed.frontmatter}
onChange={handleFrontmatterChange}
@@ -595,10 +711,18 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
{assetPickerTarget && (
<AssetPicker
title={assetPickerTarget.title}
max={assetPickerTarget.max}
onSelect={(id) => {
assetPickerTarget.onSelect(id);
assetPickerTarget.onSelect?.(id);
setAssetPickerTarget(null);
}}
onSelectMany={
assetPickerTarget.onSelectMany &&
((ids) => {
assetPickerTarget.onSelectMany?.(ids);
setAssetPickerTarget(null);
})
}
onClose={() => setAssetPickerTarget(null)}
/>
)}

View File

@@ -92,17 +92,24 @@ export function JournalPreview({ parsed, albumAssets }: JournalPreviewProps) {
<span className="journal-preview-bar-label">
<IconEye size={13} /> Realtime Theme Preview
</span>
<div style={{ display: 'flex', gap: '4px' }}>
{/* Same control and same fix as the editor's mode toggle. */}
<div
className="segmented-control segmented-control--sm"
role="group"
aria-label="Preview width"
>
<button
type="button"
className={`admin-btn admin-btn-xs ${viewport === 'desktop' ? 'admin-btn-primary' : ''}`}
className={`segment-btn ${viewport === 'desktop' ? 'active' : ''}`}
aria-pressed={viewport === 'desktop'}
onClick={() => setViewport('desktop')}
>
Desktop
</button>
<button
type="button"
className={`admin-btn admin-btn-xs ${viewport === 'mobile' ? 'admin-btn-primary' : ''}`}
className={`segment-btn ${viewport === 'mobile' ? 'active' : ''}`}
aria-pressed={viewport === 'mobile'}
onClick={() => setViewport('mobile')}
>
Mobile

View File

@@ -1,9 +1,9 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { JournalEntrySummary } from '@/lib/journal';
import { serializeJournalMarkdown, sanitizeSlug } from '@/lib/journal';
import { serializeJournalMarkdown, sanitizeSlug, slugFieldValue } from '@/lib/journal';
import { JOURNAL_TEMPLATES } from '@/lib/journalTemplates';
import {
IconTrash,
@@ -16,6 +16,13 @@ import {
} from '../Icons';
import './journal-studio.css';
import { JournalEditor } from './JournalEditor';
import { useNotify } from '../Notifications';
import { useAdminFetch } from '../useAdminFetch';
import { useContentRestored } from '../contentRestored';
import AdminLoadState from '../AdminLoadState';
import PageHeader from '../PageHeader';
import { useConfirm } from '../ConfirmDialog';
import { useModalDialog } from '@/hooks/useModalDialog';
interface JournalStudioProps {
/** Entry to open, taken from the /admin/journal/[slug] route. */
@@ -25,10 +32,17 @@ interface JournalStudioProps {
}
export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioProps) {
const confirm = useConfirm();
const notify = useNotify();
const router = useRouter();
const [entries, setEntries] = useState<JournalEntrySummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const list = useAdminFetch<{ entries?: JournalEntrySummary[] }>('/api/admin/journal');
const entries = list.data?.entries ?? [];
const fetchEntries = list.reload;
// A restored entry may be one that was deleted, or have a new title.
useContentRestored(({ target }) => {
if (target === 'journal') fetchEntries();
});
// New Entry Modal
const [showCreateModal, setShowCreateModal] = useState(false);
@@ -37,28 +51,11 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
const [creating, setCreating] = useState(false);
/** null = start blank, matching the previous (only) behavior. */
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
const fetchEntries = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/admin/journal');
if (res.ok) {
const data = await res.json();
setEntries(data.entries || []);
} else {
setError('Failed to load journal entries');
}
} catch {
setError('Failed to load journal entries');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchEntries();
}, [fetchEntries]);
const closeCreateModal = () => {
setShowCreateModal(false);
setSelectedTemplateId(null);
};
const createCardRef = useModalDialog(closeCreateModal, showCreateModal);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
@@ -98,41 +95,43 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
setNewTitle('');
setNewSlug('');
setSelectedTemplateId(null);
await fetchEntries();
fetchEntries();
if (data.entry?.slug) {
router.push(`/admin/journal/${data.entry.slug}`);
}
} else {
const data = await res.json();
alert(data.error || 'Failed to create journal entry');
notify('error', data.error || 'Failed to create journal entry');
}
} catch {
alert('Error creating journal entry');
notify('error', 'Could not create the journal entry. Check the connection and try again.');
} finally {
setCreating(false);
}
};
const handleDelete = async (slug: string) => {
if (
!confirm(
`Delete "${slug}"?\n\nA copy is kept and can be restored from Backups on the dashboard.`,
)
)
return;
const handleDelete = async (slug: string, title?: string) => {
// Named by title, as the page delete is; the slug only when there is none.
const ok = await confirm({
title: `Delete “${title || slug}”?`,
message: 'A copy is kept and can be restored under Backups.',
confirmLabel: 'Delete entry',
danger: true,
});
if (!ok) return;
try {
const res = await fetch(`/api/admin/journal/${slug}`, {
method: 'DELETE',
});
if (res.ok) {
await fetchEntries();
fetchEntries();
if (activeSlug === slug) router.push('/admin/journal');
} else {
alert('Failed to delete journal entry');
notify('error', 'Failed to delete journal entry');
}
} catch {
alert('Error deleting journal entry');
notify('error', 'Could not delete the journal entry. Check the connection and try again.');
}
};
@@ -148,30 +147,28 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
return (
<div className="journal-studio">
<div className="journal-studio-header">
<div>
<h2>
<IconBook size={20} /> Journal &amp; Photo Essays
</h2>
<p style={{ margin: '4px 0 0', opacity: 0.7, fontSize: '0.9rem' }}>
Author visual stories, field notes, and longform photo essays with live preview.
</p>
</div>
<button
type="button"
className="admin-btn admin-btn-primary"
onClick={() => setShowCreateModal(true)}
>
<IconPlus size={16} /> New Journal Entry
</button>
</div>
<PageHeader
kicker="Content"
title="Journal"
description="Stories, field notes and longform photo essays, written with a live preview."
actions={
<button
type="button"
className="admin-btn admin-btn-primary"
onClick={() => setShowCreateModal(true)}
>
<IconPlus size={16} /> New Journal Entry
</button>
}
/>
{loading ? (
<div style={{ padding: '3rem', textAlign: 'center', opacity: 0.6 }}>
Loading journal entries...
</div>
) : error ? (
<div style={{ padding: '3rem', textAlign: 'center', color: '#ef4444' }}>{error}</div>
{(list.loading && !list.data) || list.error ? (
<AdminLoadState
loading={list.loading}
error={list.error}
onRetry={list.reload}
hasData={!!list.data}
/>
) : entries.length === 0 ? (
<div style={{ padding: '5rem 2rem', textAlign: 'center', opacity: 0.6 }}>
<h3>No journal entries yet</h3>
@@ -248,7 +245,7 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
<button
type="button"
className="admin-btn admin-btn-sm admin-btn-danger"
onClick={() => handleDelete(entry.slug)}
onClick={() => handleDelete(entry.slug, entry.frontmatter.title)}
title="Delete entry"
style={{ marginLeft: 'auto' }}
>
@@ -264,11 +261,20 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
{/* Create Modal */}
{showCreateModal && (
<div className="journal-modal-overlay">
<div className="journal-modal-card">
<h3 style={{ margin: '0 0 1rem' }}>Create New Journal Entry</h3>
<div
className="journal-modal-card"
ref={createCardRef}
role="dialog"
aria-modal="true"
aria-labelledby="journal-new-dialog-title"
>
<h3 id="journal-new-dialog-title" style={{ margin: '0 0 1rem' }}>
Create New Journal Entry
</h3>
<form onSubmit={handleCreate}>
<div style={{ marginBottom: '1rem' }}>
<label
htmlFor="journal-new-title"
style={{
display: 'block',
fontSize: '0.85rem',
@@ -279,6 +285,7 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
Title
</label>
<input
id="journal-new-title"
type="text"
className="admin-input"
placeholder="e.g. Expedition Nordkap"
@@ -296,6 +303,7 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
<div style={{ marginBottom: '1.5rem' }}>
<label
htmlFor="journal-new-url-slug"
style={{
display: 'block',
fontSize: '0.85rem',
@@ -306,17 +314,18 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
URL Slug
</label>
<input
id="journal-new-url-slug"
type="text"
className="admin-input"
placeholder="e.g. expedition-nordkap"
value={newSlug}
onChange={(e) => setNewSlug(sanitizeSlug(e.target.value))}
onChange={(e) => setNewSlug(slugFieldValue(e.target.value))}
required
/>
<span
style={{ fontSize: '0.75rem', opacity: 0.6, marginTop: '2px', display: 'block' }}
>
Will be accessible at /journal/{newSlug || 'slug'}
Will be accessible at /journal/{newSlug ? sanitizeSlug(newSlug) : 'slug'}
</span>
</div>
@@ -358,10 +367,7 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
<button
type="button"
className="admin-btn admin-btn-secondary"
onClick={() => {
setShowCreateModal(false);
setSelectedTemplateId(null);
}}
onClick={closeCreateModal}
>
Cancel
</button>

View File

@@ -0,0 +1,82 @@
'use client';
import { useEffect } from 'react';
import type { ParsedJournal } from '@/lib/journal';
import PasswordField from '../fields/PasswordField';
import { IconX } from '../Icons';
type Frontmatter = ParsedJournal['frontmatter'];
interface PageSettingsPanelProps {
frontmatter: Frontmatter;
onChange: (updates: Partial<Frontmatter>) => void;
onClose: () => void;
}
/**
* A content page's settings inside the editor (#722): a side panel rather than
* the journal's modal, so the preview stays in view. The slug and the menu
* place are edited in Pages, where the collision check and the menu live.
*/
export function PageSettingsPanel({ frontmatter, onChange, onClose }: PageSettingsPanelProps) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
return (
<aside className="page-settings-panel" aria-label="Page settings">
<div className="page-settings-panel-head">
<h3>Page settings</h3>
<button type="button" className="admin-btn-icon" onClick={onClose} title="Close">
<IconX size={14} />
</button>
</div>
<div className="admin-field">
<label htmlFor="page-settings-description">SEO description</label>
<textarea
id="page-settings-description"
rows={3}
value={frontmatter.description || ''}
placeholder="One or two sentences for search engines and link previews"
// One line in the frontmatter: a line break would end the value.
onChange={(e) => onChange({ description: e.target.value.replace(/\s*\n\s*/g, ' ') })}
/>
</div>
<button
type="button"
className="setting-row"
onClick={() => onChange({ draft: !frontmatter.draft })}
aria-pressed={!!frontmatter.draft}
>
<span className="setting-row-info">
<span className="setting-row-title">Draft</span>
<span className="setting-row-desc">
Only you can see a draft, and it stays out of the menu and the sitemap.
</span>
</span>
<span className={`switch-toggle switch-toggle--sm ${frontmatter.draft ? 'on' : ''}`}>
<span className="switch-slider" />
</span>
</button>
<div className="admin-field">
<span className="admin-field-label">Password (optional)</span>
<PasswordField
value={frontmatter.password || undefined}
onChange={(password) => onChange({ password: password ?? '' })}
label="Page password"
/>
</div>
<p className="page-settings-panel-note">
Settings are saved with the page. The URL and the menu place are set in Pages.
</p>
</aside>
);
}

Some files were not shown because too many files have changed in this diff Show More