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>
This commit is contained in:
Ralksta
2026-09-27 14:43:38 +02:00
committed by GitHub
parent eab3b9c257
commit 3193c500e4
155 changed files with 13188 additions and 3539 deletions

View File

@@ -34,6 +34,14 @@ STALE_MAX_AGE=86400
# refetch. Leave it unset in normal operation. # refetch. Leave it unset in normal operation.
# IMAGE_CACHE_VERSION=1 # IMAGE_CACHE_VERSION=1
# CDN mode: serve photos and videos through a pull CDN (default: off).
# Point a CDN (Cloudflare, Bunny, CloudFront, ...) at this server as its origin,
# cache /api/image/* and /api/video/* including the query string, and put its
# URL here. Pages, EXIF, the map and downloads keep coming from this server.
# Ignored while a site password is set, so the password keeps covering photos.
# Also set TRUSTED_PROXY_HOPS to count the CDN, see docs/deployment.md#cdn-mode.
# CDN_URL=https://cdn.example.com
# How long to wait for a response from Immich, in milliseconds # How long to wait for a response from Immich, in milliseconds
# (integer, minimum 1000, default: 15000 = 15 seconds). # (integer, minimum 1000, default: 15000 = 15 seconds).
# Bounds the wait for a *response* only — downloading a large photo or a long # Bounds the wait for a *response* only — downloading a large photo or a long

5
.gitignore vendored
View File

@@ -68,6 +68,7 @@ docker-compose.override.yml
content/settings.yaml content/settings.yaml
content/gallery.yaml content/gallery.yaml
content/about.md content/about.md
content/privacy.md
# Journal entries reference Immich asset UUIDs, which are specific to one # Journal entries reference Immich asset UUIDs, which are specific to one
# server — committing an entry would publish ids from the author's instance. # server — committing an entry would publish ids from the author's instance.
# The .example alongside them stays tracked as a starting point. # The .example alongside them stays tracked as a starting point.
@@ -86,6 +87,10 @@ content/*.screenshot-bak
content/install.json content/install.json
# One-time setup token, written on first access and removed once install completes # One-time setup token, written on first access and removed once install completes
content/.setup-token content/.setup-token
# Contact form messages (lib/contact.ts), personal data of visitors
content/messages/
# Google Fonts cached by /api/fonts (lib/fonts.ts)
content/.fonts/
# ── Internal Dev Docs ──────────────────────────────── # ── Internal Dev Docs ────────────────────────────────
docs/dev/ docs/dev/

View File

@@ -7,6 +7,273 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Releases up to and including v0.9.2 are documented in the Releases up to and including v0.9.2 are documented in the
[GitHub releases](https://github.com/ralksta/immich-folio/releases). [GitHub releases](https://github.com/ralksta/immich-folio/releases).
## [0.18.0] — 2026-09-27
### Security
- **Journal drafts and password-protected entries no longer render publicly
as a subpage essay**
([#684](https://github.com/ralksta/immich-folio/pull/684),
[GHSA-987w-cpwm-c9g9](https://github.com/ralksta/immich-folio/security/advisories/GHSA-987w-cpwm-c9g9),
medium). A subpage's `essayFile` read the journal entry straight from disk,
skipping the draft and password rules `/journal/<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.
## [0.17.0] — 2026-09-23 ## [0.17.0] — 2026-09-23
### Added ### Added

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. A self-hosted portfolio powered by [Immich](https://immich.app). It acts as a **secure reverse proxy** between your visitors and your private Immich instance: your Immich server stays on your local network, completely invisible to the outside world, while your albums are published as a gallery you control.
**Latest: v0.18.0** — built-in contact form, privacy page and a reworked admin panel. → [What's New](#whats-new)
## Contents ## Contents
- [Features](#features) - [Features](#features)
@@ -67,6 +69,7 @@ A self-hosted portfolio powered by [Immich](https://immich.app). It acts as a **
- **Client proofing** — clients favorite photos and export the selection; picks are encoded in the URL, nothing is stored server-side - **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 - **Originals delivery** — per-album opt-in lets visitors download a whole album or their proofing selection as a ZIP of the originals
- **Lightbox watermark** — configurable overlay on fullscreen images - **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 - **Privacy-friendly analytics** — cookieless view counts, no third parties, can be switched off
- **Dynamic OG images** — auto-generated social preview images per album - **Dynamic OG images** — auto-generated social preview images per album
@@ -114,6 +117,7 @@ A self-hosted portfolio powered by [Immich](https://immich.app). It acts as a **
- Health check endpoint at `GET /api/health` - Health check endpoint at `GET /api/health`
- In-memory caching with configurable TTL - 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 - Standalone Docker image — multi-stage, non-root, ~150 MB
- Dependencies kept current via Dependabot (npm + GitHub Actions, weekly) - Dependencies kept current via Dependabot (npm + GitHub Actions, weekly)
@@ -219,6 +223,8 @@ AUTH_SECRET=long-random-string # required in production
TRUSTED_PROXY_HOPS=1 # reverse proxies in front, default: 0 TRUSTED_PROXY_HOPS=1 # reverse proxies in front, default: 0
ADMIN_PASSWORD=your-secure-password # enables /admin panel ADMIN_PASSWORD=your-secure-password # enables /admin panel
WEBHOOK_SECRET=long-random-string # enables POST /api/webhook cache invalidation WEBHOOK_SECRET=long-random-string # enables POST /api/webhook cache invalidation
CDN_URL=https://cdn.example.com # serve photos and videos through a pull CDN
CONTACT_NOTIFY_URL=https://ntfy.sh/x # push for new contact form messages, overrides settings.yaml
``` ```
> Login and setup endpoints have their own, much lower limits that `RATE_LIMIT_RPM` does not raise — see [Rate Limiting](docs/gallery-config.md#rate-limiting). > Login and setup endpoints have their own, much lower limits that `RATE_LIMIT_RPM` does not raise — see [Rate Limiting](docs/gallery-config.md#rate-limiting).
@@ -336,15 +342,15 @@ own password, separate from any album passwords, and writes straight to
## What's New ## What's New
v0.17.0 — originals as a ZIP, and a richer journal: v0.18.0 — a contact form, a privacy page and a reworked admin:
- **Download originals as a ZIP** — a whole album, or just the photos a client picked in proofing, as one archive of the originals. Opt-in per album; contributed by [@lancetm714](https://github.com/lancetm714) - **Built-in contact form** — no SMTP or form service: messages land in a new admin inbox, with an optional ntfy push that carries nothing about the sender
- **Journal templates** — start a new entry from eight structures, from a wedding to a hiking trip - **Privacy page and fewer third parties** — `/privacy` with a list of what your installation actually processes; fonts and map styles are served from your own server, and email addresses stay out of the page source
- **New journal blocks** — photo grids, facts lists, a map with the pins you choose, and a slice of an album; reorder blocks by dragging → [Journal guide](docs/journal.md) - **A reworked admin panel** — a collapsible sidebar, an overview page, the page builder as a structure list with Undo, and ON/OFF switches grouped by meaning
- **Unsaved admin edits survive navigation** — switching tabs or reloading no longer throws your changes away - **Four more languages and a CDN mode** — French, Spanish, Italian and Dutch, and `CDN_URL` to serve photos through a pull CDN
- **Plain-HTTP installs** — admin login and gallery passwords now work at `http://host:7211` - **Two security fixes** — draft and password-protected journal entries used as subpage essays, and unescaped Immich descriptions ([advisories](https://github.com/ralksta/immich-folio/security/advisories))
**Upgrade note:** visitors' proofing favourites from before v0.17.0 are not carried over. Nothing else to migrate. **Upgrade note:** a custom CSP or proxy allowlist must now allow `tile.openstreetmap.org` for map tiles, and `theme.accent` must be a hex colour. Nothing else to migrate.
Security fixes ship in normal releases, so **running the latest release is the Security fixes ship in normal releases, so **running the latest release is the
recommended baseline**. recommended baseline**.
@@ -373,7 +379,14 @@ easier to live with.
readable in light mode. Also brought links to journal quote attributions. In 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 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 picked, as one archive of the originals — streamed, so even a large shoot
arrives complete. arrives complete. In v0.18.0, kept accent-coloured buttons readable when the
accent is light, and made a mistyped accent fall back to the theme instead of
turning every button invisible.
- **[@RichKidsDev](https://github.com/RichKidsDev)** — noticed that the
Impressum still cited the TMG, which the DDG replaced in 2024. That report
turned into a proper overhaul in v0.18.0: the right law by default, a heading
you can change for other countries, clickable contacts and a contact form as
a second channel.
- **[@ImScheinox](https://github.com/ImScheinox)** — found and fixed portrait - **[@ImScheinox](https://github.com/ImScheinox)** — found and fixed portrait
photos rendering as landscape tiles, because the grid ignored the camera's photos rendering as landscape tiles, because the grid ignored the camera's
EXIF orientation flag. That brought the masonry layout's stagger back for EXIF orientation flag. That brought the masonry layout's stagger back for

View File

@@ -9,6 +9,7 @@ import { FadeIn } from '@/components/FadeIn';
import { LeafletMap, escapeHtml } from '@/components/LeafletMap'; import { LeafletMap, escapeHtml } from '@/components/LeafletMap';
import type { ParsedEssay, EssayBlock } from '@/lib/essay'; import type { ParsedEssay, EssayBlock } from '@/lib/essay';
import { renderInlineMarkdown } from '@/lib/essay'; import { renderInlineMarkdown } from '@/lib/essay';
import { essayPhotoSequence } from '@/lib/essaySequence';
import type { PhotoItem } from './PhotoGrid'; import type { PhotoItem } from './PhotoGrid';
import './essay.css'; import './essay.css';
import { useDictionary } from '@/components/I18nProvider'; import { useDictionary } from '@/components/I18nProvider';
@@ -60,19 +61,35 @@ function EssayViewContent({
? assetMap.get(essay.frontmatter.coverAssetId) ? assetMap.get(essay.frontmatter.coverAssetId)
: undefined; : 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 * 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 * .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 * used to spell this out a second time next to the photo case; a third copy
* for the grid is where that stops. * 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; const isFav = proofing ? proofing.isFavorite(item.id) : false;
return ( return (
<div <div
key={key} key={key}
className="essay-image-wrapper photo-grid__item" className="essay-image-wrapper photo-grid__item"
onClick={() => setLightboxIndex(index)} onClick={() => openPhoto(item)}
style={{ style={{
...(item.dominantColor ? { backgroundColor: item.dominantColor } : {}), ...(item.dominantColor ? { backgroundColor: item.dominantColor } : {}),
flexGrow: item.aspectRatio ?? 1.5, flexGrow: item.aspectRatio ?? 1.5,
@@ -166,7 +183,7 @@ function EssayViewContent({
case 'photo': { case 'photo': {
const resolved = assetMap.get(block.assetId); const resolved = assetMap.get(block.assetId);
if (!resolved) return null; if (!resolved) return null;
const { item, index } = resolved; const { item } = resolved;
const isFav = proofing ? proofing.isFavorite(item.id) : false; const isFav = proofing ? proofing.isFavorite(item.id) : false;
return ( return (
@@ -174,7 +191,7 @@ function EssayViewContent({
<figure className={`essay-figure essay-figure--${block.layout}`}> <figure className={`essay-figure essay-figure--${block.layout}`}>
<div <div
className="essay-image-wrapper photo-grid__item" className="essay-image-wrapper photo-grid__item"
onClick={() => setLightboxIndex(index)} onClick={() => openPhoto(item)}
style={{ style={{
...(item.dominantColor ? { backgroundColor: item.dominantColor } : {}), ...(item.dominantColor ? { backgroundColor: item.dominantColor } : {}),
...(item.aspectRatio ? { aspectRatio: `${item.aspectRatio}` } : {}), ...(item.aspectRatio ? { aspectRatio: `${item.aspectRatio}` } : {}),
@@ -388,7 +405,7 @@ function EssayViewContent({
<figure className="essay-figure essay-figure--wide" style={{ marginBottom: '4rem' }}> <figure className="essay-figure essay-figure--wide" style={{ marginBottom: '4rem' }}>
<div <div
className="essay-image-wrapper photo-grid__item" className="essay-image-wrapper photo-grid__item"
onClick={() => setLightboxIndex(coverAsset.index)} onClick={() => openPhoto(coverAsset.item)}
style={{ style={{
...(coverAsset.item.dominantColor ...(coverAsset.item.dominantColor
? { backgroundColor: coverAsset.item.dominantColor } ? { backgroundColor: coverAsset.item.dominantColor }
@@ -434,26 +451,11 @@ function EssayViewContent({
backdropFilter: 'blur(8px)', backdropFilter: 'blur(8px)',
}} }}
> >
<button {/* A count, not the album grid's filter toggle: a story's layout is
type="button" authored, so there is nothing for "show favourites" to narrow. */}
onClick={() => proofing.setIsFilterActive((prev) => !prev)} <span style={{ fontSize: '0.85rem', fontWeight: 500, padding: '6px 4px' }}>
style={{ {t.proofing.selected(proofing.favorites.size)}
background: proofing.isFilterActive </span>
? '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>
<button <button
type="button" type="button"
onClick={() => proofing.setIsModalOpen(true)} onClick={() => proofing.setIsModalOpen(true)}
@@ -476,15 +478,15 @@ function EssayViewContent({
{lightboxIndex !== null && ( {lightboxIndex !== null && (
<Lightbox <Lightbox
assets={assets} assets={sequence}
currentIndex={lightboxIndex} currentIndex={lightboxIndex}
onClose={() => setLightboxIndex(null)} onClose={() => setLightboxIndex(null)}
onNext={() => onNext={() =>
setLightboxIndex((prev) => (prev !== null ? (prev + 1) % assets.length : null)) setLightboxIndex((prev) => (prev !== null ? (prev + 1) % sequence.length : null))
} }
onPrev={() => onPrev={() =>
setLightboxIndex((prev) => setLightboxIndex((prev) =>
prev !== null ? (prev - 1 + assets.length) % assets.length : null, prev !== null ? (prev - 1 + sequence.length) % sequence.length : null,
) )
} }
watermark={watermark} watermark={watermark}

View File

@@ -318,7 +318,7 @@ function PhotoGridInner({
background: proofing.isFilterActive background: proofing.isFilterActive
? 'var(--accent, #e60012)' ? 'var(--accent, #e60012)'
: 'var(--bg-card-hover)', : 'var(--bg-card-hover)',
color: proofing.isFilterActive ? '#fff' : 'var(--text-primary)', color: proofing.isFilterActive ? 'var(--on-accent, #fff)' : 'var(--text-primary)',
border: 'none', border: 'none',
padding: '6px 14px', padding: '6px 14px',
borderRadius: '20px', borderRadius: '20px',

View File

@@ -45,12 +45,12 @@ import { albumStructuredData } from '@/lib/structuredData';
import { absoluteUrl } from '@/lib/siteUrl'; import { absoluteUrl } from '@/lib/siteUrl';
import { SubpageGridView } from './SubpageGridView'; import { SubpageGridView } from './SubpageGridView';
import { EssayView } from './EssayView'; 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 { pinsForEntry } from '@/lib/journalMap';
import { expandAlbumBlocks } from '@/lib/journalAlbum'; import { expandAlbumBlocks } from '@/lib/journalAlbum';
import { mapBlockAssetIds } from '@/lib/journal'; import { mapBlockAssetIds } from '@/lib/journal';
import { strictestPrecision, type LocationPrecision } from '@/lib/mapPrecision'; 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 { getServerDictionary } from '@/lib/i18n/server';
// Render at request time — requires live Immich connection // Render at request time — requires live Immich connection
@@ -424,16 +424,25 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
!!result.subpage.essayText; !!result.subpage.essayText;
if (isEssay) { if (isEssay) {
let essayParsed = result.subpage.essayText const cookieStore = await cookies();
? parseEssayMarkdown(result.subpage.essayText) let essayParsed: ParsedEssay | null = null;
: result.subpage.essayFile if (result.subpage.essayText) {
? loadEssayFromFile(result.subpage.essayFile) essayParsed = parseEssayMarkdown(result.subpage.essayText);
: null; } 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') {
return <PasswordGate slug={result.subpage.essayFile} title={file.title} type="journal" />;
}
if (file.status === 'open') essayParsed = file.parsed;
}
// Fetch assets from the subpage's albums. An essay has no per-album // 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 // gate to pass through, so an album with its own password stays out
// until it has been unlocked. // until it has been unlocked.
const cookieStore = await cookies();
const openAlbums = withoutLockedAlbums(albums, (name) => cookieStore.get(name)?.value); const openAlbums = withoutLockedAlbums(albums, (name) => cookieStore.get(name)?.value);
const allAlbums = await Promise.all( const allAlbums = await Promise.all(
openAlbums.map((a) => immich.getAlbumBySlug(a.slug, slug, forceFresh)), openAlbums.map((a) => immich.getAlbumBySlug(a.slug, slug, forceFresh)),
@@ -494,7 +503,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
...a.assets.map((asset) => ({ ...a.assets.map((asset) => ({
type: 'photo' as const, type: 'photo' as const,
assetId: encodeAssetId(asset.id), assetId: encodeAssetId(asset.id),
caption: asset.exifInfo?.description || undefined, caption: generatedEssayCaption(asset, config.exif.caption),
layout: 'contained' as const, layout: 'contained' as const,
})), })),
]), ]),

View File

@@ -4,7 +4,9 @@ import { useState, useEffect, type ReactNode } from 'react';
import AdminLogin from './components/AdminLogin'; import AdminLogin from './components/AdminLogin';
import AdminDashboard from './components/AdminDashboard'; import AdminDashboard from './components/AdminDashboard';
import { SESSION_EXPIRED_EVENT } from './components/sessionExpiry'; import { SESSION_EXPIRED_EVENT } from './components/sessionExpiry';
import { NotificationProvider } from './components/Notifications';
import './admin.css'; import './admin.css';
import { ConfirmProvider } from './components/ConfirmDialog';
/** /**
* Auth gate and panel chrome for every /admin route. Lives in the admin layout * Auth gate and panel chrome for every /admin route. Lives in the admin layout
@@ -79,5 +81,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,62 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
import { ConfirmProvider, 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]));
});
});

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 // @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from 'vitest'; import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react'; 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 * 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, * here to show the shape (`// @vitest-environment jsdom` on the first line,
* render, assert, cleanup) as much as to cover SaveBar. * render, assert, cleanup) as much as to cover SaveBar.
* *
* SaveBar earns it though. It decides whether an outcome reads as success or * SaveBar earns it though. It used to decide whether an outcome read as
* failure by testing whether the message happens to start with "Error" — #600 * success or failure by testing whether the message happened to start with
* replaces that with a typed state, and this pins the behaviour so the swap can * "Error"; #600 replaced that with a typed state, pinned below.
* be verified rather than eyeballed.
*/ */
afterEach(cleanup); afterEach(cleanup);
@@ -19,7 +18,7 @@ afterEach(cleanup);
const props = { const props = {
dirty: false, dirty: false,
saving: false, saving: false,
saveMessage: '', status: null as SaveStatus,
onSave: () => {}, onSave: () => {},
label: 'Save', label: 'Save',
}; };
@@ -40,7 +39,9 @@ describe('SaveBar', () => {
}); });
it('only offers saving when there is something to save', () => { 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); expect(screen.getByRole('button', { name: 'Save' })).toHaveProperty('disabled', true);
rerender(<SaveBar {...props} dirty />); rerender(<SaveBar {...props} dirty />);
@@ -63,18 +64,21 @@ describe('SaveBar', () => {
expect(onSave).toHaveBeenCalledOnce(); expect(onSave).toHaveBeenCalledOnce();
}); });
it('tells a failure from a success by the message prefix', () => { it('styles the outcome by its kind, not by its wording', () => {
// Pinned as it is, not as it should be: the prefix check is the thing #600 // #600: the bar used to call a message an error when it started with
// replaces. A message that fails without starting with "Error" is styled as // "Error", so this third message was shown as a success.
// a success today, which is precisely the argument for a typed state. const { rerender } = render(
const { rerender } = render(<SaveBar {...props} saveMessage="Error: Failed to save" />); <SaveBar {...props} status={{ kind: 'error', message: 'Error: Failed to save' }} />,
);
expect(screen.getByText('Error: Failed to save').className).toContain('error'); 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'); expect(screen.getByText('Saved!').className).toContain('success');
rerender(<SaveBar {...props} saveMessage="Could not reach the server" />); rerender(
expect(screen.getByText('Could not reach the server').className).toContain('success'); <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', () => { it('offers the preview link only where it belongs', () => {

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,112 @@
import { describe, it, expect } from 'vitest';
import * as ops from '../components/page-builder/galleryOps';
import type { GalleryState } from '../components/page-builder/galleryYaml';
/** #608: the page builder's edits, out of the component and under test. */
const state = (): GalleryState => ({
hero: ['h1', 'h2', 'h3'],
albums: [{ id: 'a1' }, { id: 'a2' }],
subpages: [
{ name: 'Japan', albums: [{ id: 'j1' }, { id: 'j2' }] },
{
name: 'Trips',
enabled: false,
albums: [],
sections: [{ title: 'North', albums: [{ id: 'n1' }] }],
},
{ name: 'Korea', albums: [{ id: 'k1' }] },
],
});
describe('album lists by address', () => {
it('adds to the standalone list, a subpage and a section', () => {
let g = ops.addAlbum(state(), { type: 'standalone' }, { id: 'x' });
expect(g.albums.map((a) => a.id)).toEqual(['a1', 'a2', 'x']);
g = ops.addAlbum(g, { type: 'subpage', subpageIndex: 0 }, { id: 'y' });
expect(g.subpages[0].albums.map((a) => a.id)).toEqual(['j1', 'j2', 'y']);
g = ops.addAlbum(g, { type: 'section', subpageIndex: 1, sectionIndex: 0 }, { id: 'z' });
expect(g.subpages[1].sections![0].albums.map((a) => a.id)).toEqual(['n1', 'z']);
});
it('updates, removes and moves one entry and leaves its neighbours alone', () => {
const at = { type: 'subpage' as const, subpageIndex: 0 };
let g = ops.updateAlbum(state(), at, 1, { title: 'Tokyo', sort: 'manual' });
expect(g.subpages[0].albums).toEqual([
{ id: 'j1' },
{ id: 'j2', title: 'Tokyo', sort: 'manual' },
]);
g = ops.moveAlbum(g, at, 1, 0);
expect(g.subpages[0].albums.map((a) => a.id)).toEqual(['j2', 'j1']);
g = ops.removeAlbum(g, at, 0);
expect(g.subpages[0].albums.map((a) => a.id)).toEqual(['j1']);
});
it('never mutates the state it was given', () => {
const before = state();
const copy = JSON.parse(JSON.stringify(before));
ops.removeAlbum(before, { type: 'section', subpageIndex: 1, sectionIndex: 0 }, 0);
ops.updateSubpage(before, 0, { title: 'x' });
ops.moveHero(before, 0, 2);
expect(before).toEqual(copy);
});
it('leaves the state as it is for an address that no longer exists', () => {
const g = state();
expect(ops.addAlbum(g, { type: 'subpage', subpageIndex: 9 }, { id: 'x' })).toBe(g);
expect(ops.removeAlbum(g, { type: 'section', subpageIndex: 0, sectionIndex: 0 }, 0)).toBe(g);
expect(ops.albumsAt(g, { type: 'section', subpageIndex: 1, sectionIndex: 5 })).toBeUndefined();
});
});
describe('hero, subpages and sections', () => {
it('adds, moves and removes hero photos', () => {
let g = ops.addHero(state(), 'h4');
g = ops.moveHero(g, 3, 0);
expect(ops.removeHero(g, 1).hero).toEqual(['h4', 'h2', 'h3']);
});
it('names a new subpage after its position and starts it empty', () => {
const g = ops.addSubpage(state());
expect(g.subpages[3]).toEqual({ name: 'New Page 4', albums: [], sections: undefined });
});
it('adds, updates and removes sections', () => {
let g = ops.addSection(state(), 0);
expect(g.subpages[0].sections).toEqual([{ title: 'New Section', albums: [] }]);
g = ops.updateSection(g, 0, 0, { title: 'Tokyo', description: 'Night' });
expect(g.subpages[0].sections![0]).toEqual({
title: 'Tokyo',
description: 'Night',
albums: [],
});
expect(ops.removeSection(g, 0, 0).subpages[0].sections).toEqual([]);
});
});
describe('reading', () => {
it('numbers subpages over the enabled ones only', () => {
const g = state();
expect(ops.enabledPosition(g, 0)).toBe(1);
expect(ops.enabledPosition(g, 1)).toBeUndefined();
expect(ops.enabledPosition(g, 2)).toBe(2);
});
it('collects every album in use, sections included', () => {
expect([...ops.usedAlbumIds(state())].sort()).toEqual(['a1', 'a2', 'j1', 'j2', 'k1', 'n1']);
});
/*
* The open subpage sheet follows its page through a drag: the page itself,
* or a neighbour moving across it, shifts its index.
*/
it.each([
[null, 0, 2, null],
[1, 1, 2, 2],
[1, 0, 2, 0],
[1, 2, 0, 2],
[0, 1, 2, 0],
])('keeps sheet %s on its page when %s moves to %s', (open, from, to, expected) => {
expect(ops.followMovedIndex(open, from, to)).toBe(expected);
});
});

View File

@@ -0,0 +1,162 @@
import { describe, it, expect } from 'vitest';
import { parseGalleryYaml, serializeGallery } from '../components/page-builder/galleryYaml';
import type { GalleryYaml } from '@/lib/config/schema';
/**
* #608: gallery.yaml ⇄ the page builder's model. This is what decides what the
* builder writes on save, and until it left PageBuilder.tsx nothing tested it.
* Album entries themselves are covered in album-entries-round-trip.test.ts.
*/
const A = '11111111-1111-1111-1111-111111111111';
const B = '22222222-2222-2222-2222-222222222222';
/** Load and save once, the way the builder does without any edit in between. */
const roundTrip = (yaml: Record<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,
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] }],
});
});
});

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 // @vitest-environment jsdom
import { describe, it, expect, afterEach, vi } from 'vitest'; import { describe, it, expect, afterEach, vi } from 'vitest';
import { renderHook, act, render, screen, fireEvent, cleanup } from '@testing-library/react'; 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'; import DraftNotice from '../components/DraftNotice';
/** /**
@@ -70,6 +70,25 @@ describe('useDraft', () => {
expect(second.result.current.status).toBe('restored'); 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', () => { it('forgets the draft once saved, and bases new edits on the saved state', () => {
const first = mount(); const first = mount();
act(() => void first.result.current.load('server-v1')); act(() => void first.result.current.load('server-v1'));
@@ -155,3 +174,26 @@ describe('DraftNotice', () => {
expect(h.onDiscard).toHaveBeenCalled(); 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,6 @@
@import './styles/asset-order.css'; @import './styles/asset-order.css';
@import './styles/favicon.css'; @import './styles/favicon.css';
@import './styles/diagnostics.css'; @import './styles/diagnostics.css';
@import './styles/messages.css';
@import './styles/notifications.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 { DOCTOR_LEVEL_EVENT, systemHealth } from './systemHealth';
import { reportIfSessionExpired } from './sessionExpiry'; import { reportIfSessionExpired } from './sessionExpiry';
import * as Icons from './Icons'; import * as Icons from './Icons';
import { useNotify } from './Notifications';
import { adminGet } from './useAdminFetch';
interface Props { interface Props {
onLogout: () => void; onLogout: () => void;
@@ -15,16 +17,100 @@ interface Props {
children: ReactNode; children: ReactNode;
} }
const TABS = [ /**
{ label: 'Pages', href: '/admin/pages', match: /^\/admin\/pages$/ }, * The sidebar, grouped by what the owner is doing (docs/admin-ux-concept.md).
{ label: 'Journal', href: '/admin/journal', match: /^\/admin\/journal/ }, * `match` decides the active entry; Overview only matches /admin itself.
{ label: 'Settings', href: '/admin/settings', match: /^\/admin\/settings/ }, */
{ label: 'Analytics', href: '/admin/analytics', match: /^\/admin\/analytics/ }, const NAV: {
{ label: 'Diagnostics', href: '/admin/diagnostics', match: /^\/admin\/diagnostics/ }, group?: string;
{ label: 'Help', href: '/admin/help', match: /^\/admin\/help/ }, 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: '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) { export default function AdminDashboard({ onLogout, children }: Props) {
const notify = useNotify();
const pathname = usePathname(); const pathname = usePathname();
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -40,6 +126,29 @@ export default function AdminDashboard({ onLogout, children }: Props) {
// an expired session or a 500 rendered exactly like a real outage — every // an expired session or a 500 rendered exactly like a real outage — every
// indicator flipped to its alarming value at once (#341). // indicator flipped to its alarming value at once (#341).
const [statusError, setStatusError] = useState(false); 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() { async function handleLogout() {
await fetch('/api/admin/auth', { method: 'DELETE' }); await fetch('/api/admin/auth', { method: 'DELETE' });
@@ -54,7 +163,7 @@ export default function AdminDashboard({ onLogout, children }: Props) {
// result was never checked at all (#596). // result was never checked at all (#596).
if (!res.ok) { if (!res.ok) {
if (!reportIfSessionExpired(res)) { if (!reportIfSessionExpired(res)) {
alert(`Reload failed (HTTP ${res.status}).`); notify('error', `Reload failed (HTTP ${res.status}).`);
} }
return; return;
} }
@@ -112,6 +221,18 @@ export default function AdminDashboard({ onLogout, children }: Props) {
fetchDoctor(); fetchDoctor();
}, [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 // The diagnostics page runs the doctor itself; take its answer rather than
// asking Immich the same three questions again. // asking Immich the same three questions again.
useEffect(() => { useEffect(() => {
@@ -146,36 +267,81 @@ export default function AdminDashboard({ onLogout, children }: Props) {
const missingCredentials = status?.setup?.credentials === 'missing'; const missingCredentials = status?.setup?.credentials === 'missing';
return ( return (
<div className="admin-dashboard"> <div className={`admin-dashboard admin-app${collapsed ? ' is-collapsed' : ''}`}>
<header className="admin-header"> <aside className="admin-sidebar" aria-label="Admin">
<div className="admin-header-left"> <button
<h1>Immich Folio</h1> type="button"
<nav className="admin-tabs"> className="admin-sidebar-toggle"
{TABS.map((t) => { onClick={toggleCollapsed}
const active = t.match.test(pathname); 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>
<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 ( return (
<Link <Link
key={t.href} key={item.href}
href={t.href} href={item.href}
className={`admin-tab ${active ? 'active' : ''}`} className={`admin-nav-item ${active ? 'active' : ''}`}
data-tip={item.label}
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
> >
{t.label} <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> </Link>
); );
})} })}
</nav>
</div> </div>
<div className="admin-header-right"> ))}
</nav>
<div className="admin-sidebar-foot">
{/* Diagnostics Badge */} {/* Diagnostics Badge */}
<div className="status-indicator-container"> <div className="status-indicator-container">
<button <button
className={`status-badge-btn ${health.tone}`} className={`status-badge-btn sidebar-status ${health.tone}`}
onClick={() => { onClick={() => {
setShowStatus(!showStatus); setShowStatus(!showStatus);
if (!showStatus) fetchStatus(); if (!showStatus) fetchStatus();
}} }}
title="Show system status" title="Show system status"
data-tip={health.label}
> >
<span className="status-dot"></span> <span className="status-dot"></span>
<span className="status-text">{health.label}</span> <span className="status-text">{health.label}</span>
@@ -266,39 +432,46 @@ export default function AdminDashboard({ onLogout, children }: Props) {
)} )}
</div> </div>
<span className="admin-header-divider" aria-hidden="true" /> <div className="admin-sidebar-actions">
<a <a
href="/?fresh=1" href="/?fresh=1"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="admin-btn admin-btn-ghost" className="admin-side-action"
data-tip="View site"
title="Open site in new tab (bypassing cache)" title="Open site in new tab (bypassing cache)"
> >
<Icons.IconLink size={14} /> Site <Icons.IconLink size={14} />
<span className="admin-side-label">View site</span>
</a> </a>
<button <button
className="admin-btn admin-btn-ghost" className="admin-side-action"
data-tip="Backups"
onClick={() => setShowBackupModal(true)} onClick={() => setShowBackupModal(true)}
title="Manage config backups & restore" title="Manage config backups & restore"
> >
<Icons.IconArchive size={14} /> Backups <Icons.IconArchive size={14} />
<span className="admin-side-label">Backups</span>
</button> </button>
<button <button
className="admin-btn admin-btn-ghost" className="admin-side-action"
data-tip="Reload"
onClick={handleReload} onClick={handleReload}
disabled={saving} disabled={saving}
title="Reload config & clear cache" title="Reload config & clear cache"
> >
<Icons.IconRefresh size={14} /> Reload <Icons.IconRefresh size={14} />
<span className="admin-side-label">Reload</span>
</button> </button>
<button className="admin-btn admin-btn-ghost" onClick={handleLogout}> <button className="admin-side-action" data-tip="Sign out" onClick={handleLogout}>
Logout <Icons.IconX size={14} />
<span className="admin-side-label">Sign out</span>
</button> </button>
</div> </div>
</header> </div>
</aside>
<main className="admin-main">{children}</main> <main className="admin-main admin-workspace">{children}</main>
<BackupManagerModal <BackupManagerModal
isOpen={showBackupModal} 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,229 @@
'use client';
/**
* /admin: the first screen after signing in (docs/admin-ux-concept.md).
*
* Says what needs attention before it is asked for (unread messages, doctor
* findings), then the site at a glance. Everything on it links to the screen
* where it is acted on. It only reads the existing admin endpoints; a failed
* one leaves its part out rather than failing the page.
*/
import { useEffect, useState } from 'react';
import Link from 'next/link';
import * as Icons from './Icons';
import { adminGet } from './useAdminFetch';
import type { DoctorFinding, DoctorLevel } from '@/lib/admin/doctor';
import { DOCTOR_LEVEL_EVENT } from './systemHealth';
interface OverviewData {
gallery: { hero?: unknown; subpages?: Array<{ enabled?: boolean }> } | null;
journal: Array<{ frontmatter: { draft?: boolean } }> | null;
messages: Array<{ read: boolean; name: string }> | null;
analytics: { days?: Record<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',
};
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;
};
}, []);
const subpages = data?.gallery?.subpages ?? [];
const published = subpages.filter((sp) => sp.enabled !== false).length;
const heroRaw = data?.gallery?.hero;
const heroCount = Array.isArray(heroRaw) ? heroRaw.length : heroRaw ? 1 : 0;
const entries = data?.journal ?? [];
const drafts = entries.filter((e) => e.frontmatter.draft).length;
const unread = (data?.messages ?? []).filter((m) => !m.read);
const views7 = viewsLastDays(data?.analytics?.days, 7);
const findings = (data?.doctor?.findings ?? []).filter((f) => f.level !== 'ok');
const attention: Array<{
tone: 'accent' | 'warn' | 'error';
title: string;
detail?: string;
href: string;
}> = [];
if (unread.length) {
attention.push({
tone: 'accent',
title: `${unread.length} unread message${unread.length === 1 ? '' : 's'}`,
detail: `From ${[...new Set(unread.map((m) => m.name))].slice(0, 3).join(', ')}`,
href: '/admin/messages',
});
}
for (const f of findings) {
attention.push({
tone: f.level === 'error' ? 'error' : 'warn',
title: f.title,
detail: f.detail,
href: FIX_ROUTE[f.id] ?? '/admin/diagnostics',
});
}
return (
<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

@@ -1,7 +1,9 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react';
import * as Icons from './Icons'; import * as Icons from './Icons';
import { useAdminFetch } from './useAdminFetch';
import AdminLoadState from './AdminLoadState';
import PageHeader from './PageHeader';
interface AnalyticsData { interface AnalyticsData {
trackingEnabled?: boolean; trackingEnabled?: boolean;
@@ -22,47 +24,17 @@ interface AnalyticsData {
} }
export default function AnalyticsView() { export default function AnalyticsView() {
const [data, setData] = useState<AnalyticsData | null>(null); const analytics = useAdminFetch<AnalyticsData>('/api/admin/analytics');
const [loading, setLoading] = useState(true); const data = analytics.data;
const [error, setError] = useState<string | null>(null);
const fetchAnalytics = useCallback(async () => { if (!data || analytics.error) {
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) {
return ( return (
<div className="admin-loading-container"> <AdminLoadState
<div className="admin-spinner" /> loading={analytics.loading}
</div> error={analytics.error}
); onRetry={analytics.reload}
} hasData={!!data}
/>
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>
); );
} }
@@ -80,20 +52,16 @@ export default function AnalyticsView() {
return ( return (
<div className="analytics-view"> <div className="analytics-view">
{/* Header */} <PageHeader
<div className="analytics-header"> kicker="Visitors"
<div> title="Analytics"
<h2> description="Cookieless page counts, kept on this server. No personal data is collected."
<Icons.IconSparkles size={20} /> Visitor Insights &amp; Analytics actions={
</h2> <button className="admin-btn admin-btn-ghost admin-btn-sm" onClick={analytics.reload}>
<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 <Icons.IconRefresh size={14} /> Refresh
</button> </button>
</div> }
/>
{data.trackingEnabled === false && ( {data.trackingEnabled === false && (
<div <div

View File

@@ -1,10 +1,12 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect } from 'react';
import type { BackupItem, BackupTarget } from '@/app/api/admin/backups/route'; import type { BackupItem, BackupTarget } from '@/app/api/admin/backups/route';
import * as Icons from './Icons'; import * as Icons from './Icons';
import { useScrollLock } from './useScrollLock'; import { useScrollLock } from './useScrollLock';
import { useModalDialog } from '@/hooks/useModalDialog'; import { useModalDialog } from '@/hooks/useModalDialog';
import { reportContentRestored } from './contentRestored';
import { useAdminFetch } from './useAdminFetch';
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
@@ -16,48 +18,47 @@ const TABS: { target: BackupTarget; label: string; file: string }[] = [
{ target: 'gallery', label: 'Gallery', file: 'gallery.yaml' }, { target: 'gallery', label: 'Gallery', file: 'gallery.yaml' },
{ target: 'settings', label: 'Settings', file: 'settings.yaml' }, { target: 'settings', label: 'Settings', file: 'settings.yaml' },
{ target: 'about', label: 'About', file: 'about.md' }, { target: 'about', label: 'About', file: 'about.md' },
{ target: 'privacy', label: 'Privacy', file: 'privacy.md' },
{ target: 'journal', label: 'Journal', file: 'journal entries' }, { target: 'journal', label: 'Journal', file: 'journal entries' },
]; ];
type BackupLists = Record<BackupTarget, BackupItem[]>; type BackupLists = Record<BackupTarget, BackupItem[]>;
const EMPTY_LISTS: BackupLists = { gallery: [], settings: [], about: [], journal: [] }; const EMPTY_LISTS: BackupLists = {
gallery: [],
settings: [],
about: [],
privacy: [],
journal: [],
};
export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }: Props) { export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }: Props) {
const [activeTab, setActiveTab] = useState<BackupTarget>('gallery'); const [activeTab, setActiveTab] = useState<BackupTarget>('gallery');
const [showAllBackups, setShowAllBackups] = useState(false); const [showAllBackups, setShowAllBackups] = useState(false);
const [backups, setBackups] = useState<BackupLists>(EMPTY_LISTS); const list = useAdminFetch<{ backups: Partial<BackupLists> }>(
const [loading, setLoading] = useState(false); isOpen ? '/api/admin/backups' : null,
const [error, setError] = useState<string | null>(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 [restoringFilename, setRestoringFilename] = useState<string | null>(null);
const [confirmItem, setConfirmItem] = useState<BackupItem | null>(null); const [confirmItem, setConfirmItem] = useState<BackupItem | null>(null);
const [successMsg, setSuccessMsg] = useState<string | 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(() => { useEffect(() => {
if (isOpen) { 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); setConfirmItem(null);
setSuccessMsg(null); setSuccessMsg(null);
setShowAllBackups(false); setShowAllBackups(false);
} }
}, [isOpen, fetchBackups]); }, [isOpen, reloadList]);
useScrollLock(isOpen); useScrollLock(isOpen);
/* Before the early `return null`: hooks must not run conditionally. */ /* Before the early `return null`: hooks must not run conditionally. */
@@ -68,7 +69,7 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
async function handleRestore(item: BackupItem) { async function handleRestore(item: BackupItem) {
const filename = item.filename; const filename = item.filename;
setRestoringFilename(filename); setRestoringFilename(filename);
setError(null); setRestoreError(null);
setSuccessMsg(null); setSuccessMsg(null);
try { try {
@@ -90,10 +91,12 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
: `Backup restored successfully! (${filename})`, : `Backup restored successfully! (${filename})`,
); );
setConfirmItem(null); 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(); onRestoreSuccess();
} catch (err: any) { } catch (err) {
setError(err.message || 'Error restoring backup'); setRestoreError(err instanceof Error ? err.message : 'Error restoring backup');
} finally { } finally {
setRestoringFilename(null); setRestoringFilename(null);
} }

View File

@@ -0,0 +1,115 @@
'use client';
/**
* One confirmation for the admin (UX stage 3, #694 §3), replacing the
* browser's `confirm()`, which cannot be styled, blocks the tab and reads the
* same for "discard a draft" and "delete a file".
*
* Policy: ask only before something that is permanent or takes effect at
* once (deleting a message, a journal entry, the uploaded favicon). Removals
* of unsaved state are undone by not saving and do not need a dialog.
*
* const confirm = useConfirm();
* if (!(await confirm({ title: 'Delete message?', confirmLabel: 'Delete', danger: true }))) return;
*/
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react';
import { useModalDialog } from '@/hooks/useModalDialog';
export interface ConfirmOptions {
title: string;
/** What happens, in one or two sentences. */
message?: ReactNode;
confirmLabel?: string;
cancelLabel?: string;
/** Destructive: the confirm button uses the error colour. */
danger?: boolean;
}
type Confirm = (options: ConfirmOptions) => Promise<boolean>;
/** Outside the provider (a component tested on its own) it falls back to window.confirm. */
const ConfirmContext = createContext<Confirm>(async (o) =>
typeof window === 'undefined' ? false : window.confirm(o.title),
);
export function useConfirm(): Confirm {
return useContext(ConfirmContext);
}
export function ConfirmProvider({ children }: { children: ReactNode }) {
const [pending, setPending] = useState<ConfirmOptions | null>(null);
const resolver = useRef<((ok: boolean) => void) | null>(null);
const confirm = useCallback<Confirm>(
(options) =>
new Promise<boolean>((resolve) => {
resolver.current?.(false);
resolver.current = resolve;
setPending(options);
}),
[],
);
const settle = useCallback((ok: boolean) => {
resolver.current?.(ok);
resolver.current = null;
setPending(null);
}, []);
return (
<ConfirmContext.Provider value={confirm}>
{children}
{pending && <Dialog options={pending} onSettle={settle} />}
</ConfirmContext.Provider>
);
}
function Dialog({
options,
onSettle,
}: {
options: ConfirmOptions;
onSettle: (ok: boolean) => void;
}) {
// Esc cancels, focus is trapped and returned (the same hook the modals use).
const cardRef = useModalDialog(() => onSettle(false));
return (
<div className="confirm-backdrop" onClick={() => onSettle(false)}>
<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(false)}
>
{options.cancelLabel ?? 'Cancel'}
</button>
<button
type="button"
className={`admin-btn ${options.danger ? 'confirm-danger' : 'admin-btn-primary'}`}
onClick={() => onSettle(true)}
>
{options.confirmLabel ?? 'Confirm'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -7,6 +7,7 @@ import * as Icons from './Icons';
import { DOCTOR_LEVEL_EVENT } from './systemHealth'; import { DOCTOR_LEVEL_EVENT } from './systemHealth';
import type { DoctorFinding, DoctorLevel } from '@/lib/admin/doctor'; import type { DoctorFinding, DoctorLevel } from '@/lib/admin/doctor';
import type { AltTextReport } from '@/lib/admin/alt-text'; import type { AltTextReport } from '@/lib/admin/alt-text';
import PageHeader from './PageHeader';
/** /**
* Diagnostics as a page (/admin/diagnostics). * Diagnostics as a page (/admin/diagnostics).
@@ -40,8 +41,12 @@ type AltTextData = AltTextReport & {
/** Which group a doctor check belongs to. An unknown id lands in Content. */ /** Which group a doctor check belongs to. An unknown id lands in Content. */
const GROUPS: { id: string; title: string; checks: string[] }[] = [ const GROUPS: { id: string; title: string; checks: string[] }[] = [
{ id: 'connection', title: 'Connection', checks: ['immich-api', 'album-ids'] }, { id: 'connection', title: 'Connection', checks: ['immich-api', 'album-ids'] },
{ id: 'security', title: 'Security', checks: ['auth-secret', 'passwords', 'proxy-hops'] }, { id: 'security', title: 'Security', checks: ['auth-secret', 'passwords', 'proxy-hops', 'cdn'] },
{ id: 'content', title: 'Content', checks: ['albums-shared', 'content-writable', 'alt-text'] }, {
id: 'content',
title: 'Content',
checks: ['albums-shared', 'content-writable', 'alt-text', 'legal', 'contact', 'privacy'],
},
]; ];
const LEVEL_LABEL: Record<DoctorLevel, string> = { ok: 'OK', warn: 'Check', error: 'Problem' }; const LEVEL_LABEL: Record<DoctorLevel, string> = { ok: 'OK', warn: 'Check', error: 'Problem' };
@@ -56,9 +61,13 @@ const FIXES: Record<string, { label: string; href: string }> = {
href: `${DOCS}/README.md#environment-variables-envlocal`, href: `${DOCS}/README.md#environment-variables-envlocal`,
}, },
'proxy-hops': { label: 'How to set it', href: `${DOCS}/docs/gallery-config.md#trusted-proxies` }, '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' }, passwords: { label: 'Open settings', href: '/admin/settings/security' },
'album-ids': { label: 'Open pages', href: '/admin/pages' }, 'album-ids': { label: 'Open pages', href: '/admin/pages' },
'albums-shared': { label: 'Open pages', href: '/admin/pages' }, 'albums-shared': { label: 'Open pages', href: '/admin/pages' },
legal: { label: 'Open settings', href: '/admin/settings/legal' },
contact: { label: 'Open settings', href: '/admin/settings/legal' },
privacy: { label: 'Open settings', href: '/admin/settings/legal' },
'content-writable': { label: 'How to fix', href: `${DOCS}/docs/admin-panel.md#docker-usage` }, 'content-writable': { label: 'How to fix', href: `${DOCS}/docs/admin-panel.md#docker-usage` },
'immich-api': { 'immich-api': {
label: 'API key permissions', label: 'API key permissions',
@@ -371,14 +380,12 @@ export default function DiagnosticsView() {
return ( return (
<div className="diag-page"> <div className="diag-page">
<div className="diag-head"> <PageHeader
<div> kicker="System"
<h2>{headline}</h2> title={headline}
<p className="diag-sub"> description={ranAt ? `Last run ${ranAt.toLocaleString()}` : 'Running the checks…'}
{ranAt ? `Last run ${ranAt.toLocaleString()}` : 'Running the checks…'} actions={
</p> <>
</div>
<div className="diag-actions">
<button className="admin-btn admin-btn-sm" onClick={run} disabled={loading}> <button className="admin-btn admin-btn-sm" onClick={run} disabled={loading}>
<Icons.IconRefresh size={14} /> {loading ? 'Checking…' : 'Run again'} <Icons.IconRefresh size={14} /> {loading ? 'Checking…' : 'Run again'}
</button> </button>
@@ -390,8 +397,9 @@ export default function DiagnosticsView() {
> >
<Icons.IconCopy size={14} /> {copied ? 'Copied' : 'Copy report'} <Icons.IconCopy size={14} /> {copied ? 'Copied' : 'Copy report'}
</button> </button>
</div> </>
</div> }
/>
{error && <div className="admin-error">{error}</div>} {error && <div className="admin-error">{error}</div>}

View File

@@ -3,6 +3,7 @@
import * as Icons from './Icons'; import * as Icons from './Icons';
import { en } from '@/lib/i18n/locales/en'; import { en } from '@/lib/i18n/locales/en';
import { LIGHTBOX_SHORTCUTS, shortcutDisplayKeys } from '@/lib/lightboxShortcuts'; import { LIGHTBOX_SHORTCUTS, shortcutDisplayKeys } from '@/lib/lightboxShortcuts';
import PageHeader from './PageHeader';
/** /**
* The admin help section. * The admin help section.
@@ -19,14 +20,11 @@ import { LIGHTBOX_SHORTCUTS, shortcutDisplayKeys } from '@/lib/lightboxShortcuts
export default function HelpView() { export default function HelpView() {
return ( return (
<div className="settings-panel"> <div className="settings-panel">
<div className="settings-section-header"> <PageHeader
<h3> kicker="System"
<Icons.IconFileText size={18} /> Help title="Help"
</h3> description="What your portfolio can do that has no button of its own."
<p className="settings-section-sub"> />
What your portfolio can do that has no button of its own.
</p>
</div>
<div className="settings-group"> <div className="settings-group">
<div className="settings-group-head"> <div className="settings-group-head">

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

@@ -1,9 +1,16 @@
'use client'; '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 { interface SaveBarProps {
dirty: boolean; dirty: boolean;
saving: boolean; saving: boolean;
saveMessage: string; status: SaveStatus;
onSave: () => void; onSave: () => void;
label: string; label: string;
/** Show the cache-bypassing preview link (page builder only). */ /** Show the cache-bypassing preview link (page builder only). */
@@ -21,20 +28,18 @@ interface SaveBarProps {
export default function SaveBar({ export default function SaveBar({
dirty, dirty,
saving, saving,
saveMessage, status,
onSave, onSave,
label, label,
showPreview = false, showPreview = false,
}: SaveBarProps) { }: SaveBarProps) {
if (!dirty && !saving && !saveMessage) return null; if (!dirty && !saving && !status) return null;
return ( return (
<div className={`floating-save-bar ${dirty ? 'dirty' : ''}`} role="status"> <div className={`floating-save-bar ${dirty ? 'dirty' : ''}`} role="status">
<div className="save-bar-left"> <div className="save-bar-left">
{saveMessage ? ( {status ? (
<span className={`save-message ${saveMessage.startsWith('Error') ? 'error' : 'success'}`}> <span className={`save-message ${status.kind}`}>{status.message}</span>
{saveMessage}
</span>
) : ( ) : (
<span className="unsaved-badge"> <span className="unsaved-badge">
<span className="badge-pulse-dot" aria-hidden="true" /> <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 { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
import AdminDashboard from '../AdminDashboard'; import AdminDashboard from '../AdminDashboard';
import { SESSION_EXPIRED_EVENT } from '../sessionExpiry'; import { SESSION_EXPIRED_EVENT } from '../sessionExpiry';
import { NotificationProvider } from '../Notifications';
vi.mock('next/navigation', () => ({ vi.mock('next/navigation', () => ({
usePathname: () => '/admin', usePathname: () => '/admin',
@@ -16,8 +17,6 @@ afterEach(cleanup);
*/ */
describe('AdminDashboard reload failure reporting', () => { describe('AdminDashboard reload failure reporting', () => {
it('reports a non-ok, non-401 reload failure', async () => { it('reports a non-ok, non-401 reload failure', async () => {
const alertMock = vi.fn();
vi.stubGlobal('alert', alertMock);
vi.stubGlobal( vi.stubGlobal(
'fetch', 'fetch',
vi.fn(async (url: string, init?: RequestInit) => { vi.fn(async (url: string, init?: RequestInit) => {
@@ -29,19 +28,22 @@ describe('AdminDashboard reload failure reporting', () => {
); );
render( render(
<NotificationProvider>
<AdminDashboard onLogout={() => {}}> <AdminDashboard onLogout={() => {}}>
<div>Panel</div> <div>Panel</div>
</AdminDashboard>, </AdminDashboard>
</NotificationProvider>,
); );
fireEvent.click(screen.getByTitle('Reload config & clear cache')); 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 () => { it('raises the session-expired event instead of reporting an error on a 401', async () => {
const alertMock = vi.fn();
vi.stubGlobal('alert', alertMock);
const listener = vi.fn(); const listener = vi.fn();
window.addEventListener(SESSION_EXPIRED_EVENT, listener); window.addEventListener(SESSION_EXPIRED_EVENT, listener);
vi.stubGlobal( vi.stubGlobal(
@@ -55,21 +57,21 @@ describe('AdminDashboard reload failure reporting', () => {
); );
render( render(
<NotificationProvider>
<AdminDashboard onLogout={() => {}}> <AdminDashboard onLogout={() => {}}>
<div>Panel</div> <div>Panel</div>
</AdminDashboard>, </AdminDashboard>
</NotificationProvider>,
); );
fireEvent.click(screen.getByTitle('Reload config & clear cache')); fireEvent.click(screen.getByTitle('Reload config & clear cache'));
await waitFor(() => expect(listener).toHaveBeenCalledOnce()); await waitFor(() => expect(listener).toHaveBeenCalledOnce());
expect(alertMock).not.toHaveBeenCalled(); expect(screen.getByRole('alert').textContent).toBe('');
window.removeEventListener(SESSION_EXPIRED_EVENT, listener); window.removeEventListener(SESSION_EXPIRED_EVENT, listener);
}); });
it('does not alert when the reload succeeds', async () => { it('reports no error when the reload succeeds', async () => {
const alertMock = vi.fn();
vi.stubGlobal('alert', alertMock);
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
if (url === '/api/admin/reload' && init?.method === 'POST') { if (url === '/api/admin/reload' && init?.method === 'POST') {
return new Response(null, { status: 200 }); return new Response(null, { status: 200 });
@@ -79,9 +81,11 @@ describe('AdminDashboard reload failure reporting', () => {
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);
render( render(
<NotificationProvider>
<AdminDashboard onLogout={() => {}}> <AdminDashboard onLogout={() => {}}>
<div>Panel</div> <div>Panel</div>
</AdminDashboard>, </AdminDashboard>
</NotificationProvider>,
); );
fireEvent.click(screen.getByTitle('Reload config & clear cache')); fireEvent.click(screen.getByTitle('Reload config & clear cache'));
@@ -93,6 +97,6 @@ describe('AdminDashboard reload failure reporting', () => {
), ),
).toHaveLength(1), ).toHaveLength(1),
); );
expect(alertMock).not.toHaveBeenCalled(); expect(screen.getByRole('alert').textContent).toBe('');
}); });
}); });

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

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

View File

@@ -1,6 +1,6 @@
'use client'; '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 }; export type CardOption = { value: string; label: string; desc: string };
@@ -55,10 +55,14 @@ export default function OptionGrid({
renderSpecs?: (option: CardOption) => ReactNode; renderSpecs?: (option: CardOption) => ReactNode;
cardStyle?: (option: CardOption) => CSSProperties | undefined; 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 ( return (
<div className="admin-field"> <div className="admin-field">
<label>{label}</label> <span className="admin-field-label" id={labelId}>
<div className="preset-card-grid"> {label}
</span>
<div className="preset-card-grid" role="group" aria-labelledby={labelId}>
{options.map((option) => { {options.map((option) => {
const isActive = value === option.value; const isActive = value === option.value;
return ( 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

@@ -30,12 +30,14 @@ import { useUnsavedGuard } from '../useUnsavedGuard';
import { useDraft } from '../useDraft'; import { useDraft } from '../useDraft';
import DraftNotice from '../DraftNotice'; import DraftNotice from '../DraftNotice';
import { reportIfSessionExpired } from '../sessionExpiry'; import { reportIfSessionExpired } from '../sessionExpiry';
import { useContentRestored } from '../contentRestored';
import './journal-studio.css'; import './journal-studio.css';
import { BlockFields, type AssetPickTarget } from './BlockFields'; import { BlockFields, type AssetPickTarget } from './BlockFields';
import { StorySettingsModal } from './StorySettingsModal'; import { StorySettingsModal } from './StorySettingsModal';
import { JournalPreview } from './JournalPreview'; import { JournalPreview } from './JournalPreview';
import { createBlock, moveBlock } from './blockOps'; import { createBlock, moveBlock } from './blockOps';
import { useSplitPane, SPLIT_MIN, SPLIT_MAX } from './splitPane'; import { useSplitPane, SPLIT_MIN, SPLIT_MAX } from './splitPane';
import { useNotify } from '../Notifications';
interface JournalEditorProps { interface JournalEditorProps {
slug: string; slug: string;
@@ -44,6 +46,7 @@ interface JournalEditorProps {
} }
export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps) { export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps) {
const notify = useNotify();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
@@ -82,6 +85,12 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
const loadDraft = draft.load; const loadDraft = draft.load;
const serverMarkdown = useRef(''); const serverMarkdown = useRef('');
// Bumped when this entry is restored from a backup, to load it again.
const [reloadKey, setReloadKey] = useState(0);
useContentRestored(({ target, slug: restored }) => {
if (target === 'journal' && restored === slug) setReloadKey((k) => k + 1);
});
// Load entry // Load entry
useEffect(() => { useEffect(() => {
async function load() { async function load() {
@@ -113,7 +122,7 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
} }
} }
load(); load();
}, [slug, loadDraft]); }, [slug, loadDraft, reloadKey]);
// Update markdown and sync blocks // Update markdown and sync blocks
const handleMarkdownChange = (newMd: string) => { const handleMarkdownChange = (newMd: string) => {
@@ -154,6 +163,9 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
const handleSave = async () => { const handleSave = async () => {
// The editor is not rendered in this state, but Cmd+S still reaches here. // The editor is not rendered in this state, but Cmd+S still reaches here.
if (loadError) return; 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 || saving) return;
setSaving(true); setSaving(true);
try { try {
@@ -166,15 +178,25 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
}); });
if (res.ok) { if (res.ok) {
serverMarkdown.current = rawMarkdown; // The file as written, not as sent: a password line is hashed on the
draft.saved(rawMarkdown); // 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 = await res.json().catch(() => null);
const written: string =
typeof data?.entry?.rawMarkdown === 'string' ? data.entry.rawMarkdown : rawMarkdown;
serverMarkdown.current = written;
draft.saved(written);
if (written !== rawMarkdown) {
setRawMarkdown(written);
setParsed(parseJournalMarkdown(written));
}
setDirty(false); setDirty(false);
} else if (!reportIfSessionExpired(res)) { } else if (!reportIfSessionExpired(res)) {
const data = await res.json(); const data = await res.json();
alert(data.error || 'Failed to save'); notify('error', data.error || 'Failed to save');
} }
} catch { } catch {
alert('Error saving entry'); notify('error', 'Could not save the entry. Check the connection and try again.');
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -324,6 +346,7 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
className="journal-editor-title-input" className="journal-editor-title-input"
value={parsed.frontmatter.title || ''} value={parsed.frontmatter.title || ''}
placeholder="Story Title..." placeholder="Story Title..."
aria-label="Story title"
onChange={(e) => handleFrontmatterChange({ title: e.target.value })} onChange={(e) => handleFrontmatterChange({ title: e.target.value })}
/> />
@@ -375,7 +398,7 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
type="button" type="button"
className="admin-btn admin-btn-sm admin-btn-primary" className="admin-btn admin-btn-sm admin-btn-primary"
onClick={handleSave} onClick={handleSave}
disabled={saving} disabled={saving || !dirty}
> >
{saving ? ( {saving ? (
'Saving...' 'Saving...'

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import type { JournalEntrySummary } from '@/lib/journal'; import type { JournalEntrySummary } from '@/lib/journal';
import { serializeJournalMarkdown, sanitizeSlug } from '@/lib/journal'; import { serializeJournalMarkdown, sanitizeSlug } from '@/lib/journal';
@@ -16,6 +16,12 @@ import {
} from '../Icons'; } from '../Icons';
import './journal-studio.css'; import './journal-studio.css';
import { JournalEditor } from './JournalEditor'; 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';
interface JournalStudioProps { interface JournalStudioProps {
/** Entry to open, taken from the /admin/journal/[slug] route. */ /** Entry to open, taken from the /admin/journal/[slug] route. */
@@ -25,10 +31,17 @@ interface JournalStudioProps {
} }
export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioProps) { export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioProps) {
const confirm = useConfirm();
const notify = useNotify();
const router = useRouter(); const router = useRouter();
const [entries, setEntries] = useState<JournalEntrySummary[]>([]); const list = useAdminFetch<{ entries?: JournalEntrySummary[] }>('/api/admin/journal');
const [loading, setLoading] = useState(true); const entries = list.data?.entries ?? [];
const [error, setError] = useState<string | null>(null); 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 // New Entry Modal
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
@@ -38,28 +51,6 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
/** null = start blank, matching the previous (only) behavior. */ /** null = start blank, matching the previous (only) behavior. */
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null); 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 handleCreate = async (e: React.FormEvent) => { const handleCreate = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!newTitle.trim()) return; if (!newTitle.trim()) return;
@@ -98,41 +89,42 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
setNewTitle(''); setNewTitle('');
setNewSlug(''); setNewSlug('');
setSelectedTemplateId(null); setSelectedTemplateId(null);
await fetchEntries(); fetchEntries();
if (data.entry?.slug) { if (data.entry?.slug) {
router.push(`/admin/journal/${data.entry.slug}`); router.push(`/admin/journal/${data.entry.slug}`);
} }
} else { } else {
const data = await res.json(); const data = await res.json();
alert(data.error || 'Failed to create journal entry'); notify('error', data.error || 'Failed to create journal entry');
} }
} catch { } catch {
alert('Error creating journal entry'); notify('error', 'Could not create the journal entry. Check the connection and try again.');
} finally { } finally {
setCreating(false); setCreating(false);
} }
}; };
const handleDelete = async (slug: string) => { const handleDelete = async (slug: string) => {
if ( const ok = await confirm({
!confirm( title: `Delete “${slug}”?`,
`Delete "${slug}"?\n\nA copy is kept and can be restored from Backups on the dashboard.`, message: 'A copy is kept and can be restored under Backups.',
) confirmLabel: 'Delete',
) danger: true,
return; });
if (!ok) return;
try { try {
const res = await fetch(`/api/admin/journal/${slug}`, { const res = await fetch(`/api/admin/journal/${slug}`, {
method: 'DELETE', method: 'DELETE',
}); });
if (res.ok) { if (res.ok) {
await fetchEntries(); fetchEntries();
if (activeSlug === slug) router.push('/admin/journal'); if (activeSlug === slug) router.push('/admin/journal');
} else { } else {
alert('Failed to delete journal entry'); notify('error', 'Failed to delete journal entry');
} }
} catch { } catch {
alert('Error deleting journal entry'); notify('error', 'Could not delete the journal entry. Check the connection and try again.');
} }
}; };
@@ -148,15 +140,11 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
return ( return (
<div className="journal-studio"> <div className="journal-studio">
<div className="journal-studio-header"> <PageHeader
<div> kicker="Content"
<h2> title="Journal"
<IconBook size={20} /> Journal &amp; Photo Essays description="Stories, field notes and longform photo essays, written with a live preview."
</h2> actions={
<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 <button
type="button" type="button"
className="admin-btn admin-btn-primary" className="admin-btn admin-btn-primary"
@@ -164,14 +152,16 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
> >
<IconPlus size={16} /> New Journal Entry <IconPlus size={16} /> New Journal Entry
</button> </button>
</div> }
/>
{loading ? ( {(list.loading && !list.data) || list.error ? (
<div style={{ padding: '3rem', textAlign: 'center', opacity: 0.6 }}> <AdminLoadState
Loading journal entries... loading={list.loading}
</div> error={list.error}
) : error ? ( onRetry={list.reload}
<div style={{ padding: '3rem', textAlign: 'center', color: '#ef4444' }}>{error}</div> hasData={!!list.data}
/>
) : entries.length === 0 ? ( ) : entries.length === 0 ? (
<div style={{ padding: '5rem 2rem', textAlign: 'center', opacity: 0.6 }}> <div style={{ padding: '5rem 2rem', textAlign: 'center', opacity: 0.6 }}>
<h3>No journal entries yet</h3> <h3>No journal entries yet</h3>
@@ -269,6 +259,7 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
<form onSubmit={handleCreate}> <form onSubmit={handleCreate}>
<div style={{ marginBottom: '1rem' }}> <div style={{ marginBottom: '1rem' }}>
<label <label
htmlFor="journal-new-title"
style={{ style={{
display: 'block', display: 'block',
fontSize: '0.85rem', fontSize: '0.85rem',
@@ -279,6 +270,7 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
Title Title
</label> </label>
<input <input
id="journal-new-title"
type="text" type="text"
className="admin-input" className="admin-input"
placeholder="e.g. Expedition Nordkap" placeholder="e.g. Expedition Nordkap"
@@ -296,6 +288,7 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
<div style={{ marginBottom: '1.5rem' }}> <div style={{ marginBottom: '1.5rem' }}>
<label <label
htmlFor="journal-new-url-slug"
style={{ style={{
display: 'block', display: 'block',
fontSize: '0.85rem', fontSize: '0.85rem',
@@ -306,6 +299,7 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
URL Slug URL Slug
</label> </label>
<input <input
id="journal-new-url-slug"
type="text" type="text"
className="admin-input" className="admin-input"
placeholder="e.g. expedition-nordkap" placeholder="e.g. expedition-nordkap"

View File

@@ -2,6 +2,7 @@
import type { ParsedJournal } from '@/lib/journal'; import type { ParsedJournal } from '@/lib/journal';
import type { AssetPickTarget } from './BlockFields'; import type { AssetPickTarget } from './BlockFields';
import PasswordField from '../fields/PasswordField';
type Frontmatter = ParsedJournal['frontmatter']; type Frontmatter = ParsedJournal['frontmatter'];
@@ -27,6 +28,7 @@ export function StorySettingsModal({
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div> <div>
<label <label
htmlFor="story-subtitle"
style={{ style={{
display: 'block', display: 'block',
fontSize: '0.8rem', fontSize: '0.8rem',
@@ -37,6 +39,7 @@ export function StorySettingsModal({
Subtitle Subtitle
</label> </label>
<input <input
id="story-subtitle"
type="text" type="text"
className="admin-input" className="admin-input"
value={frontmatter.subtitle || ''} value={frontmatter.subtitle || ''}
@@ -48,6 +51,7 @@ export function StorySettingsModal({
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<div> <div>
<label <label
htmlFor="story-author"
style={{ style={{
display: 'block', display: 'block',
fontSize: '0.8rem', fontSize: '0.8rem',
@@ -58,6 +62,7 @@ export function StorySettingsModal({
Author Author
</label> </label>
<input <input
id="story-author"
type="text" type="text"
className="admin-input" className="admin-input"
value={frontmatter.author || ''} value={frontmatter.author || ''}
@@ -67,6 +72,7 @@ export function StorySettingsModal({
</div> </div>
<div> <div>
<label <label
htmlFor="story-publish-date"
style={{ style={{
display: 'block', display: 'block',
fontSize: '0.8rem', fontSize: '0.8rem',
@@ -77,6 +83,7 @@ export function StorySettingsModal({
Publish Date Publish Date
</label> </label>
<input <input
id="story-publish-date"
type="date" type="date"
className="admin-input" className="admin-input"
value={frontmatter.date || ''} value={frontmatter.date || ''}
@@ -155,12 +162,10 @@ export function StorySettingsModal({
> >
Password Protection (Optional) Password Protection (Optional)
</label> </label>
<input <PasswordField
type="password" value={frontmatter.password || undefined}
className="admin-input" onChange={(password) => onChange({ password: password ?? '' })}
value={frontmatter.password || ''} label="Entry password"
placeholder="Leave empty for public access"
onChange={(e) => onChange({ password: e.target.value })}
/> />
</div> </div>

View File

@@ -674,3 +674,39 @@
background: rgba(239, 68, 68, 0.1); background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3); border: 1px solid rgba(239, 68, 68, 0.3);
} }
/* Phones: the top bar was one non-wrapping row, so the title input shrank to
nothing and Save ran off the screen — and there is no ⌘S on a phone (#675).
Below 700px it wraps: back button and mode toggle, the title on a line of
its own, then the actions with Save taking the remaining width. */
@media (max-width: 700px) {
.journal-editor-topbar {
flex-wrap: wrap;
padding: 0.75rem 1rem;
gap: 0.75rem;
}
.journal-editor-topbar-left {
flex-wrap: wrap;
gap: 0.5rem 0.75rem;
width: 100%;
}
.journal-editor-title-input {
order: 3;
flex: 1 1 100%;
max-width: none;
min-width: 0;
border-color: var(--admin-border, rgba(255, 255, 255, 0.08));
}
.journal-editor-topbar-right {
width: 100%;
gap: 0.5rem;
}
.journal-editor-topbar-right > .admin-btn-primary {
flex: 1;
justify-content: center;
}
}

View File

@@ -5,6 +5,7 @@ import { IconCamera, IconCopy, IconGripVertical, IconImage, IconTrash, IconX } f
import { Listbox } from '../Listbox'; import { Listbox } from '../Listbox';
import { SORT_OPTIONS } from './sortOptions'; import { SORT_OPTIONS } from './sortOptions';
import type { AlbumEntry, HeroPickerTarget, OrderEditorTarget } from './types'; import type { AlbumEntry, HeroPickerTarget, OrderEditorTarget } from './types';
import PasswordField from '../fields/PasswordField';
interface AlbumDrawerProps { interface AlbumDrawerProps {
album: AlbumEntry; album: AlbumEntry;
@@ -71,8 +72,9 @@ export default function AlbumDrawer({
</div> </div>
<div className="admin-field"> <div className="admin-field">
<label>Title override</label> <label htmlFor="album-title-override">Title override</label>
<input <input
id="album-title-override"
value={album.title || ''} value={album.title || ''}
onChange={(e) => onUpdate({ title: e.target.value || undefined })} onChange={(e) => onUpdate({ title: e.target.value || undefined })}
placeholder={name} placeholder={name}
@@ -80,8 +82,9 @@ export default function AlbumDrawer({
</div> </div>
<div className="admin-field"> <div className="admin-field">
<label>Description</label> <label htmlFor="album-description">Description</label>
<textarea <textarea
id="album-description"
value={album.description || ''} value={album.description || ''}
onChange={(e) => onUpdate({ description: e.target.value || undefined })} onChange={(e) => onUpdate({ description: e.target.value || undefined })}
placeholder="Optional description for visitors" placeholder="Optional description for visitors"
@@ -90,19 +93,17 @@ export default function AlbumDrawer({
</div> </div>
<div className="admin-field"> <div className="admin-field">
<label>Password protection</label> <label htmlFor="album-password">Password protection</label>
<div className="input-with-icon"> <PasswordField
<input id="album-password"
type="password" value={album.password}
value={album.password || ''} onChange={(password) => onUpdate({ password })}
onChange={(e) => onUpdate({ password: e.target.value || undefined })} label="Album password"
placeholder="Leave empty for public access"
/> />
</div> </div>
</div>
<div className="admin-field"> <div className="admin-field">
<label>Downloads</label> <span className="admin-field-label">Downloads</span>
<button <button
type="button" type="button"
className={`admin-toggle-card ${album.download ? 'active' : ''}`} className={`admin-toggle-card ${album.download ? 'active' : ''}`}
@@ -141,8 +142,9 @@ export default function AlbumDrawer({
<div className="admin-sheet-col admin-sheet-col--divided"> <div className="admin-sheet-col admin-sheet-col--divided">
<div className="admin-field"> <div className="admin-field">
<label>Layout override (Experimental)</label> <label htmlFor="album-layout-override">Layout override (Experimental)</label>
<select <select
id="album-layout-override"
aria-label="Layout override" aria-label="Layout override"
value={album.grid?.layout || ''} value={album.grid?.layout || ''}
onChange={(e) => { onChange={(e) => {
@@ -166,8 +168,9 @@ export default function AlbumDrawer({
</div> </div>
<div className="admin-field"> <div className="admin-field">
<label>Cover focal point (Experimental)</label> <label htmlFor="album-cover-focal-point">Cover focal point (Experimental)</label>
<input <input
id="album-cover-focal-point"
value={album.coverPosition || ''} value={album.coverPosition || ''}
onChange={(e) => onUpdate({ coverPosition: e.target.value || undefined })} onChange={(e) => onUpdate({ coverPosition: e.target.value || undefined })}
placeholder={'e.g. "50% 25%" or "top" — where the cover crop should anchor'} placeholder={'e.g. "50% 25%" or "top" — where the cover crop should anchor'}
@@ -176,7 +179,7 @@ export default function AlbumDrawer({
{/* Hero Image Selection */} {/* Hero Image Selection */}
<div className="admin-field"> <div className="admin-field">
<label>Custom Hero Image</label> <span className="admin-field-label">Custom Hero Image</span>
<div className="album-hero-field"> <div className="album-hero-field">
{album.heroImage ? ( {album.heroImage ? (
<div className="album-hero-preview"> <div className="album-hero-preview">
@@ -213,7 +216,9 @@ export default function AlbumDrawer({
{/* Photo order */} {/* Photo order */}
<div className="admin-field"> <div className="admin-field">
<label id="album-sort-label">Photo order</label> <span className="admin-field-label" id="album-sort-label">
Photo order
</span>
<Listbox <Listbox
labelledBy="album-sort-label" labelledBy="album-sort-label"
value={album.sort || DEFAULT_ALBUM_SORT} value={album.sort || DEFAULT_ALBUM_SORT}

View File

@@ -0,0 +1,213 @@
'use client';
/**
* The two drag-sortable tiles of the page builder (#608): a hero photo, and
* a subpage in the page list. Album cards are in ./AlbumCard.tsx.
*/
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { IconFolder, IconGripVertical, IconLock, IconPencil, IconX } from '../Icons';
import type { Subpage } from './types';
// ── Sortable Hero Tile ─────────────────────────────────────────
export function SortableHeroTile({
id,
index,
onRemove,
}: {
id: string;
index: number;
onRemove: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: `hero-${index}`,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 10 : undefined,
};
return (
<div ref={setNodeRef} style={style} className="hero-tile" {...attributes}>
<div className="hero-tile-drag" {...listeners} title="Drag to reorder">
<IconGripVertical size={18} className="svg-icon svg-drag" />
</div>
<img src={`/api/admin/thumbnail/${id}`} alt="" loading="lazy" />
<button className="hero-tile-remove" onClick={onRemove} title="Remove">
<IconX size={14} />
</button>
<span className="hero-tile-index">{index + 1}</span>
</div>
);
}
// ── Sortable Subpage Tile ──────────────────────────────────────
export function SortableSubpageTile({
sp,
spIndex,
isActive,
onClick,
getFirstThumb,
}: {
sp: Subpage;
spIndex: number;
isActive: boolean;
onClick: () => void;
getFirstThumb: (sp: Subpage) => string | null;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: `subpage-${spIndex}`,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 10 : undefined,
};
const totalAlbums =
sp.albums.length + (sp.sections?.reduce((sum, sec) => sum + sec.albums.length, 0) || 0);
const firstThumb = getFirstThumb(sp);
const slug = sp.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
return (
<div
ref={setNodeRef}
style={style}
className={`subpage-tile ${isActive ? 'active' : ''}`}
onClick={onClick}
{...attributes}
>
<div className="subpage-tile-drag" {...listeners} title="Drag to reorder">
<IconGripVertical size={18} className="svg-icon svg-drag" />
</div>
{sp.enabled === false && (
<span
className="subpage-badge-protected"
style={{ background: '#e60012', color: '#fff' }}
title="Page is disabled"
>
Disabled
</span>
)}
{sp.hidden === true && sp.enabled !== false && (
<span
className="subpage-badge-protected"
title="Hidden from navigation, reachable by direct link"
>
Unlisted
</span>
)}
{sp.password && (
<span className="subpage-badge-protected" title="Password protected">
<IconLock size={12} /> Password
</span>
)}
<div className="subpage-tile-cover">
{firstThumb ? (
<img src={`/api/admin/thumbnail/${firstThumb}`} alt="" loading="lazy" />
) : (
<div className="subpage-tile-placeholder">
<IconFolder />
</div>
)}
<div className="subpage-hover-overlay">
<span className="hover-action-btn">
<IconPencil size={14} /> Edit Page
</span>
</div>
</div>
<div className="subpage-tile-info">
<div className="subpage-tile-title-row">
<span className="subpage-tile-name">{sp.title || sp.name}</span>
<span className="subpage-tile-slug">/{slug}</span>
</div>
<span className="subpage-tile-meta">
<IconFolder /> {totalAlbums} album{totalAlbums !== 1 ? 's' : ''}
</span>
</div>
</div>
);
}
// ── Component ──────────────────────────────────────────────────
// ── Sortable Subpage Row (stage 4 structure list) ──────────────
/**
* One subpage in the page builder's structure list (UX stage 4): drag handle,
* cover, name, state and album count, in one line. Same sortable id as the
* tile it replaces, so the builder's drag handler is unchanged.
*/
export function SortableSubpageRow({
sp,
spIndex,
isActive,
onClick,
getFirstThumb,
}: {
sp: Subpage;
spIndex: number;
isActive: boolean;
onClick: () => void;
getFirstThumb: (sp: Subpage) => string | null;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: `subpage-${spIndex}`,
});
const totalAlbums =
sp.albums.length + (sp.sections?.reduce((sum, sec) => sum + sec.albums.length, 0) || 0);
const thumb = getFirstThumb(sp);
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 10 : undefined,
}}
className={`pb-row ${isActive ? 'active' : ''} ${sp.enabled === false ? 'is-off' : ''}`}
{...attributes}
>
<span className="pb-row-grip" {...listeners} title="Drag to reorder">
<IconGripVertical size={14} />
</span>
<button type="button" className="pb-row-main" onClick={onClick} aria-current={isActive}>
<span className="pb-row-thumb">
{thumb ? (
<img src={`/api/admin/thumbnail/${thumb}`} alt="" loading="lazy" />
) : (
<IconFolder size={14} />
)}
</span>
<span className="pb-row-name">
{sp.title || sp.name}
{sp.password && <IconLock size={11} />}
</span>
{sp.enabled === false ? (
<span className="pb-row-state">Off</span>
) : sp.hidden ? (
<span className="pb-row-state">Unlisted</span>
) : (
<span className="pb-row-count">{totalAlbums}</span>
)}
</button>
</div>
);
}

View File

@@ -15,7 +15,6 @@ import {
IconBook, IconBook,
IconEyeOff, IconEyeOff,
IconGlobe, IconGlobe,
IconLock,
IconPencil, IconPencil,
IconSearch, IconSearch,
IconTrash, IconTrash,
@@ -23,6 +22,7 @@ import {
} from '../Icons'; } from '../Icons';
import AlbumCard, { SortableAlbumCard } from './AlbumCard'; import AlbumCard, { SortableAlbumCard } from './AlbumCard';
import SubpagePreview from './SubpagePreview'; import SubpagePreview from './SubpagePreview';
import PasswordField from '../fields/PasswordField';
import { import {
normalizeSubpageGrid, normalizeSubpageGrid,
type ActiveEditAlbumAddress, type ActiveEditAlbumAddress,
@@ -68,6 +68,12 @@ interface SubpageDrawerProps {
getAlbumThumbnailId: (id: string) => string | null; getAlbumThumbnailId: (id: string) => string | null;
/** Album to mark, when the sheet was opened from a diagnostics link. */ /** Album to mark, when the sheet was opened from a diagnostics link. */
highlightedAlbumId?: string | null; highlightedAlbumId?: string | null;
/**
* Rendered as the page builder's right-hand panel (UX stage 4) instead of
* a slide-over: no backdrop, no close or Done, the structure list beside
* it is how you move on.
*/
inline?: boolean;
} }
/** /**
@@ -101,10 +107,13 @@ export default function SubpageDrawer({
getAlbumCount, getAlbumCount,
getAlbumThumbnailId, getAlbumThumbnailId,
highlightedAlbumId, highlightedAlbumId,
inline = false,
}: SubpageDrawerProps) { }: SubpageDrawerProps) {
return ( const sheet = (
<div className="subpage-drawer-backdrop" onClick={() => onClose()}> <div
<div className="subpage-drawer-container" onClick={(e) => e.stopPropagation()}> className={`subpage-drawer-container${inline ? ' subpage-drawer-container--inline' : ''}`}
onClick={(e) => e.stopPropagation()}
>
<div className="subpage-drawer-header"> <div className="subpage-drawer-header">
<div className="subpage-drawer-title-group"> <div className="subpage-drawer-title-group">
<span className="subpage-drawer-breadcrumb">Pages / Subpages</span> <span className="subpage-drawer-breadcrumb">Pages / Subpages</span>
@@ -139,9 +148,11 @@ export default function SubpageDrawer({
> >
/{slugify(sp.name)} ↗ /{slugify(sp.name)} ↗
</a> </a>
{!inline && (
<button className="admin-btn-icon" onClick={() => onClose()} title="Close drawer"> <button className="admin-btn-icon" onClick={() => onClose()} title="Close drawer">
<IconX size={14} /> <IconX size={14} />
</button> </button>
)}
</div> </div>
</div> </div>
@@ -153,9 +164,10 @@ export default function SubpageDrawer({
<div className="admin-sheet-col"> <div className="admin-sheet-col">
<div className="subpage-drawer-section"> <div className="subpage-drawer-section">
<div className="admin-field"> <div className="admin-field">
<label>Page Name (URL Identifier)</label> <label htmlFor="subpage-name">Page Name (URL Identifier)</label>
<div className="input-slug-wrapper"> <div className="input-slug-wrapper">
<input <input
id="subpage-name"
className="subpage-name-input" className="subpage-name-input"
value={sp.name} value={sp.name}
onChange={(e) => updateSubpage(spIndex, { name: e.target.value })} onChange={(e) => updateSubpage(spIndex, { name: e.target.value })}
@@ -170,8 +182,9 @@ export default function SubpageDrawer({
<div className="subpage-drawer-section"> <div className="subpage-drawer-section">
<div className="admin-field-row"> <div className="admin-field-row">
<div className="admin-field"> <div className="admin-field">
<label>Display Title (optional)</label> <label htmlFor="subpage-display-title">Display Title (optional)</label>
<input <input
id="subpage-display-title"
value={sp.title || ''} value={sp.title || ''}
onChange={(e) => onChange={(e) =>
updateSubpage(spIndex, { title: e.target.value || undefined }) updateSubpage(spIndex, { title: e.target.value || undefined })
@@ -180,8 +193,9 @@ export default function SubpageDrawer({
/> />
</div> </div>
<div className="admin-field"> <div className="admin-field">
<label>Subtitle</label> <label htmlFor="subpage-subtitle">Subtitle</label>
<input <input
id="subpage-subtitle"
value={sp.subtitle || ''} value={sp.subtitle || ''}
onChange={(e) => onChange={(e) =>
updateSubpage(spIndex, { updateSubpage(spIndex, {
@@ -193,7 +207,7 @@ export default function SubpageDrawer({
</div> </div>
</div> </div>
<div className="admin-field" style={{ marginTop: '1rem' }}> <div className="admin-field" style={{ marginTop: '1rem' }}>
<label>Visibility &amp; Access</label> <span className="admin-field-label">Visibility &amp; Access</span>
{/* {/*
One field, three states — enabled/hidden are two YAML flags, One field, three states — enabled/hidden are two YAML flags,
but for the owner it is a single question: how visible is but for the owner it is a single question: how visible is
@@ -286,26 +300,20 @@ export default function SubpageDrawer({
{/* Password lives in the same group: it is the access half {/* Password lives in the same group: it is the access half
of "who gets to see this page". Applies to Published and of "who gets to see this page". Applies to Published and
Unlisted; a Disabled page 404s before the gate. */} Unlisted; a Disabled page 404s before the gate. */}
<div className="password-input-wrapper" style={{ marginTop: '6px' }}> <div style={{ marginTop: '6px' }}>
<span className="password-icon"> <PasswordField
<IconLock size={12} /> value={sp.password}
</span> onChange={(password) => updateSubpage(spIndex, { password })}
<input
type="password"
value={sp.password || ''}
onChange={(e) =>
updateSubpage(spIndex, {
password: e.target.value || undefined,
})
}
placeholder="Password protection (optional) — leave empty for public access" placeholder="Password protection (optional) — leave empty for public access"
label="Page password"
disabled={sp.enabled === false} disabled={sp.enabled === false}
/> />
</div> </div>
</div> </div>
<div className="admin-field" style={{ marginTop: '1rem' }}> <div className="admin-field" style={{ marginTop: '1rem' }}>
<label>Page Layout Style</label> <label htmlFor="subpage-page-layout-style">Page Layout Style</label>
<select <select
id="subpage-page-layout-style"
aria-label="Page layout style" aria-label="Page layout style"
value={sp.grid?.layout || 'masonry'} value={sp.grid?.layout || 'masonry'}
onChange={(e) => { onChange={(e) => {
@@ -485,6 +493,7 @@ export default function SubpageDrawer({
}) })
} }
placeholder="Section title" placeholder="Section title"
aria-label="Section title"
/> />
<button <button
className="admin-btn-icon" className="admin-btn-icon"
@@ -503,6 +512,7 @@ export default function SubpageDrawer({
}) })
} }
placeholder="Section description (optional)" placeholder="Section description (optional)"
aria-label="Section description"
className="section-desc-input" className="section-desc-input"
/> />
</div> </div>
@@ -575,11 +585,19 @@ export default function SubpageDrawer({
> >
<IconTrash size={14} /> Delete Subpage <IconTrash size={14} /> Delete Subpage
</button> </button>
{!inline && (
<button className="admin-btn admin-btn-primary admin-btn-sm" onClick={() => onClose()}> <button className="admin-btn admin-btn-primary admin-btn-sm" onClick={() => onClose()}>
Done Done
</button> </button>
</div> )}
</div> </div>
</div> </div>
); );
if (inline) return sheet;
return (
<div className="subpage-drawer-backdrop" onClick={() => onClose()}>
{sheet}
</div>
);
} }

View File

@@ -0,0 +1,175 @@
/**
* The page builder's edits as pure functions on GalleryState (#608).
*
* Each takes a state and returns a new one; PageBuilder wraps them in
* setGallery() and adds what is not the state's business: confirming a
* removal and marking the form dirty. Out here they have tests
* (app/admin/__tests__/gallery-ops.test.ts).
*
* An index that does not exist leaves the state as it is.
*/
import { arrayMove } from '@dnd-kit/sortable';
import type { GalleryState } from './galleryYaml';
import type { AlbumEntry, PickerTarget, Section, Subpage } from './types';
/**
* Where a list of albums lives: the standalone list, a subpage's own list, or
* a section of a subpage. PickerTarget and ActiveEditAlbumAddress both carry
* this shape.
*/
type AlbumListAddress = Pick<PickerTarget, 'type' | 'subpageIndex' | 'sectionIndex'>;
function mapSubpage(g: GalleryState, index: number, fn: (sp: Subpage) => Subpage): GalleryState {
if (!g.subpages[index]) return g;
return { ...g, subpages: g.subpages.map((sp, i) => (i === index ? fn(sp) : sp)) };
}
function mapSection(
g: GalleryState,
subpageIndex: number,
sectionIndex: number,
fn: (sec: Section) => Section,
): GalleryState {
if (!g.subpages[subpageIndex]?.sections?.[sectionIndex]) return g;
return mapSubpage(g, subpageIndex, (sp) => ({
...sp,
sections: sp.sections!.map((sec, i) => (i === sectionIndex ? fn(sec) : sec)),
}));
}
/** Replace the album list at an address. */
export function mapAlbums(
g: GalleryState,
at: AlbumListAddress,
fn: (albums: AlbumEntry[]) => AlbumEntry[],
): GalleryState {
if (at.type === 'standalone') return { ...g, albums: fn(g.albums) };
if (at.subpageIndex == null) return g;
if (at.type === 'subpage') {
return mapSubpage(g, at.subpageIndex, (sp) => ({ ...sp, albums: fn(sp.albums) }));
}
if (at.sectionIndex == null) return g;
return mapSection(g, at.subpageIndex, at.sectionIndex, (sec) => ({
...sec,
albums: fn(sec.albums),
}));
}
/** The album list at an address, or undefined when the address is stale. */
export function albumsAt(g: GalleryState, at: AlbumListAddress): AlbumEntry[] | undefined {
if (at.type === 'standalone') return g.albums;
const sp = at.subpageIndex == null ? undefined : g.subpages[at.subpageIndex];
if (at.type === 'subpage') return sp?.albums;
return at.sectionIndex == null ? undefined : sp?.sections?.[at.sectionIndex]?.albums;
}
// ── Albums ──────────────────────────────────────────────────────
export const addAlbum = (g: GalleryState, at: AlbumListAddress, entry: AlbumEntry) =>
mapAlbums(g, at, (albums) => [...albums, entry]);
export const updateAlbum = (
g: GalleryState,
at: AlbumListAddress,
albumIndex: number,
updates: Partial<AlbumEntry>,
) =>
mapAlbums(g, at, (albums) => albums.map((a, i) => (i === albumIndex ? { ...a, ...updates } : a)));
export const removeAlbum = (g: GalleryState, at: AlbumListAddress, albumIndex: number) =>
mapAlbums(g, at, (albums) => albums.filter((_, i) => i !== albumIndex));
export const moveAlbum = (g: GalleryState, at: AlbumListAddress, from: number, to: number) =>
mapAlbums(g, at, (albums) => arrayMove(albums, from, to));
// ── Hero ────────────────────────────────────────────────────────
export const addHero = (g: GalleryState, assetId: string): GalleryState => ({
...g,
hero: [...g.hero, assetId],
});
export const removeHero = (g: GalleryState, index: number): GalleryState => ({
...g,
hero: g.hero.filter((_, i) => i !== index),
});
export const moveHero = (g: GalleryState, from: number, to: number): GalleryState => ({
...g,
hero: arrayMove(g.hero, from, to),
});
// ── Subpages and sections ───────────────────────────────────────
export const addSubpage = (g: GalleryState): GalleryState => ({
...g,
subpages: [
...g.subpages,
{ name: `New Page ${g.subpages.length + 1}`, albums: [], sections: undefined },
],
});
export const removeSubpage = (g: GalleryState, index: number): GalleryState => ({
...g,
subpages: g.subpages.filter((_, i) => i !== index),
});
export const updateSubpage = (g: GalleryState, index: number, updates: Partial<Subpage>) =>
mapSubpage(g, index, (sp) => ({ ...sp, ...updates }));
export const moveSubpage = (g: GalleryState, from: number, to: number): GalleryState => ({
...g,
subpages: arrayMove(g.subpages, from, to),
});
export const addSection = (g: GalleryState, subpageIndex: number) =>
mapSubpage(g, subpageIndex, (sp) => ({
...sp,
sections: [...(sp.sections || []), { title: 'New Section', albums: [] }],
}));
export const removeSection = (g: GalleryState, subpageIndex: number, sectionIndex: number) =>
mapSubpage(g, subpageIndex, (sp) => ({
...sp,
sections: (sp.sections || []).filter((_, i) => i !== sectionIndex),
}));
export const updateSection = (
g: GalleryState,
subpageIndex: number,
sectionIndex: number,
updates: Partial<Section>,
) => mapSection(g, subpageIndex, sectionIndex, (sec) => ({ ...sec, ...updates }));
// ── Reading ─────────────────────────────────────────────────────
/** The "03 — Collection" number the page renders: counted over enabled subpages only. */
export function enabledPosition(g: GalleryState, index: number): number | undefined {
const sp = g.subpages[index];
if (!sp || sp.enabled === false) return undefined;
return g.subpages.slice(0, index).filter((s) => s.enabled !== false).length + 1;
}
/** Every album published anywhere, so the picker can mark the ones already in use. */
export function usedAlbumIds(g: GalleryState): Set<string> {
const ids = new Set<string>();
g.albums.forEach((a) => ids.add(a.id));
g.subpages.forEach((sp) => {
sp.albums.forEach((a) => ids.add(a.id));
sp.sections?.forEach((sec) => sec.albums.forEach((a) => ids.add(a.id)));
});
return ids;
}
/**
* Where the open subpage sheet goes after a drag, so the sheet stays on the
* page it was showing.
*/
export function followMovedIndex(open: number | null, from: number, to: number): number | null {
if (open === null) return null;
if (open === from) return to;
if (from < open && to >= open) return open - 1;
if (from > open && to <= open) return open + 1;
return open;
}

View File

@@ -0,0 +1,130 @@
/**
* gallery.yaml ⇄ the page builder's model (#608).
*
* This is the code that decides what ends up in gallery.yaml when the
* builder saves. It lived inside the 1,200-line PageBuilder component, where
* nothing could test it; as pure functions it has a table of cases in
* app/admin/__tests__/gallery-yaml.test.ts.
*/
import { parseAlbumEntries, serializeAlbumEntries } from './albumEntries';
import { seedCoverGrid, type AlbumEntry, type Subpage } from './types';
export interface GalleryState {
hero: string[];
albums: AlbumEntry[];
subpages: Subpage[];
}
/**
* Accepts both subpage shapes gallery.yaml allows: the list of objects the
* builder writes, and the older map of name → albums (or name → object).
*/
export function parseGalleryYaml(raw: Record<string, unknown>): GalleryState {
const hero = Array.isArray(raw.hero) ? raw.hero : raw.hero ? [raw.hero as string] : [];
const albums = parseAlbumEntries(
raw.albums as Array<string | Record<string, string>> | undefined,
);
let subpages: Subpage[] = [];
if (Array.isArray(raw.subpages)) {
subpages = (raw.subpages as Array<Record<string, unknown>>).map((sp) => ({
name: (sp.name as string) || '',
title: sp.title as string | undefined,
subtitle: sp.subtitle as string | undefined,
password: sp.password as string | undefined,
enabled: sp.enabled !== false,
hidden: sp.hidden === true,
proofing: typeof sp.proofing === 'boolean' ? sp.proofing : undefined,
location: sp.location as string | undefined,
essayText: sp.essayText as string | undefined,
essayFile: sp.essayFile as string | undefined,
albums: parseAlbumEntries(sp.albums as Array<string | Record<string, string>> | undefined),
sections: sp.sections
? (sp.sections as Array<Record<string, unknown>>).map((sec) => ({
title: (sec.title as string) || '',
description: sec.description as string | undefined,
albums: parseAlbumEntries(sec.albums as Array<string | Record<string, string>>),
}))
: undefined,
grid: sp.grid as Subpage['grid'],
coverGrid:
(sp.coverGrid as Subpage['coverGrid']) ?? seedCoverGrid(sp.grid as Subpage['grid']),
}));
} else if (raw.subpages && typeof raw.subpages === 'object') {
subpages = Object.entries(raw.subpages as Record<string, unknown>).map(([name, value]) => {
if (Array.isArray(value)) {
return { name, albums: parseAlbumEntries(value), sections: undefined, enabled: true };
}
const sp = value as Record<string, unknown>;
return {
name,
title: sp.title as string | undefined,
subtitle: sp.subtitle as string | undefined,
password: sp.password as string | undefined,
enabled: sp.enabled !== false,
hidden: sp.hidden === true,
proofing: typeof sp.proofing === 'boolean' ? sp.proofing : undefined,
location: sp.location as string | undefined,
essayText: sp.essayText as string | undefined,
essayFile: sp.essayFile as string | undefined,
albums: parseAlbumEntries(sp.albums as Array<string | Record<string, string>> | undefined),
sections: undefined,
grid: sp.grid as Subpage['grid'],
coverGrid:
(sp.coverGrid as Subpage['coverGrid']) ?? seedCoverGrid(sp.grid as Subpage['grid']),
};
});
}
return { hero, albums, subpages };
}
/** The gallery.yaml object for a builder state. Empty groups are left out. */
export function serializeGallery(gallery: GalleryState): Record<string, unknown> {
const yamlData: Record<string, unknown> = {};
if (gallery.hero.length > 0) {
yamlData.hero = gallery.hero;
}
if (gallery.albums.length > 0) {
yamlData.albums = serializeAlbumEntries(gallery.albums);
}
if (gallery.subpages.length > 0) {
yamlData.subpages = gallery.subpages.map((sp) => {
const entry: Record<string, unknown> = { name: sp.name };
if (sp.title) entry.title = sp.title;
if (sp.subtitle) entry.subtitle = sp.subtitle;
if (sp.password) entry.password = sp.password;
if (sp.enabled === false) entry.enabled = false;
if (sp.hidden === true) entry.hidden = true;
// Both values mean something: false switches proofing off for this page.
if (sp.proofing !== undefined) entry.proofing = sp.proofing;
if (sp.essayText) entry.essayText = sp.essayText;
if (sp.essayFile) entry.essayFile = sp.essayFile;
if (sp.grid) entry.grid = sp.grid;
if (sp.coverGrid) entry.coverGrid = sp.coverGrid;
if (sp.location) entry.location = sp.location;
if (sp.sections && sp.sections.length > 0) {
entry.sections = sp.sections.map((sec) => {
const s: Record<string, unknown> = {
title: sec.title,
albums: serializeAlbumEntries(sec.albums),
};
if (sec.description) s.description = sec.description;
return s;
});
if (sp.albums.length > 0) {
entry.albums = serializeAlbumEntries(sp.albums);
}
} else {
entry.albums = serializeAlbumEntries(sp.albums);
}
return entry;
});
}
return yamlData;
}

View File

@@ -38,6 +38,11 @@ export interface Subpage {
subtitle?: string; subtitle?: string;
password?: string; password?: string;
enabled?: boolean; enabled?: boolean;
/**
* Client proofing on this page, overriding the site-wide setting in either
* direction. No control in the builder yet; carried so a save keeps it.
*/
proofing?: boolean;
/** EXPERIMENTAL: reachable by direct link, but not shown in navigation */ /** EXPERIMENTAL: reachable by direct link, but not shown in navigation */
hidden?: boolean; hidden?: boolean;
essayText?: string; essayText?: string;

View File

@@ -0,0 +1,287 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import * as Icons from '../Icons';
import type { SaveStatus } from '../SaveBar';
import { useDraft, readDraft } from '../useDraft';
import { useContentRestored } from '../contentRestored';
interface AboutMeta {
portrait?: string;
name?: string;
location?: string;
gear?: string[];
}
/** The About editor's state, as kept in a draft. */
interface AboutDraft {
meta: AboutMeta;
body: string;
gearText: string;
}
/**
* What a draft of the About page is compared against: the file's content, not
* the textarea's. `gearText` is derived from `meta.gear` and would make a
* trailing newline read as a change on disk.
*/
function aboutFingerprint(about: AboutDraft): string {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { gear: _gear, ...meta } = about.meta;
const gear = about.gearText
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
return JSON.stringify({ meta, gear, body: about.body });
}
/**
* The About page editor's state: content/about.md, loaded over its own
* endpoint and saved on its own, but reached through the settings nav and
* saved by the same save bar and ⌘S (#554).
*
* A hook rather than state inside the section, because the section unmounts
* when another one is opened while an About edit is still unsaved, and the
* save bar has to keep offering it.
*/
export function useAboutEditor(active: boolean) {
const [aboutMeta, setAboutMeta] = useState<AboutMeta>({});
const [aboutBody, setAboutBody] = useState('');
const [aboutLoading, setAboutLoading] = useState(false);
const [aboutLoaded, setAboutLoaded] = useState(false);
const [aboutSaving, setAboutSaving] = useState(false);
const [aboutDirty, setAboutDirty] = useState(false);
const [aboutStatus, setAboutStatus] = useState<SaveStatus>(null);
const [aboutGearText, setAboutGearText] = useState('');
const [serverAbout, setServerAbout] = useState<AboutDraft | null>(null);
// Unsaved About edits survive a tab switch or Reload, like settings (#592).
const draft = useDraft<AboutDraft>(
'about',
{ meta: aboutMeta, body: aboutBody, gearText: aboutGearText },
aboutDirty,
);
const loadDraft = draft.load;
const applyAbout = (value: AboutDraft) => {
setAboutMeta(value.meta);
setAboutBody(value.body);
setAboutGearText(value.gearText);
};
const loadAboutContent = useCallback(async () => {
setAboutLoading(true);
setAboutStatus(null);
try {
const res = await fetch('/api/admin/about');
if (!res.ok) {
throw new Error(
res.status === 401
? 'Your session has expired. Sign in again to continue.'
: `The server answered ${res.status}.`,
);
}
const data = await res.json();
const loaded: AboutDraft = {
meta: data.meta || {},
body: data.body || '',
gearText: data.meta?.gear?.join('\n') || '',
};
setServerAbout(loaded);
const restored = loadDraft(aboutFingerprint(loaded));
const value = restored ?? loaded;
setAboutMeta(value.meta);
setAboutBody(value.body);
setAboutGearText(value.gearText);
setAboutDirty(restored !== null);
setAboutLoaded(true);
} catch (err) {
console.error('Failed to load about content:', err);
// aboutLoaded stays false, which is what blocks the save below: an empty
// editor written to about.md would replace the page with nothing.
setAboutStatus({
kind: 'error',
message: `Error: ${err instanceof Error ? err.message : 'The About page could not be loaded.'}`,
});
} finally {
setAboutLoading(false);
}
}, [loadDraft]);
async function saveAboutContent() {
if (!aboutLoaded) return;
setAboutSaving(true);
setAboutStatus(null);
const cleanedMeta = { ...aboutMeta };
for (const [k, v] of Object.entries(cleanedMeta)) {
if (v === '' || v === undefined) delete cleanedMeta[k as keyof typeof cleanedMeta];
}
const gearLines = aboutGearText
.split('\n')
.map((s) => s.trim())
.filter(Boolean);
if (gearLines.length > 0) {
cleanedMeta.gear = gearLines;
} else {
delete cleanedMeta.gear;
}
try {
const res = await fetch('/api/admin/about', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ meta: cleanedMeta, body: aboutBody }),
});
if (res.ok) {
const data = await res.json();
const saved: AboutDraft = { meta: cleanedMeta, body: aboutBody, gearText: aboutGearText };
setServerAbout(saved);
draft.saved(aboutFingerprint(saved));
setAboutDirty(false);
setAboutStatus({ kind: 'success', message: data.message || 'Saved!' });
setTimeout(() => setAboutStatus(null), 4000);
} else {
const err = await res.json();
setAboutStatus({ kind: 'error', message: `Error: ${err.error}` });
}
} catch {
setAboutStatus({ kind: 'error', message: 'Error: Failed to save' });
} finally {
setAboutSaving(false);
}
}
// Loaded the first time the About section opens, and only then: the editor
// stays mounted across sections, so reloading on every visit would overwrite
// unsaved About edits with the file on disk.
useEffect(() => {
if (active && !aboutLoaded) loadAboutContent();
}, [active, aboutLoaded, loadAboutContent]);
// A kept About draft loads the file at once, from any section, so the save
// bar owns up to those edits before the About section is ever opened.
useEffect(() => {
if (!active && readDraft('about')) loadAboutContent();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// A restored about.md replaces what this editor loaded.
useContentRestored(({ target }) => {
if (target === 'about' && aboutLoaded) loadAboutContent();
});
function updateAboutMeta(key: string, value: unknown) {
setAboutMeta((m) => ({ ...m, [key]: value }));
setAboutDirty(true);
setAboutStatus(null);
}
return {
meta: aboutMeta,
body: aboutBody,
gearText: aboutGearText,
loading: aboutLoading,
saving: aboutSaving,
dirty: aboutDirty,
status: aboutStatus as SaveStatus,
updateMeta: updateAboutMeta,
setBody: (value: string) => {
setAboutBody(value);
setAboutDirty(true);
setAboutStatus(null);
},
setGearText: (value: string) => {
setAboutGearText(value);
setAboutDirty(true);
setAboutStatus(null);
},
save: saveAboutContent,
draftStatus: draft.status,
dismissDraft: draft.dismiss,
discardDraft: () => {
draft.discard();
if (serverAbout) applyAbout(serverAbout);
setAboutDirty(false);
setAboutStatus(null);
},
restoreDraft: () => {
const value = draft.takeConflicting();
if (!value) return;
applyAbout(value);
setAboutDirty(true);
},
};
}
export type AboutEditor = ReturnType<typeof useAboutEditor>;
export default function AboutSection({ about }: { about: AboutEditor }) {
return (
<div className="settings-panel">
<div className="settings-section-header">
<h3>
<Icons.IconCamera size={18} /> About Page
</h3>
<p className="settings-section-sub">
Edit the portrait, biography and gear shown on the About page. Use General to show or hide
the page itself.
</p>
</div>
{about.loading ? (
<div className="admin-spinner" style={{ margin: '2rem auto' }} />
) : (
<>
<div className="admin-field">
<label htmlFor="about-portrait-asset-id">Portrait Asset ID</label>
<input
id="about-portrait-asset-id"
value={about.meta.portrait || ''}
onChange={(e) => about.updateMeta('portrait', e.target.value)}
placeholder="Immich asset UUID for the portrait photo"
/>
</div>
<div className="admin-field">
<label htmlFor="about-name">Name</label>
<input
id="about-name"
value={about.meta.name || ''}
onChange={(e) => about.updateMeta('name', e.target.value)}
placeholder="Your name"
/>
</div>
<div className="admin-field">
<label htmlFor="about-location">Location</label>
<input
id="about-location"
value={about.meta.location || ''}
onChange={(e) => about.updateMeta('location', e.target.value)}
placeholder="City, Country"
/>
</div>
<div className="admin-field">
<label htmlFor="about-gear">Gear (one per line)</label>
<textarea
id="about-gear"
value={about.gearText}
onChange={(e) => about.setGearText(e.target.value)}
placeholder={`Leica Q3\nSummilux 35mm f/1.4`}
rows={4}
/>
</div>
<div className="admin-field">
<label htmlFor="about-biography">Biography (Markdown)</label>
<textarea
id="about-biography"
value={about.body}
onChange={(e) => about.setBody(e.target.value)}
placeholder="Photographer based in..."
rows={6}
/>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,118 @@
'use client';
import * as Icons from '../Icons';
import type { SectionProps } from './types';
export default function FooterSection({ settings, update }: SectionProps) {
return (
<div className="settings-panel">
<div className="settings-section-header">
<h3>
<Icons.IconLink size={18} /> Footer &amp; Social Links
</h3>
<p className="settings-section-sub">
Display branding, Instagram, email and website links in portfolio footer.
</p>
</div>
<div className="admin-field">
<label htmlFor="footer-brand-name">Footer Brand Name</label>
<input
id="footer-brand-name"
value={settings.footer?.name || ''}
onChange={(e) => update('footer.name', e.target.value)}
placeholder="My Photography"
/>
</div>
<div className="admin-field">
<label htmlFor="footer-instagram-url">Instagram URL</label>
<input
id="footer-instagram-url"
value={settings.footer?.instagram || ''}
onChange={(e) => update('footer.instagram', e.target.value)}
placeholder="https://instagram.com/your-handle"
/>
</div>
<div className="admin-field-row">
<div className="admin-field">
<label htmlFor="footer-contact-email">Contact Email</label>
<input
id="footer-contact-email"
value={settings.footer?.email || ''}
onChange={(e) => update('footer.email', e.target.value)}
placeholder="hello@example.com"
/>
</div>
<div className="admin-field">
<label htmlFor="footer-personal-website">Personal Website</label>
<input
id="footer-personal-website"
value={settings.footer?.website || ''}
onChange={(e) => update('footer.website', e.target.value)}
placeholder="https://example.com"
/>
</div>
</div>
<div className="settings-section-header" style={{ marginTop: '2rem' }}>
<h3>
<Icons.IconLink size={18} /> Header Navigation Links (Experimental)
</h3>
<p className="settings-section-sub">
External links shown after your pages in the header menu. Only http(s) URLs are allowed;
they open in a new tab.
</p>
</div>
{(settings.navLinks || []).map((link, i) => (
<div className="admin-field-row" key={i}>
<div className="admin-field">
<label htmlFor="footer-label">Label</label>
<input
id="footer-label"
value={link.label || ''}
onChange={(e) => {
const next = [...(settings.navLinks || [])];
next[i] = { ...next[i], label: e.target.value };
update('navLinks', next);
}}
placeholder="Shop"
/>
</div>
<div className="admin-field">
<label htmlFor="footer-url">URL</label>
<input
id="footer-url"
value={link.url || ''}
onChange={(e) => {
const next = [...(settings.navLinks || [])];
next[i] = { ...next[i], url: e.target.value };
update('navLinks', next);
}}
placeholder="https://shop.example.com"
/>
</div>
<button
type="button"
className="admin-btn admin-btn-sm"
style={{ alignSelf: 'flex-end' }}
onClick={() => {
const next = (settings.navLinks || []).filter((_, j) => j !== i);
update('navLinks', next.length > 0 ? next : undefined);
}}
title="Remove link"
>
Remove
</button>
</div>
))}
<button
type="button"
className="admin-btn admin-btn-sm"
onClick={() => update('navLinks', [...(settings.navLinks || []), { label: '', url: '' }])}
>
+ Add external link
</button>
</div>
);
}

View File

@@ -0,0 +1,311 @@
'use client';
import { useState } from 'react';
import * as Icons from '../Icons';
import type { SaveStatus } from '../SaveBar';
import { resolveExifDisplay } from '@/lib/config/schema';
import { SUPPORTED_LOCALES } from '@/lib/i18n';
import { FeatureGroup, FeatureRow, SettingRow } from './fields';
import type { SectionProps } from './types';
import { useConfirm } from '../ConfirmDialog';
export default function GeneralSection({ settings, update, updateMany }: SectionProps) {
const confirm = useConfirm();
// Collapsed by default: the four metadata switches are a detail of one
// decision, and showing them permanently is what made the section a wall (#510).
const [metadataOpen, setMetadataOpen] = useState(false);
const [faviconUploading, setFaviconUploading] = useState(false);
const [faviconStatus, setFaviconStatus] = useState<SaveStatus>(null);
// Resolved the same way the site resolves it, so the switches show what a
// visitor actually sees — including a config that only ever set the older
// `exifOnHover`.
const exif = resolveExifDisplay(settings.exif, settings.exifOnHover);
// The master switch has no key of its own and needs none: "off" is all four
// groups off, a state the site already acts on — with nothing left to show,
// the lightbox withdraws its info button and the `i` key entirely (#506).
const anyMetadata = exif.camera || exif.settings || exif.location || exif.caption;
const metadataSummary =
[
exif.camera && 'Camera & lens',
exif.settings && 'Exposure',
exif.location && 'Location',
exif.caption && 'Description',
]
.filter(Boolean)
.join(' · ') || 'Nothing published — the lightbox hides its info panel';
/** Turning it back on selects everything; the details below narrow it again. */
const toggleMetadata = () =>
updateMany({
'exif.camera': !anyMetadata,
'exif.settings': !anyMetadata,
'exif.location': !anyMetadata,
'exif.caption': !anyMetadata,
});
return (
<div className="settings-panel">
<div className="settings-section-header">
<h3>
<Icons.IconGear size={18} /> General Site Settings
</h3>
<p className="settings-section-sub">
Configure basic site identity, language, and core feature toggles.
</p>
</div>
<div className="admin-field">
<label htmlFor="general-site-title">Site Title</label>
<input
id="general-site-title"
value={settings.title || ''}
onChange={(e) => update('title', e.target.value)}
placeholder="My Portfolio"
/>
</div>
<div className="admin-field">
<label htmlFor="general-subtitle">Subtitle</label>
<input
id="general-subtitle"
value={settings.subtitle || ''}
onChange={(e) => update('subtitle', e.target.value)}
placeholder="A visual journal"
/>
</div>
<div className="admin-field">
<label htmlFor="general-language">Language</label>
<select
id="general-language"
aria-label="Language"
value={settings.lang || 'en'}
onChange={(e) => update('lang', e.target.value)}
>
<option value="en">English (US)</option>
<option value="de">Deutsch (DE)</option>
<option value="fr">Français (FR)</option>
<option value="es">Español (ES)</option>
<option value="it">Italiano (IT)</option>
<option value="nl">Nederlands (NL)</option>
<option value="ja">日本語 (JA)</option>
</select>
<p className="admin-field-hint">
The visitor-facing interface is translated for {SUPPORTED_LOCALES.join(', ')}. Other
languages still set <code>&lt;html lang&gt;</code> and date formatting, but show the
English interface. The admin panel is always English.
</p>
</div>
<div className="settings-section-divider" />
<div className="settings-section-header">
<h3>
<Icons.IconSparkles size={18} /> Portfolio Features &amp; Modules
</h3>
<p className="settings-section-sub">
Optional pages, and what the site reveals about your photos and counts about visits.
</p>
</div>
<FeatureGroup
icon={<Icons.IconFrame size={13} />}
title="Modules"
description="Each one adds a page or a control visitors can use."
>
<div className="feature-list">
<FeatureRow
icon={<Icons.IconCamera size={15} />}
title="About page"
description="Portrait, bio and gear at /about"
checked={settings.about?.enabled !== false}
onToggle={() => update('about.enabled', settings.about?.enabled === false)}
href="/admin/settings/about"
/>
<FeatureRow
icon={<Icons.IconMap size={15} />}
title="Map"
description="Photo locations on a world map at /map"
checked={settings.map === true}
onToggle={() => update('map', !settings.map)}
/>
<FeatureRow
icon={<Icons.IconHeart size={15} />}
title="Client proofing"
description="Visitors heart, filter and export a selection of photos"
checked={settings.proofing?.enabled !== false}
onToggle={() => update('proofing.enabled', settings.proofing?.enabled === false)}
/>
</div>
</FeatureGroup>
<FeatureGroup
icon={<Icons.IconShieldCheck size={13} />}
title="What visitors learn about you"
description="What each photo reveals, and what is counted about a visit."
chip="visible to visitors"
>
<div className={`metadata-card ${anyMetadata ? 'active' : ''}`}>
<button
type="button"
className="metadata-card-main"
onClick={toggleMetadata}
aria-pressed={anyMetadata}
>
<span className="toggle-card-info">
<span className="toggle-card-title">
<Icons.IconCamera size={16} /> Photo metadata
</span>
<span className="toggle-card-desc">{metadataSummary}</span>
</span>
<span className={`switch-toggle ${anyMetadata ? 'on' : ''}`}>
<span className="switch-slider" />
</span>
</button>
<button
type="button"
className="metadata-details-btn"
onClick={() => setMetadataOpen((open) => !open)}
aria-expanded={metadataOpen}
>
Details
<Icons.IconChevronDown
size={13}
className={`metadata-details-chevron${metadataOpen ? ' open' : ''}`}
/>
</button>
{metadataOpen && (
<div className="metadata-details">
<SettingRow
title="Camera &amp; Lens"
description="Body, lens and focal length"
checked={exif.camera}
onToggle={() => update('exif.camera', !exif.camera)}
/>
<SettingRow
title="Exposure Settings"
description="Aperture, shutter speed and ISO"
checked={exif.settings}
onToggle={() => update('exif.settings', !exif.settings)}
/>
<SettingRow
title="Location"
description="City and country from the photo's GPS data"
checked={exif.location}
onToggle={() => update('exif.location', !exif.location)}
/>
<SettingRow
title="Photo Description"
description="The description written in Immich"
checked={exif.caption}
onToggle={() => update('exif.caption', !exif.caption)}
/>
{/* Dependent, not a peer: this only controls the grid overlay, and
the overlay carries camera and lens. With those off it has
nothing to show, which used to leave it switched on and
silently doing nothing (#510). */}
<SettingRow
indented
title="Summary on grid hover"
description="Camera and lens over the photo, not only in the lightbox"
checked={settings.exifOnHover !== false}
disabled={!exif.camera}
hint={exif.camera ? undefined : 'Needs Camera & Lens'}
onToggle={() => update('exifOnHover', settings.exifOnHover === false)}
/>
</div>
)}
</div>
<div className="feature-list">
<FeatureRow
icon={<Icons.IconBarChart size={15} />}
title="Analytics"
description="Cookieless view counts, stored on your server only"
checked={settings.analytics !== false}
onToggle={() => update('analytics', settings.analytics === false)}
href="/admin/analytics"
/>
</div>
</FeatureGroup>
<div className="admin-field favicon-field">
<span className="admin-field-label">Favicon</span>
<div className="favicon-row">
<input
type="file"
accept=".svg,.png,.ico,.jpg,.jpeg"
disabled={faviconUploading}
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setFaviconStatus(null);
setFaviconUploading(true);
const form = new FormData();
form.append('file', file);
try {
const res = await fetch('/api/admin/favicon', {
method: 'PUT',
body: form,
});
const data = await res.json();
setFaviconStatus(
res.ok
? { kind: 'success', message: data.message }
: { kind: 'error', message: `Error: ${data.error}` },
);
} catch {
setFaviconStatus({ kind: 'error', message: 'Error: Upload failed' });
} finally {
setFaviconUploading(false);
e.target.value = '';
}
}}
/>
<button
type="button"
className="admin-btn"
disabled={faviconUploading}
onClick={async () => {
// Deletes the uploaded file at once, outside the staged form.
const ok = await confirm({
title: 'Reset the favicon?',
message: 'The uploaded icon is deleted and the bundled default is used again.',
confirmLabel: 'Reset',
danger: true,
});
if (!ok) return;
setFaviconStatus(null);
setFaviconUploading(true);
try {
const res = await fetch('/api/admin/favicon', { method: 'DELETE' });
const data = await res.json();
setFaviconStatus(
res.ok
? { kind: 'success', message: data.message }
: { kind: 'error', message: `Error: ${data.error}` },
);
} catch {
setFaviconStatus({ kind: 'error', message: 'Error: Reset failed' });
} finally {
setFaviconUploading(false);
}
}}
>
Reset
</button>
{faviconUploading && <div className="admin-spinner" />}
</div>
{faviconStatus && (
<p className={`save-message ${faviconStatus.kind}`}>{faviconStatus.message}</p>
)}
<span className="admin-field-hint">
SVG, PNG, ICO, or JPEG — max 512 kB. Stored in the content volume. Reset restores the
bundled default.
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,204 @@
'use client';
import * as Icons from '../Icons';
import OptionGrid, { toOptions } from '../fields/OptionGrid';
import {
PHOTO_GRID_COLUMNS_MAX,
PHOTO_GRID_COLUMNS_MIN,
PHOTO_GRID_GAP_MAX,
} from '@/lib/config/schema';
import type { SectionProps } from './types';
const LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow', 'justified'];
const ASPECT_RATIOS = ['1', '3/2', '2/3', '16/9', 'auto'];
const LAYOUT_INFO: Record<string, { label: string; desc: string }> = {
masonry: {
label: 'Masonry',
desc: 'Dynamic pinterest-style staggered columns',
},
uniform: { label: 'Uniform Grid', desc: 'Clean equal aspect ratio grid' },
showcase: {
label: 'Showcase',
desc: 'Featured hero photos mixed with smaller tiles',
},
filmstrip: {
label: 'Filmstrip',
desc: 'Horizontal scrollable film strip timeline',
},
'editorial-flow': {
label: 'Editorial Flow',
desc: 'Magazine story layout with varying photo sizes',
},
justified: {
label: 'Justified (Experimental)',
desc: 'Equal-height rows that fill the full width',
},
};
const LAYOUT_OPTIONS = toOptions(LAYOUTS, LAYOUT_INFO);
function LayoutPreview({ value }: { value: string }) {
return (
<div className="grid-card-preview">
<div className={`mini-layout-demo layout-demo-${value}`}>
{value === 'masonry' && (
<div className="demo-masonry-col-group">
<div className="demo-col">
<div className="demo-tile h-high" />
<div className="demo-tile h-low" />
</div>
<div className="demo-col">
<div className="demo-tile h-low" />
<div className="demo-tile h-high" />
</div>
<div className="demo-col">
<div className="demo-tile h-med" />
<div className="demo-tile h-med" />
</div>
</div>
)}
{value === 'uniform' && (
<div className="demo-uniform-grid">
<div className="demo-tile" />
<div className="demo-tile" />
<div className="demo-tile" />
<div className="demo-tile" />
<div className="demo-tile" />
<div className="demo-tile" />
</div>
)}
{value === 'showcase' && (
<div className="demo-showcase-grid">
<div className="demo-tile demo-hero" />
<div className="demo-col">
<div className="demo-tile" />
<div className="demo-tile" />
</div>
</div>
)}
{value === 'filmstrip' && (
<div className="demo-filmstrip-row">
<div className="demo-tile strip" />
<div className="demo-tile strip" />
<div className="demo-tile strip" />
</div>
)}
{value === 'editorial-flow' && (
<div className="demo-editorial-flow">
<div className="demo-tile wide" />
<div className="demo-row">
<div className="demo-tile" />
<div className="demo-tile" />
</div>
</div>
)}
{value === 'justified' && (
<div className="demo-editorial-flow">
<div className="demo-row">
<div className="demo-tile wide" />
<div className="demo-tile" />
</div>
<div className="demo-row">
<div className="demo-tile" />
<div className="demo-tile wide" />
</div>
</div>
)}
</div>
</div>
);
}
const ASPECT_RATIO_INFO: Record<string, { label: string; desc: string }> = {
'1': { label: 'Square (1:1)', desc: '1:1 ratio square crops' },
'3/2': { label: 'Landscape (3:2)', desc: 'Standard 35mm DSLR landscape' },
'2/3': { label: 'Portrait (2:3)', desc: 'Vertical portrait orientation' },
'16/9': { label: 'Cinema (16:9)', desc: 'Widescreen 16:9 cinematic ratio' },
auto: {
label: 'Original Auto',
desc: 'Uncropped original image proportions',
},
};
const ASPECT_RATIO_OPTIONS = toOptions(ASPECT_RATIOS, ASPECT_RATIO_INFO);
function AspectRatioPreview({ value }: { value: string }) {
return (
<div className="ratio-card-preview">
<div className={`mini-aspect-box ratio-${value.replace('/', '-')}`}>
<div className="mini-aspect-inner" />
</div>
</div>
);
}
export default function GridSection({ settings, update }: SectionProps) {
return (
<div className="settings-panel">
<div className="settings-section-header">
<h3>
<Icons.IconGrid size={18} /> Grid &amp; Layout Engine
</h3>
<p className="settings-section-sub">
Configure photography gallery column structures and thumbnail aspect ratios.
</p>
</div>
<OptionGrid
label="Layout Algorithm"
options={LAYOUT_OPTIONS}
value={settings.grid?.layout || 'masonry'}
onSelect={(v) => update('grid.layout', v)}
cardClassName="grid-layout-card"
renderPreview={(o) => <LayoutPreview value={o.value} />}
/>
<OptionGrid
label="Aspect Ratio"
options={ASPECT_RATIO_OPTIONS}
value={settings.grid?.aspectRatio || '1'}
onSelect={(v) => update('grid.aspectRatio', v)}
cardClassName="ratio-card"
renderPreview={(o) => <AspectRatioPreview value={o.value} />}
/>
<div className="settings-section-divider" />
<div className="settings-section-header">
<h3>
<Icons.IconColumns size={18} /> Spacing &amp; Columns
</h3>
<p className="settings-section-sub">
Adjust column counts and grid gap spacing. The column count also tiles the album covers on
a subpage; their spacing stays with the theme unless a page overrides it.
</p>
</div>
<div className="admin-field-row">
<div className="admin-field">
<label htmlFor="grid-columns">
Columns ({PHOTO_GRID_COLUMNS_MIN} - {PHOTO_GRID_COLUMNS_MAX})
</label>
<input
id="grid-columns"
type="number"
min={PHOTO_GRID_COLUMNS_MIN}
max={PHOTO_GRID_COLUMNS_MAX}
value={settings.grid?.columns ?? 3}
onChange={(e) => update('grid.columns', parseInt(e.target.value) || 3)}
/>
</div>
<div className="admin-field">
<label htmlFor="grid-gap-spacing">Gap Spacing (px)</label>
<input
id="grid-gap-spacing"
type="number"
min={0}
max={PHOTO_GRID_GAP_MAX}
value={settings.grid?.gap ?? 12}
onChange={(e) => update('grid.gap', parseInt(e.target.value) || 0)}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,255 @@
'use client';
import * as Icons from '../Icons';
import ToggleCard from '../fields/ToggleCard';
import PrivacyEditor from '../PrivacyEditor';
import { CONTACT_RETENTION_DEFAULT, CONTACT_RETENTION_MAX, isHttpUrl } from '@/lib/config/schema';
import type { SectionProps } from './types';
export default function LegalSection({ settings, update }: SectionProps) {
// The Impressum drops a non-http(s) contact URL with a warning in the server
// log, where nobody looks; say it here instead.
const contactUrl = settings.legal?.contactUrl?.trim();
const contactUrlInvalid = !!contactUrl && !isHttpUrl(contactUrl);
const notifyUrl = settings.contact?.notifyUrl?.trim();
const notifyUrlInvalid = !!notifyUrl && !isHttpUrl(notifyUrl);
return (
<div className="settings-panel">
<div className="settings-section-header">
<h3>
<Icons.IconScale size={18} /> Legal Notice &amp; Impressum
</h3>
<p className="settings-section-sub">
Configure the legal disclosure page required by the German Digitale-Dienste-Gesetz (DDG).
</p>
{settings.legal?.enabled && (
<a
href="/impressum"
target="_blank"
rel="noopener noreferrer"
className="admin-btn admin-btn-sm settings-section-action"
>
View /impressum ↗
</a>
)}
</div>
<div className="admin-toggle-cards-grid" style={{ marginBottom: '1.25rem' }}>
<ToggleCard
icon={<Icons.IconFileText size={16} />}
title="Enable Impressum Page (/impressum)"
description="Automatically generates and links /impressum in footer"
checked={settings.legal?.enabled === true}
onToggle={() => update('legal.enabled', !settings.legal?.enabled)}
/>
</div>
{settings.legal?.enabled && (
<>
<div className="admin-field">
<label htmlFor="legal-heading">Heading</label>
<input
id="legal-heading"
value={settings.legal?.heading || ''}
onChange={(e) => update('legal.heading', e.target.value)}
placeholder="Angaben gemäß § 5 DDG"
/>
<p className="admin-field-hint">
Leave empty for the § 5 DDG line in the site language. Set it when a different law
applies, e.g. § 5 ECG in Austria.
</p>
</div>
<div className="admin-field-row">
<div className="admin-field">
<label htmlFor="legal-full-name-business-name">Full Name / Business Name</label>
<input
id="legal-full-name-business-name"
value={settings.legal?.name || ''}
onChange={(e) => update('legal.name', e.target.value)}
placeholder="Max Mustermann"
/>
</div>
<div className="admin-field">
<label htmlFor="legal-street-address">Street Address</label>
<input
id="legal-street-address"
value={settings.legal?.address || ''}
onChange={(e) => update('legal.address', e.target.value)}
placeholder="Musterstraße 1"
/>
</div>
</div>
<div className="admin-field-row">
<div className="admin-field">
<label htmlFor="legal-zip-city">ZIP &amp; City</label>
<input
id="legal-zip-city"
value={settings.legal?.zipCity || ''}
onChange={(e) => update('legal.zipCity', e.target.value)}
placeholder="12345 Berlin"
/>
</div>
<div className="admin-field">
<label htmlFor="legal-country">Country</label>
<input
id="legal-country"
value={settings.legal?.country || ''}
onChange={(e) => update('legal.country', e.target.value)}
placeholder="Germany"
/>
</div>
</div>
<div className="admin-field-row">
<div className="admin-field">
<label htmlFor="legal-email">Legal Email</label>
<input
id="legal-email"
value={settings.legal?.email || ''}
onChange={(e) => update('legal.email', e.target.value)}
placeholder="legal@example.com"
/>
</div>
<div className="admin-field">
<label htmlFor="legal-phone-number">Phone Number</label>
<input
id="legal-phone-number"
value={settings.legal?.phone || ''}
onChange={(e) => update('legal.phone', e.target.value)}
placeholder="+49 123 456789"
/>
</div>
</div>
<div className="admin-field-row">
<div className="admin-field">
<label htmlFor="legal-contact-url">Contact Form URL</label>
<input
id="legal-contact-url"
type="url"
value={settings.legal?.contactUrl || ''}
onChange={(e) => update('legal.contactUrl', e.target.value)}
placeholder="https://example.com/contact"
aria-invalid={contactUrlInvalid || undefined}
aria-describedby={contactUrlInvalid ? 'legal-contact-url-error' : undefined}
/>
{contactUrlInvalid && (
<p
id="legal-contact-url-error"
className="admin-field-hint admin-field-hint--error"
>
Must start with https:// or http://. Any other link is left off the page.
</p>
)}
</div>
<div className="admin-field">
<label htmlFor="legal-contact-link-text">Contact Link Text</label>
<input
id="legal-contact-link-text"
value={settings.legal?.contactLabel || ''}
onChange={(e) => update('legal.contactLabel', e.target.value)}
placeholder="Contact form"
/>
</div>
</div>
<div className="admin-field-row">
<div className="admin-field">
<label htmlFor="legal-vat-id">VAT ID</label>
<input
id="legal-vat-id"
value={settings.legal?.vatId || ''}
onChange={(e) => update('legal.vatId', e.target.value)}
placeholder="DE123456789"
/>
</div>
<div className="admin-field">
<label htmlFor="legal-tax-number">Tax Number</label>
<input
id="legal-tax-number"
value={settings.legal?.taxId || ''}
onChange={(e) => update('legal.taxId', e.target.value)}
placeholder="12/345/67890"
/>
</div>
</div>
<div className="admin-field">
<label htmlFor="legal-additional-disclosures">Additional Disclosures</label>
<textarea
id="legal-additional-disclosures"
value={settings.legal?.extraInfo || ''}
onChange={(e) => update('legal.extraInfo', e.target.value)}
placeholder="Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV..."
rows={3}
/>
</div>
</>
)}
<div className="settings-section-divider" />
<div className="admin-toggle-cards-grid" style={{ marginBottom: '1.25rem' }}>
<ToggleCard
icon={<Icons.IconFileText size={16} />}
title="Contact Form (/contact)"
description="Messages are stored on this server and read under Messages. No mail is sent."
checked={settings.contact?.enabled === true}
onToggle={() => update('contact.enabled', !settings.contact?.enabled)}
/>
</div>
{settings.contact?.enabled && (
<div className="admin-field-row">
<div className="admin-field">
<label htmlFor="contact-notify-url">Notification URL (ntfy)</label>
<input
id="contact-notify-url"
type="url"
value={settings.contact?.notifyUrl || ''}
onChange={(e) => update('contact.notifyUrl', e.target.value)}
placeholder="https://ntfy.sh/your-secret-topic"
aria-invalid={notifyUrlInvalid || undefined}
aria-describedby="contact-notify-url-hint"
/>
<p
id="contact-notify-url-hint"
className={`admin-field-hint${notifyUrlInvalid ? ' admin-field-hint--error' : ''}`}
>
{notifyUrlInvalid
? 'Must start with https:// or http://.'
: 'Gets a fixed "new message" push, with nothing about the sender. Pick a topic name nobody can guess. CONTACT_NOTIFY_URL overrides this.'}
</p>
</div>
<div className="admin-field">
<label htmlFor="contact-retention">Delete messages after (days)</label>
<input
id="contact-retention"
type="number"
min={1}
max={CONTACT_RETENTION_MAX}
value={settings.contact?.retentionDays ?? CONTACT_RETENTION_DEFAULT}
onChange={(e) =>
update(
'contact.retentionDays',
parseInt(e.target.value) || CONTACT_RETENTION_DEFAULT,
)
}
/>
</div>
</div>
)}
<div className="settings-section-divider" />
<div className="admin-toggle-cards-grid" style={{ marginBottom: '1.25rem' }}>
<ToggleCard
icon={<Icons.IconShieldCheck size={16} />}
title="Privacy Policy (/privacy)"
description="Shown and linked in the footer once the text below is saved"
checked={settings.privacy?.enabled !== false}
onToggle={() => update('privacy.enabled', settings.privacy?.enabled === false)}
/>
</div>
<PrivacyEditor />
</div>
);
}

View File

@@ -0,0 +1,139 @@
'use client';
import * as Icons from '../Icons';
import ToggleCard from '../fields/ToggleCard';
import PasswordField from '../fields/PasswordField';
import { resolveWatermarkOpacity } from '@/lib/config/schema';
import type { SectionProps } from './types';
export default function SecuritySection({ settings, update }: SectionProps) {
return (
<div className="settings-panel">
<div className="settings-section-header">
<h3>
<Icons.IconLock size={18} /> Site Password
</h3>
<p className="settings-section-sub">
Puts the whole public site behind one password — useful while a portfolio is still being
built, or for a folio only ever shown to clients.
</p>
</div>
<div className="admin-field">
<label htmlFor="security-site-password">Site Password</label>
<PasswordField
id="security-site-password"
value={settings.sitePassword}
onChange={(password) => update('sitePassword', password ?? '')}
placeholder="Leave empty for a public site"
label="Site password"
/>
<p className="admin-field-hint">
Stored as a <code>scrypt:…</code> hash in <code>settings.yaml</code>, never as typed. The{' '}
<code>SITE_PASSWORD</code> environment variable overrides this field. The admin panel
keeps its own password and is never behind this gate.
</p>
</div>
<div className="settings-section-divider" />
<div className="settings-section-header">
<h3>
<Icons.IconShieldCheck size={18} /> Asset Protection &amp; Watermark
</h3>
<p className="settings-section-sub">
Configure image protection rules, right-click prevention, and watermark overlay.
</p>
</div>
<div className="admin-toggle-cards-grid">
<ToggleCard
icon={<Icons.IconLock size={16} />}
title="Disable Right-Click Menu"
description="Prevents context menu on portfolio images to hinder unauthorized downloads"
checked={settings.protection?.disableRightClick === true}
onToggle={() =>
update('protection.disableRightClick', !settings.protection?.disableRightClick)
}
/>
<ToggleCard
icon={<Icons.IconBan size={16} />}
title="Disable Image Dragging"
description="Prevents visitors from dragging images off the portfolio page"
checked={settings.protection?.disableImageDrag === true}
onToggle={() =>
update('protection.disableImageDrag', !settings.protection?.disableImageDrag)
}
/>
</div>
<div className="settings-section-divider" />
<div className="settings-section-header">
<h3>
<Icons.IconSparkles size={18} /> Dynamic Watermark Overlay
</h3>
<p className="settings-section-sub">Overlay copyright branding text on Lightbox images.</p>
</div>
<div className="admin-toggle-cards-grid" style={{ marginBottom: '1.25rem' }}>
<ToggleCard
icon={<Icons.IconFrame size={16} />}
title="Enable Watermark"
description="Overlay copyright text on portfolio image views"
checked={settings.watermark?.enabled === true}
onToggle={() => update('watermark.enabled', !settings.watermark?.enabled)}
/>
</div>
{settings.watermark?.enabled && (
<>
<div className="admin-field">
<label htmlFor="security-watermark-text">Watermark Text</label>
<input
id="security-watermark-text"
value={settings.watermark?.text || ''}
onChange={(e) => update('watermark.text', e.target.value)}
placeholder="© Ralfo Photography"
/>
</div>
<div className="admin-field-row">
<div className="admin-field">
<label htmlFor="security-position">Position</label>
<select
id="security-position"
aria-label="Watermark position"
value={settings.watermark?.position || 'bottom-right'}
onChange={(e) => update('watermark.position', e.target.value)}
>
<option value="bottom-right">Bottom Right</option>
<option value="bottom-left">Bottom Left</option>
<option value="center">Center Overlay</option>
</select>
</div>
<div className="admin-field">
{/* Read through the same normaliser the lightbox uses, so a
config written as a percentage shows as "90%" here rather
than "9000%" — and is written back as a fraction on save. */}
<label htmlFor="security-opacity-100">
Opacity ({Math.round(resolveWatermarkOpacity(settings.watermark?.opacity) * 100)}
%)
</label>
<input
id="security-opacity-100"
type="range"
min="0.1"
max="1"
step="0.05"
value={resolveWatermarkOpacity(settings.watermark?.opacity)}
onChange={(e) => update('watermark.opacity', parseFloat(e.target.value))}
/>
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,141 @@
'use client';
import * as Icons from '../Icons';
import ToggleCard from '../fields/ToggleCard';
import type { SectionProps } from './types';
export default function SeoSection({
settings,
update,
siteUrlInfo,
}: SectionProps & {
/** Resolved site URL and its origin, so the panel can name SITE_URL (#472). */
siteUrlInfo: { effective: string | null; source: 'env' | 'settings' | 'none' } | null;
}) {
return (
<div className="settings-panel">
<div className="settings-section-header">
<h3>
<Icons.IconSearch size={18} /> Search Engine Optimization (SEO)
</h3>
<p className="settings-section-sub">
Customize search engine metadata, OpenGraph tags, and indexing rules.
</p>
</div>
{/* Live Google Search Result Snippet Card */}
<div className="google-snippet-preview">
<div className="google-snippet-header">
<span>
<Icons.IconGlobe size={14} /> Google Search Result Preview
</span>
</div>
<div className="google-snippet-card">
<div className="google-snippet-url">
https://yourportfolio.com <span className="google-snippet-arrow">▼</span>
</div>
<div className="google-snippet-title">
{settings.seo?.title || settings.title || 'My Photography Portfolio'}
</div>
<div className="google-snippet-desc">
{settings.seo?.description ||
settings.subtitle ||
'A curated selection of photography work.'}
</div>
</div>
</div>
<div className="admin-field">
<label htmlFor="seo-site-url">Site URL</label>
<input
id="seo-site-url"
value={settings.url || ''}
onChange={(e) => update('url', e.target.value)}
placeholder="https://folio.example"
/>
<p style={{ fontSize: '0.8rem', opacity: 0.7, marginTop: '4px' }}>
Needed for <code>sitemap.xml</code>, the feed and structured data — those are generated
without a request, so the address cannot be derived from it. Leave empty and the sitemap
stays empty rather than guessing.
{siteUrlInfo?.source === 'env' && (
<>
<br />
Currently supplied by the <code>SITE_URL</code> environment variable (
<code>{siteUrlInfo.effective}</code>). A value entered here takes precedence once
saved.
</>
)}
</p>
</div>
<div className="admin-field">
<label htmlFor="seo-meta-title">SEO Meta Title</label>
<input
id="seo-meta-title"
value={settings.seo?.title || ''}
onChange={(e) => update('seo.title', e.target.value)}
placeholder="Overrides default site title for Google search results"
/>
</div>
<div className="admin-field">
<label htmlFor="seo-subpage-title-template">Subpage Title Template</label>
<input
id="seo-subpage-title-template"
value={settings.seo?.titleTemplate || ''}
onChange={(e) => update('seo.titleTemplate', e.target.value)}
placeholder={`%s | ${settings.seo?.title || settings.title || 'My Portfolio'}`}
/>
<p style={{ fontSize: '0.8rem', opacity: 0.7, marginTop: '4px' }}>
Template for subpages &amp; albums. Use <code>%s</code> as placeholder for the page title.
<br />
<strong>Preview:</strong>{' '}
{(
settings.seo?.titleTemplate ||
`%s | ${settings.seo?.title || settings.title || 'My Portfolio'}`
).replace('%s', 'Landscapes')}
</p>
</div>
<div className="admin-field">
<label htmlFor="seo-meta-description">SEO Meta Description</label>
<textarea
id="seo-meta-description"
value={settings.seo?.description || ''}
onChange={(e) => update('seo.description', e.target.value)}
placeholder="A curated selection of photography work..."
rows={3}
/>
</div>
<div className="settings-section-divider" />
<div className="settings-section-header">
<h3>
<Icons.IconGlobe size={18} /> Search Crawler Directives
</h3>
<p className="settings-section-sub">
Control how Googlebot and other web crawlers index your site.
</p>
</div>
<div className="admin-toggle-cards-grid">
<ToggleCard
icon={<Icons.IconBan size={16} />}
title="noindex (Hide from Google)"
description="Instructs search engines NOT to index this site in search results"
checked={settings.seo?.noIndex === true}
onToggle={() => update('seo.noIndex', !settings.seo?.noIndex)}
/>
<ToggleCard
icon={<Icons.IconLink size={16} />}
title="nofollow (Block Link Following)"
description="Instructs search engine crawlers not to follow outgoing links"
checked={settings.seo?.noFollow === true}
onToggle={() => update('seo.noFollow', !settings.seo?.noFollow)}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,495 @@
'use client';
import { useState, type CSSProperties } from 'react';
import * as Icons from '../Icons';
import OptionGrid, { toOptions } from '../fields/OptionGrid';
import { DEFAULT_PRESET } from '@/lib/config/theme';
import { effectiveTheme, FeatureRow } from './fields';
import type { SectionProps } from './types';
const PRESETS = ['studio-modern', 'studio', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
const PHOTO_FRAMES = ['none', 'passepartout', 'shadow'];
const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic', 'cover'];
const PHOTO_FRAME_INFO: Record<string, { label: string; desc: string }> = {
none: { label: 'None', desc: 'Flush image with crisp edges' },
passepartout: {
label: 'Passepartout',
desc: 'Classic gallery matting border',
},
shadow: { label: 'Shadow', desc: 'Soft floating drop shadow' },
};
const PHOTO_FRAME_OPTIONS = toOptions(PHOTO_FRAMES, PHOTO_FRAME_INFO);
function PhotoFramePreview({ value }: { value: string }) {
return (
<div className="frame-card-preview">
<div className={`mini-frame-demo frame-${value}`}>
<div className="mini-frame-photo" />
</div>
</div>
);
}
const HERO_STYLE_INFO: Record<string, { label: string; desc: string }> = {
split: { label: 'Split', desc: 'Side-by-side title & photo' },
fullbleed: { label: 'Fullbleed', desc: 'Edge-to-edge full width banner' },
minimal: { label: 'Minimal', desc: 'Centered title with subtle photo' },
stacked: { label: 'Stacked', desc: 'Title stacked directly over photo' },
typographic: { label: 'Typographic', desc: 'Oversized magazine masthead' },
mosaic: { label: 'Mosaic', desc: 'Dynamic photo collage layout' },
cover: {
label: 'Cover (Experimental)',
desc: 'Fullscreen splash with a single Enter link',
},
};
const HERO_STYLE_OPTIONS = toOptions(HERO_STYLES, HERO_STYLE_INFO);
function HeroStylePreview({ value }: { value: string }) {
return (
<div className="hero-card-preview">
<div className={`mini-hero-demo hero-demo-${value}`}>
{value === 'split' && (
<>
<div className="hero-demo-text">
<div className="demo-line title" />
<div className="demo-line sub" />
</div>
<div className="hero-demo-photo" />
</>
)}
{value === 'fullbleed' && (
<div className="hero-demo-full">
<div className="demo-line title light" />
</div>
)}
{value === 'minimal' && (
<>
<div className="hero-demo-center-text">
<div className="demo-line title short" />
</div>
<div className="hero-demo-photo small" />
</>
)}
{value === 'stacked' && (
<>
<div className="hero-demo-text">
<div className="demo-line title" />
</div>
<div className="hero-demo-photo banner" />
</>
)}
{value === 'typographic' && (
<>
<div className="demo-line title giant" />
<div className="hero-demo-grid2">
<div className="hero-demo-photo" />
<div className="hero-demo-photo" />
</div>
</>
)}
{value === 'mosaic' && (
<div className="hero-demo-mosaic">
<div className="hero-demo-photo big" />
<div className="hero-demo-photo-col">
<div className="hero-demo-photo" />
<div className="hero-demo-photo" />
</div>
</div>
)}
</div>
</div>
);
}
/**
* Card metadata for the theme picker. `font`, `radius` and `frame` mirror the
* real preset definitions in lib/config/theme.ts so the mini mockups show what
* the preset actually does; `gap` is the visual density of its gallery grid.
*/
const THEME_INFO: Record<
string,
{
desc: string;
label: string;
accent: string;
bg: string;
tile: string;
font: string;
type: 'serif' | 'sans' | 'mono';
radius: number;
frame: 'none' | 'passepartout' | 'shadow';
gap: number;
}
> = {
studio: {
label: 'Studio',
desc: 'Clean, high-contrast grid with sans-serif type.',
bg: '#141414',
tile: '#242424',
accent: '#e60012',
font: 'Playfair Display',
type: 'serif',
radius: 0,
frame: 'passepartout',
gap: 4,
},
'studio-modern': {
label: 'Studio Modern',
desc: 'Leica precision: Archivo grotesque, mono EXIF, red signal accents.',
bg: '#121212',
tile: '#191919',
accent: '#e60012',
font: 'Archivo',
type: 'sans',
radius: 0,
frame: 'none',
gap: 3,
},
minimal: {
label: 'Minimal',
desc: 'Pure raw layouts with tiny gaps and high whitespace.',
bg: '#ffffff',
tile: '#f3f3f3',
accent: '#111111',
font: 'Geist',
type: 'sans',
radius: 0,
frame: 'none',
gap: 2,
},
editorial: {
label: 'Editorial',
desc: 'Warm backgrounds, elegant serifs and large headers.',
bg: '#fbf9f4',
tile: '#e5dfd4',
accent: '#b89053',
font: 'Bodoni Moda',
type: 'serif',
radius: 0,
frame: 'shadow',
gap: 8,
},
classic: {
label: 'Classic',
desc: 'Soft traditional photographer portfolio with shadows.',
bg: '#f7f7f7',
tile: '#ffffff',
accent: '#444444',
font: 'Cinzel',
type: 'serif',
radius: 12,
frame: 'passepartout',
gap: 7,
},
noir: {
label: 'Noir',
desc: 'High drama absolute pitch black, stark high-fashion look.',
bg: '#000000',
tile: '#151515',
accent: '#ffffff',
font: 'Libre Baskerville',
type: 'serif',
radius: 0,
frame: 'passepartout',
gap: 6,
},
monograph: {
label: 'Monograph',
desc: 'Typewriter monospace font, grid borders and document feel.',
bg: '#f4f4f6',
tile: '#ffffff',
accent: '#555555',
font: 'Instrument Serif',
type: 'mono',
radius: 0,
frame: 'none',
gap: 5,
},
};
/* The preset group sits below THEME_INFO on purpose. PRESET_OPTIONS is built
at module evaluation and reads it through themeInfo(); declared above, it
hit the temporal dead zone and the whole Settings page failed to load with
"Cannot access 'THEME_INFO' before initialization". tsc does not catch it
because the read goes through a hoisted function, and a production build
succeeds because the module is only evaluated in the browser (#542). */
/** The theme picker's own metadata, with the fallback the card grid needs. */
function themeInfo(value: string) {
return (
THEME_INFO[value] || {
label: value,
desc: '',
bg: '#fff',
tile: '#eee',
accent: '#333',
font: 'System',
type: 'sans' as const,
radius: 0,
frame: 'none' as const,
gap: 4,
}
);
}
const PRESET_OPTIONS = PRESETS.map((value) => ({
value,
label: themeInfo(value).label,
desc: themeInfo(value).desc,
}));
function PresetPreview({ value }: { value: string }) {
const i = themeInfo(value);
return (
<div className="preset-card-preview" data-frame={i.frame}>
<div className="mini-header">
<span className={`mini-specimen type-${i.type}`}>Aa</span>
<span className="mini-line" />
</div>
<div className="mini-grid">
<div className="mini-tile" />
<div className="mini-tile" />
<div className="mini-tile" />
</div>
</div>
);
}
function PresetSpecs({ value }: { value: string }) {
const i = themeInfo(value);
return (
<span className="preset-card-specs">
{i.font} &middot; radius {i.radius} &middot; {i.frame}
</span>
);
}
export default function ThemeSection({ settings, update }: SectionProps) {
// Read off the page on mount: the toggle previews light and dark on the
// admin itself, and the page may already be in either.
const [currentMode, setCurrentMode] = useState<'dark' | 'light'>(() =>
typeof document !== 'undefined' &&
document.documentElement.getAttribute('data-theme') === 'light'
? 'light'
: 'dark',
);
const toggleMode = (mode: 'dark' | 'light') => {
setCurrentMode(mode);
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('data-theme', mode);
localStorage.setItem('theme', mode);
}
};
// Same idea for the theme: an unset field shows the preset's own value, not
// a fallback of this form's — Classic used to read "split hero, no frame".
const theme = effectiveTheme(settings.theme);
return (
<div className="settings-panel theme-settings-panel">
<div className="settings-section-header">
<h3>
<Icons.IconPalette size={18} /> Theme Presets &amp; Color Mode
</h3>
<p className="settings-section-sub">
Choose a typography preset and preview in Light or Dark mode.
</p>
</div>
<div className="admin-field">
<label htmlFor="theme-visitor-default-mode">Visitor Default Mode</label>
<select
id="theme-visitor-default-mode"
aria-label="Visitor default mode"
value={settings.mode || 'dark'}
onChange={(e) => update('mode', e.target.value)}
>
<option value="dark">Dark</option>
<option value="light">Light</option>
<option value="auto">Follow the visitor&apos;s system</option>
</select>
<p className="admin-field-hint">
What a first-time visitor sees. The light/dark toggle in the site header still lets them
choose, and their choice is remembered on their device. The preview below is your own, and
does not change this.
</p>
</div>
<OptionGrid
label="Preset"
options={PRESET_OPTIONS}
value={settings.theme?.preset || DEFAULT_PRESET}
onSelect={(v) => update('theme.preset', v)}
cardClassName="theme-preset-card"
renderPreview={(o) => <PresetPreview value={o.value} />}
renderSpecs={(o) => <PresetSpecs value={o.value} />}
cardStyle={(o) => {
const i = themeInfo(o.value);
return {
'--preset-accent': i.accent,
'--preset-bg': i.bg,
'--preset-tile': i.tile,
'--preset-radius': `${i.radius}px`,
'--preset-gap': `${i.gap}px`,
} as CSSProperties;
}}
/>
<div className="admin-field">
<span className="admin-field-label">Color Mode</span>
<p
style={{
fontSize: '0.78rem',
color: 'var(--admin-text-muted)',
margin: '0.25rem 0 0.6rem',
lineHeight: '1.4',
}}
>
Presets like <strong>Editorial</strong>, <strong>Minimal</strong> &amp;{' '}
<strong>Classic</strong> feature warm light/cream backgrounds in Light Mode and charcoal
in Dark Mode.
</p>
<div className="segmented-control">
<button
type="button"
className={`segment-btn ${currentMode === 'light' ? 'active' : ''}`}
onClick={() => toggleMode('light')}
>
<Icons.IconSun size={14} /> Light Mode (Cream / Beige)
</button>
<button
type="button"
className={`segment-btn ${currentMode === 'dark' ? 'active' : ''}`}
onClick={() => toggleMode('dark')}
>
<Icons.IconMoon size={14} /> Dark Mode (Charcoal)
</button>
</div>
</div>
<div className="settings-section-divider" />
<div className="settings-section-header">
<h3>
<Icons.IconTarget size={18} /> Accent Color
</h3>
<p className="settings-section-sub">
Pick a primary accent color for links, buttons, and highlights.
</p>
</div>
<div className="admin-field">
<div className="accent-picker-wrapper">
<div className="color-swatches-row">
{[
{ hex: '#e60012', name: 'Studio Red' },
{ hex: '#b89053', name: 'Editorial Gold' },
{ hex: '#10b981', name: 'Emerald' },
{ hex: '#3b82f6', name: 'Sapphire' },
{ hex: '#8b5cf6', name: 'Violet' },
{ hex: '#ffffff', name: 'Monochrome White' },
{ hex: '#000000', name: 'Obsidian Black' },
].map((swatch) => {
const isSelected = theme.accent.toLowerCase() === swatch.hex.toLowerCase();
return (
<button
key={swatch.hex}
type="button"
className={`color-swatch-btn ${isSelected ? 'active' : ''}`}
style={{ backgroundColor: swatch.hex }}
onClick={() => update('theme.accent', swatch.hex)}
title={swatch.name}
/>
);
})}
</div>
<div className="color-field">
<input
type="color"
aria-label="Accent colour"
value={theme.accent}
onChange={(e) => update('theme.accent', e.target.value)}
/>
<input
aria-label="Accent colour (hex)"
type="text"
value={settings.theme?.accent || ''}
onChange={(e) => update('theme.accent', e.target.value)}
placeholder={theme.accent}
/>
</div>
</div>
</div>
<div className="settings-section-divider" />
<div className="settings-section-header">
<h3>
<Icons.IconFrame size={18} /> Photo Frame &amp; Layout
</h3>
<p className="settings-section-sub">
Customize image presentation borders and hero layouts.
</p>
</div>
<OptionGrid
label="Photo Frame"
options={PHOTO_FRAME_OPTIONS}
value={theme.photoFrame}
onSelect={(v) => update('theme.photoFrame', v)}
cardClassName="frame-card"
renderPreview={(o) => <PhotoFramePreview value={o.value} />}
/>
<OptionGrid
label="Hero Style"
options={HERO_STYLE_OPTIONS}
value={theme.heroStyle}
onSelect={(v) => update('theme.heroStyle', v)}
cardClassName="hero-card"
renderPreview={(o) => <HeroStylePreview value={o.value} />}
/>
<div className="settings-section-divider" />
<div className="settings-section-header">
<h3>
<Icons.IconSparkles size={18} /> Look &amp; motion
</h3>
<p className="settings-section-sub">Effects and small controls on every page.</p>
</div>
<div className="feature-list">
<FeatureRow
icon={<Icons.IconSparkles size={15} />}
title="Page transitions"
description="A short fade between pages"
checked={settings.transitions !== false}
onToggle={() => update('transitions', settings.transitions === false)}
/>
<FeatureRow
icon={<Icons.IconArrowUp size={15} />}
title="Scroll-to-top button"
description="A floating arrow on long pages"
checked={settings.scrollToTop !== false}
onToggle={() => update('scrollToTop', settings.scrollToTop === false)}
/>
<FeatureRow
icon={<Icons.IconFilm size={15} />}
title="Film grain"
description="Analog noise over the background"
checked={theme.grain}
onToggle={() => update('theme.grain', !theme.grain)}
/>
<FeatureRow
icon={<Icons.IconTarget size={15} />}
title="Header accent dot"
description="A dot beside the active section heading"
checked={theme.headerDot}
onToggle={() => update('theme.headerDot', !theme.headerDot)}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,154 @@
'use client';
import type { ReactNode } from 'react';
import Link from 'next/link';
import * as Icons from '../Icons';
// Direct import from the theme module, not from '@/lib/config': the config
// index pulls in `fs` and cannot be bundled into a client component.
import { resolveTheme } from '@/lib/config/theme';
import type { SettingsYaml } from '@/lib/config/schema';
import type { Settings } from './types';
/**
* One labelled block of related switches.
*
* The features panel was a single flat grid of identical cards, so nothing said
* which switches belonged together — and a switch that publishes private notes
* looked exactly like one that controls a fade (#510).
*/
export function FeatureGroup({
icon,
title,
description,
chip,
children,
}: {
icon: ReactNode;
title: string;
description: string;
/** Short marker for a group that needs its own weight, e.g. what visitors see. */
chip?: string;
children: ReactNode;
}) {
return (
<div className="settings-group">
<div className="settings-group-head">
<span className="settings-group-title">
{icon}
{title}
{chip && <span className="settings-group-chip">{chip}</span>}
</span>
<span className="settings-group-desc">{description}</span>
</div>
{children}
</div>
);
}
/**
* A switch inside a group panel. Deliberately lighter than a toggle card: these
* are settings *of* the panel they sit in, and rendering them as peers of the
* top-level cards is what hid the structure in the first place.
*/
export function SettingRow({
title,
description,
checked,
onToggle,
disabled,
hint,
indented,
}: {
title: string;
description: string;
checked: boolean;
onToggle: () => void;
/** A row whose parent switch is off — shown, but inert and explained. */
disabled?: boolean;
hint?: string;
indented?: boolean;
}) {
return (
<button
type="button"
className={`setting-row${indented ? ' setting-row--indented' : ''}${
disabled ? ' setting-row--disabled' : ''
}`}
onClick={onToggle}
disabled={disabled}
aria-pressed={checked}
>
<span className="setting-row-info">
<span className="setting-row-title">{title}</span>
<span className="setting-row-desc">{description}</span>
{hint && (
<span className="setting-row-hint">
<Icons.IconBan size={11} /> {hint}
</span>
)}
</span>
<span className={`switch-toggle switch-toggle--sm ${checked && !disabled ? 'on' : ''}`}>
<span className="switch-slider" />
</span>
</button>
);
}
/**
* One switch in a feature list: a single dense line instead of a card. Cards
* gave every switch the same weight, so a fade and a whole page looked alike;
* rows grouped by meaning let the group heading carry that weight instead.
* The optional link sits beside the switch, not inside it — a link nested in
* a button is invalid and swallows the click.
*/
export function FeatureRow({
icon,
title,
description,
checked,
onToggle,
href,
}: {
icon: ReactNode;
title: string;
description: string;
checked: boolean;
onToggle: () => void;
/** Where this feature is configured beyond on/off. */
href?: string;
}) {
return (
<div className="feature-row">
<button type="button" className="feature-row-main" onClick={onToggle} aria-pressed={checked}>
<span className="feature-row-icon">{icon}</span>
<span className="feature-row-info">
<span className="feature-row-title">{title}</span>
<span className="feature-row-desc">{description}</span>
</span>
<span className={`switch-toggle ${checked ? 'on' : ''}`}>
<span className="switch-slider" />
</span>
</button>
{/* Reserved even without a link, so every switch lines up in one column. */}
{href ? (
<Link href={href} className="feature-row-link">
Configure →
</Link>
) : (
<span className="feature-row-link" aria-hidden="true" />
)}
</div>
);
}
/**
* The theme as the site resolves it. A preset name the form does not know (a
* hand-edited typo) falls back to the default instead of taking the panel down.
*/
export function effectiveTheme(raw: Settings['theme']) {
try {
return resolveTheme(raw as SettingsYaml['theme']);
} catch {
return resolveTheme(undefined);
}
}

View File

@@ -0,0 +1,95 @@
/**
* Shared shape of the settings form (#554). The sections edit one object
* that mirrors settings.yaml; SettingsEditor owns it and hands each section
* the object plus the two ways to change it.
*/
export interface Settings {
title?: string;
subtitle?: string;
/** Absolute site URL for sitemap, feed and structured data (#472). */
url?: string;
lang?: string;
sitePassword?: string;
mode?: 'light' | 'dark' | 'auto';
exifOnHover?: boolean;
exif?: {
camera?: boolean;
settings?: boolean;
location?: boolean;
caption?: boolean;
};
map?: boolean;
transitions?: boolean;
scrollToTop?: boolean;
analytics?: boolean;
theme?: {
preset?: string;
accent?: string;
photoFrame?: string;
grain?: boolean;
headerDot?: boolean;
heroStyle?: string;
};
grid?: {
columns?: number;
gap?: number;
aspectRatio?: string;
layout?: string;
};
footer?: {
name?: string;
instagram?: string;
email?: string;
website?: string;
};
/** EXPERIMENTAL: external links appended to the header navigation */
navLinks?: Array<{ label?: string; url?: string }>;
contact?: { enabled?: boolean; notifyUrl?: string; retentionDays?: number };
privacy?: { enabled?: boolean };
legal?: {
enabled?: boolean;
heading?: string;
name?: string;
address?: string;
zipCity?: string;
country?: string;
email?: string;
phone?: string;
contactUrl?: string;
contactLabel?: string;
taxId?: string;
vatId?: string;
extraInfo?: string;
};
seo?: {
title?: string;
description?: string;
titleTemplate?: string;
noIndex?: boolean;
noFollow?: boolean;
};
protection?: {
disableRightClick?: boolean;
disableImageDrag?: boolean;
};
proofing?: {
enabled?: boolean;
allowMailto?: boolean;
};
watermark?: {
enabled?: boolean;
text?: string;
opacity?: number;
position?: 'bottom-right' | 'bottom-left' | 'center';
};
about?: { enabled?: boolean };
}
export interface SectionProps {
settings: Settings;
/** Set one dotted path, e.g. `theme.accent`. An empty value removes the key. */
update: (path: string, value: unknown) => void;
/** Set several paths in one state update. */
updateMany: (entries: Record<string, unknown>) => void;
}

View File

@@ -0,0 +1,85 @@
'use client';
/**
* Loading, data and a typed error for an admin screen (#609).
*
* Every screen used to write this by hand: set loading, fetch, check res.ok,
* set data, catch, set error, clear loading. The copies disagreed on errors:
* `err: any` with `err.message`, a silent `catch {}`, a boolean flag. A silent
* catch is how a failed load turns into an empty form. Here a failure is
* always a message, and a 401 always goes through reportIfSessionExpired.
*
* Pair it with <AdminLoadState> for the spinner and the error-with-retry.
*/
import { useCallback, useEffect, useState } from 'react';
import { reportIfSessionExpired } from './sessionExpiry';
export type AdminResult<T> = { data: T; error: null } | { data: null; error: string };
/** One GET, with the admin's error conventions. Usable outside the hook too. */
export async function adminGet<T>(url: string): Promise<AdminResult<T>> {
try {
const res = await fetch(url);
if (!res.ok) {
if (reportIfSessionExpired(res)) {
return { data: null, error: 'Your session expired. Sign in again.' };
}
const body = (await res.json().catch(() => null)) as { error?: unknown } | null;
const detail = typeof body?.error === 'string' ? `: ${body.error}` : '';
return { data: null, error: `Could not load (HTTP ${res.status})${detail}.` };
}
return { data: (await res.json()) as T, error: null };
} catch {
return { data: null, error: 'Could not reach the server.' };
}
}
export interface AdminFetch<T> {
data: T | null;
/** Only the latest attempt's error; cleared while a reload is in flight. */
error: string | null;
/** True until the current URL has answered, including after reload(). */
loading: boolean;
reload: () => void;
/** Local edit after a successful mutation, without a round trip. */
mutate: (update: (data: T) => T) => void;
}
/**
* `url === null` fetches nothing (a closed modal, say). Data from the previous
* answer stays visible while a reload runs.
*/
export function useAdminFetch<T>(url: string | null): AdminFetch<T> {
const [attempt, setAttempt] = useState(0);
const key = url === null ? null : `${attempt}:${url}`;
const [result, setResult] = useState<(AdminResult<T> & { key: string }) | null>(null);
useEffect(() => {
if (key === null || url === null) return;
let live = true;
// State is set in the callback, never synchronously in the effect.
adminGet<T>(url).then((r) => {
if (live) setResult({ ...r, key });
});
return () => {
live = false;
};
}, [key, url]);
const loading = key !== null && result?.key !== key;
const reload = useCallback(() => setAttempt((n) => n + 1), []);
const mutate = useCallback((update: (data: T) => T) => {
setResult((r) =>
r && r.error === null ? { key: r.key, data: update(r.data), error: null } : r,
);
}, []);
return {
data: result?.data ?? null,
error: loading ? null : (result?.error ?? null),
loading,
reload,
mutate,
};
}

View File

@@ -46,7 +46,35 @@ export function readDraft<T>(key: string): Draft<T> | null {
} }
} }
/**
* True when a draft holds a password as typed. Passwords are stored as scrypt
* hashes (#706), but between typing and saving the editor holds the plaintext,
* and sessionStorage is readable by any script on the admin origin. Such a
* draft is not kept: the unsaved-changes guard still warns before leaving, and
* the password is typed again after a reload.
*/
export function holdsPlaintextPassword(value: unknown): boolean {
if (typeof value === 'string') {
// Journal markdown: a `password:` line in the frontmatter.
const fm = /^---\n([\s\S]*?)\n---/.exec(value)?.[1] ?? '';
return /^password:[ \t]*(?!['"]?scrypt:)['"]?\S/m.test(fm);
}
if (Array.isArray(value)) return value.some(holdsPlaintextPassword);
if (value && typeof value === 'object') {
return Object.entries(value).some(([k, v]) =>
/^(?:password|sitePassword)$/.test(k) && typeof v === 'string'
? v !== '' && !v.startsWith('scrypt:')
: holdsPlaintextPassword(v),
);
}
return false;
}
export function writeDraft<T>(key: string, draft: Draft<T>): void { export function writeDraft<T>(key: string, draft: Draft<T>): void {
if (holdsPlaintextPassword(draft.value)) {
clearDraft(key);
return;
}
try { try {
window.sessionStorage.setItem(PREFIX + key, JSON.stringify(draft)); window.sessionStorage.setItem(PREFIX + key, JSON.stringify(draft));
} catch { } catch {
@@ -73,13 +101,19 @@ export type DraftStatus = 'none' | 'restored' | 'conflict';
export function useDraft<T>(key: string, value: T, dirty: boolean) { export function useDraft<T>(key: string, value: T, dirty: boolean) {
/** The server fingerprint the current edits started from; null until loaded. */ /** The server fingerprint the current edits started from; null until loaded. */
const baseRef = useRef<string | null>(null); const baseRef = useRef<string | null>(null);
/**
* True while a `conflict` waits for an answer. The editor shows the server
* state meanwhile — not dirty — and without this hold the effect below would
* clear the very draft "Restore my changes" is about to read.
*/
const holdRef = useRef(false);
const [status, setStatus] = useState<DraftStatus>('none'); const [status, setStatus] = useState<DraftStatus>('none');
// Mirror the edits while there are any, and drop the draft once there are // Mirror the edits while there are any, and drop the draft once there are
// none — a save or a discard. Nothing is written before `load()` has set a // none — a save or a discard. Nothing is written before `load()` has set a
// base, so the empty state an editor mounts with never lands in storage. // base, so the empty state an editor mounts with never lands in storage.
useEffect(() => { useEffect(() => {
if (baseRef.current === null) return; if (baseRef.current === null || holdRef.current) return;
if (dirty) writeDraft(key, { base: baseRef.current, value }); if (dirty) writeDraft(key, { base: baseRef.current, value });
else clearDraft(key); else clearDraft(key);
}, [key, value, dirty]); }, [key, value, dirty]);
@@ -91,6 +125,7 @@ export function useDraft<T>(key: string, value: T, dirty: boolean) {
const load = useCallback( const load = useCallback(
(base: string): T | null => { (base: string): T | null => {
baseRef.current = base; baseRef.current = base;
holdRef.current = false;
const draft = readDraft<T>(key); const draft = readDraft<T>(key);
if (!draft) { if (!draft) {
setStatus('none'); setStatus('none');
@@ -100,6 +135,7 @@ export function useDraft<T>(key: string, value: T, dirty: boolean) {
setStatus('restored'); setStatus('restored');
return draft.value; return draft.value;
} }
holdRef.current = true;
setStatus('conflict'); setStatus('conflict');
return null; return null;
}, },
@@ -110,6 +146,7 @@ export function useDraft<T>(key: string, value: T, dirty: boolean) {
const saved = useCallback( const saved = useCallback(
(base: string) => { (base: string) => {
baseRef.current = base; baseRef.current = base;
holdRef.current = false;
clearDraft(key); clearDraft(key);
setStatus('none'); setStatus('none');
}, },
@@ -118,6 +155,7 @@ export function useDraft<T>(key: string, value: T, dirty: boolean) {
/** The held-back draft of a `conflict`, for "restore anyway". */ /** The held-back draft of a `conflict`, for "restore anyway". */
const takeConflicting = useCallback((): T | null => { const takeConflicting = useCallback((): T | null => {
holdRef.current = false;
const draft = readDraft<T>(key); const draft = readDraft<T>(key);
setStatus(draft ? 'restored' : 'none'); setStatus(draft ? 'restored' : 'none');
return draft?.value ?? null; return draft?.value ?? null;
@@ -125,6 +163,7 @@ export function useDraft<T>(key: string, value: T, dirty: boolean) {
/** Forget the draft. The editor reloads the server state itself. */ /** Forget the draft. The editor reloads the server state itself. */
const discard = useCallback(() => { const discard = useCallback(() => {
holdRef.current = false;
clearDraft(key); clearDraft(key);
setStatus('none'); setStatus('none');
}, [key]); }, [key]);

View File

@@ -0,0 +1,5 @@
import MessagesView from '../components/MessagesView';
export default function AdminMessagesPage() {
return <MessagesView />;
}

View File

@@ -1,15 +1,11 @@
import { redirect } from 'next/navigation'; import AdminOverview from './components/AdminOverview';
/** /**
* `/admin` and `/admin/pages` both rendered the page builder, so the same * `/admin` is the overview: what needs attention, and the site at a glance
* screen had two addresses: two routes to keep in step, two things to find * (docs/admin-ux-concept.md). It used to redirect to the page builder, which
* when something about the builder changes, and no way to tell which one a * left unread messages and doctor warnings to be found by visiting the right
* bookmark or a bug report meant. * tab. The page builder keeps its own address at `/admin/pages`.
*
* `/admin/pages` wins because that is what the Pages tab links to. `/admin`
* stays as the entry point people type and bookmark — and it is what the
* public diagnostic banner links to — so it redirects rather than disappearing.
*/ */
export default function AdminHomePage() { export default function AdminHomePage() {
redirect('/admin/pages'); return <AdminOverview />;
} }

View File

@@ -1,5 +1,15 @@
import PageBuilder from '../components/PageBuilder'; import PageBuilder from '../components/PageBuilder';
import PageHeader from '../components/PageHeader';
export default function AdminPagesPage() { export default function AdminPagesPage() {
return <PageBuilder />; return (
<>
<PageHeader
kicker="Content"
title="Pages"
description="The home page hero, standalone albums and every subpage. Drag to reorder; changes go live when saved."
/>
<PageBuilder />
</>
);
} }

View File

@@ -0,0 +1,138 @@
/* The contact form inbox (#702). */
.messages-view {
display: flex;
flex-direction: column;
gap: 1.25rem;
max-width: 900px;
}
.messages-count {
margin-left: 0.5rem;
padding: 0.1rem 0.5rem;
border-radius: 999px;
background: var(--admin-accent);
color: #fff;
font-size: 0.7rem;
font-weight: 600;
}
/* A hint, not an error: --admin-warning, never the accent (a preset may make
the accent red). */
.messages-notice {
padding: 0.65rem 0.85rem;
border: 1px solid var(--admin-warning);
border-radius: 8px;
color: var(--admin-text);
font-size: 0.82rem;
}
.messages-notice a {
color: var(--admin-warning);
text-decoration: underline;
}
.messages-empty {
color: var(--admin-text-muted);
text-align: center;
}
.messages-list {
list-style: none;
margin: 0;
padding: 0;
border: 1px solid var(--admin-border);
border-radius: 12px;
overflow: hidden;
background: var(--admin-bg-raised);
}
.messages-item + .messages-item {
border-top: 1px solid var(--admin-border);
}
.messages-summary {
display: grid;
grid-template-columns: 10px minmax(8rem, 12rem) 1fr auto;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.8rem 1rem;
background: none;
border: none;
color: var(--admin-text);
font: inherit;
font-size: 0.85rem;
text-align: left;
cursor: pointer;
}
.messages-summary:hover {
background: var(--admin-bg-hover, rgba(128, 128, 128, 0.08));
}
.messages-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.messages-item--unread .messages-dot {
background: var(--admin-accent);
}
.messages-item--unread .messages-from {
font-weight: 600;
}
.messages-from,
.messages-preview {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.messages-preview,
.messages-date {
color: var(--admin-text-muted);
}
.messages-date {
font-size: 0.75rem;
white-space: nowrap;
}
.messages-body {
padding: 0 1rem 1rem calc(1rem + 10px + 0.75rem);
}
.messages-meta {
margin: 0 0 0.5rem;
font-size: 0.8rem;
color: var(--admin-text-muted);
}
.messages-text {
margin: 0 0 1rem;
white-space: pre-wrap;
line-height: 1.55;
font-size: 0.9rem;
}
.messages-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
@media (max-width: 640px) {
.messages-summary {
grid-template-columns: 10px 1fr auto;
}
.messages-preview {
display: none;
}
.messages-body {
padding-left: 1rem;
}
}

View File

@@ -0,0 +1,100 @@
/* The admin notification stack (#600), bottom right, above the save bar. */
.admin-notifications {
position: fixed;
right: 1.25rem;
bottom: 5.5rem;
z-index: 1100;
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: min(420px, calc(100vw - 2.5rem));
pointer-events: none;
}
.admin-notifications__group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.admin-notification {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.7rem 0.8rem 0.7rem 1rem;
border-radius: 10px;
border: 1px solid var(--admin-border);
border-left-width: 4px;
background: var(--admin-bg-raised);
color: var(--admin-text);
font-size: 0.85rem;
line-height: 1.45;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
pointer-events: auto;
}
.admin-notification--success {
border-left-color: var(--admin-success, #10b981);
}
.admin-notification--error {
border-left-color: var(--admin-error);
}
.admin-notification__text {
flex: 1;
overflow-wrap: anywhere;
}
.admin-notification__close {
display: inline-flex;
padding: 2px;
background: none;
border: none;
border-radius: 4px;
color: var(--admin-text-muted);
cursor: pointer;
}
.admin-notification__close:hover {
color: var(--admin-text);
}
@media (max-width: 640px) {
.admin-notifications {
left: 1rem;
right: 1rem;
max-width: none;
}
}
/* AdminLoadState (#609): the shared failed-load panel. */
.admin-load-error {
padding: 2rem;
text-align: center;
border: 1px solid var(--admin-border);
border-radius: 12px;
background: var(--admin-bg-raised);
}
.admin-load-error p {
margin: 0 0 1rem;
color: var(--admin-text-muted);
}
.admin-notification__action {
padding: 0.1rem 0.5rem;
border: 1px solid var(--admin-border-hover);
border-radius: 4px;
background: none;
color: var(--admin-text);
font: inherit;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
}
.admin-notification__action:hover {
background: var(--admin-bg-elevated);
}

File diff suppressed because it is too large Load Diff

View File

@@ -635,3 +635,71 @@
.status-val.unknown { .status-val.unknown {
color: var(--admin-text-secondary); color: var(--admin-text-secondary);
} }
.admin-field-hint--error {
color: var(--admin-error);
}
/* A link out of a section header, e.g. to the page the section configures.
It shows the saved state, not unsaved edits. */
.settings-section-action {
display: inline-flex;
margin-top: 0.6rem;
}
/* ── Privacy policy editor (#699) ──────────────────────────────── */
.privacy-editor {
display: flex;
flex-direction: column;
gap: 1rem;
}
.privacy-editor__facts {
padding: 1rem 1.1rem;
border: 1px solid var(--admin-border);
border-radius: 10px;
background: var(--admin-bg-raised);
}
.privacy-editor__facts h4 {
margin: 0 0 0.25rem;
font-size: 0.9rem;
}
.privacy-editor__facts ul {
margin: 0.75rem 0 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.privacy-editor__facts li {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 0.5rem;
font-size: 0.8rem;
line-height: 1.5;
}
.privacy-editor__facts li span:last-child {
flex-basis: 100%;
color: var(--admin-text-muted);
}
.privacy-editor__badge {
padding: 0 0.4rem;
border: 1px solid var(--admin-warning);
border-radius: 999px;
color: var(--admin-warning);
font-size: 0.7rem;
}
.privacy-editor__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}

View File

@@ -323,3 +323,53 @@
.password-input-wrapper input { .password-input-wrapper input {
padding-left: 2.25rem !important; padding-left: 2.25rem !important;
} }
/* ── PasswordField (#690) ──────────────────────────────────────── */
.password-field {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.password-field--set {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.password-field__status {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin-right: auto;
font-size: 0.85rem;
color: var(--admin-text);
}
.password-field .password-input-wrapper input {
padding-right: 2.5rem !important;
}
.password-field__reveal {
position: absolute;
right: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: none;
border: none;
border-radius: 4px;
color: var(--admin-text-muted);
cursor: pointer;
}
.password-field__reveal:hover {
color: var(--admin-text);
}
.password-field__cancel {
align-self: flex-start;
}

View File

@@ -37,6 +37,8 @@ const config = {
subpages: [], subpages: [],
albumPasswords: {}, albumPasswords: {},
theme: { accent: '#e60012', fonts: { heading: 'Inter', body: 'Inter', caption: 'Inter' } }, theme: { accent: '#e60012', fonts: { heading: 'Inter', body: 'Inter', caption: 'Inter' } },
contact: { enabled: true, retentionDays: 90 },
siteUrl: null,
}; };
vi.mock('@/lib/config', () => ({ vi.mock('@/lib/config', () => ({
@@ -89,6 +91,14 @@ const GATED: { name: string; call: () => Promise<Response> }[] = [
{ params: Promise.resolve({ album: 'tok' }) } as never, { params: Promise.resolve({ album: 'tok' }) } as never,
), ),
}, },
{
// Stores whatever a visitor sends; a locked site accepts nothing from strangers.
name: 'POST /api/contact',
call: async () =>
(await import('../contact/route')).POST(
new NextRequest('http://localhost/api/contact', { method: 'POST', body: '{}' }) as never,
),
},
]; ];
describe('public content routes behind a locked site', () => { describe('public content routes behind a locked site', () => {
@@ -142,4 +152,24 @@ describe('routes that must stay open', () => {
); );
expect(res.status).toBe(200); expect(res.status).toBe(200);
}); });
// The password gate is set in the theme's fonts; locking them would render
// the gate itself in fallback fonts. Three public font names give nothing away.
it('GET /api/fonts/css answers a locked site', async () => {
config.sitePassword = 'letmein';
const res = await (
await import('../fonts/css/route')
).GET(request('/api/fonts/css?family=Inter%3Ax') as never);
expect(res.status).not.toBe(401);
});
it('GET /api/fonts/file/[name] answers a locked site', async () => {
config.sitePassword = 'letmein';
const res = await (
await import('../fonts/file/[name]/route')
).GET(request('/api/fonts/file/x.woff2') as never, {
params: Promise.resolve({ name: 'x.woff2' }),
});
expect(res.status).not.toBe(401);
});
}); });

View File

@@ -22,7 +22,7 @@ const ROUTES: {
name: string; name: string;
path: string; path: string;
load: () => Promise<Record<string, unknown>>; load: () => Promise<Record<string, unknown>>;
method: 'GET' | 'PUT' | 'POST' | 'DELETE'; method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'PATCH';
args: () => unknown[]; args: () => unknown[];
}[] = [ }[] = [
{ {
@@ -124,6 +124,44 @@ const ROUTES: {
method: 'GET', method: 'GET',
args: () => [], args: () => [],
}, },
{
// Names and email addresses of everyone who used the contact form.
name: 'GET /api/admin/messages',
path: 'messages',
load: () => import('../messages/route'),
method: 'GET',
args: () => [],
},
{
name: 'PATCH /api/admin/messages',
path: 'messages',
load: () => import('../messages/route'),
method: 'PATCH',
args: () => [
new NextRequest('http://localhost/api/admin/messages', { method: 'PATCH', body: '{}' }),
],
},
{
name: 'DELETE /api/admin/messages',
path: 'messages',
load: () => import('../messages/route'),
method: 'DELETE',
args: () => [new NextRequest('http://localhost/api/admin/messages?id=x', { method: 'DELETE' })],
},
{
name: 'GET /api/admin/privacy',
path: 'privacy',
load: () => import('../privacy/route'),
method: 'GET',
args: () => [],
},
{
name: 'PUT /api/admin/privacy',
path: 'privacy',
load: () => import('../privacy/route'),
method: 'PUT',
args: () => [new Request('http://localhost/api/admin/privacy', { method: 'PUT', body: '{}' })],
},
{ {
name: 'GET /api/admin/backups', name: 'GET /api/admin/backups',
path: 'backups', path: 'backups',

View File

@@ -26,6 +26,15 @@ const baseConfig = {
sitePassword: '', sitePassword: '',
immich: { apiUrl: '', apiKey: '' }, immich: { apiUrl: '', apiKey: '' },
immichTimeoutMs: 1000, immichTimeoutMs: 1000,
legal: {
enabled: false,
name: '',
address: '',
zipCity: '',
country: '',
} as Record<string, unknown>,
contact: { enabled: false, retentionDays: 90 } as Record<string, unknown>,
privacy: { enabled: true },
}; };
const getConfigMock = vi.fn(() => baseConfig); const getConfigMock = vi.fn(() => baseConfig);
@@ -40,6 +49,8 @@ vi.mock('@/lib/config', () => ({
vi.mock('@/lib/env', () => ({ env: { AUTH_SECRET: 'x'.repeat(64) } })); vi.mock('@/lib/env', () => ({ env: { AUTH_SECRET: 'x'.repeat(64) } }));
vi.mock('@/lib/admin/journal-service', () => ({ listJournalEntries: async () => [] })); vi.mock('@/lib/admin/journal-service', () => ({ listJournalEntries: async () => [] }));
const readSettingsYamlMock = vi.fn(async (): Promise<unknown> => null);
vi.mock('@/lib/admin/yaml-service', () => ({ readSettingsYaml: () => readSettingsYamlMock() }));
import { GET } from '../doctor/route'; import { GET } from '../doctor/route';
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
@@ -215,3 +226,36 @@ describe('GET /api/admin/doctor — album slug collisions', () => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
}); });
/**
* resolveLegal() drops a non-http(s) contact URL and only tells the server
* log. The doctor reads the raw settings.yaml so the admin sees why the link
* is gone.
*/
describe('GET /api/admin/doctor — Impressum', () => {
const complete = {
enabled: true,
name: 'Ralf',
address: 'Street 1',
zipCity: '10247 Berlin',
country: 'Germany',
email: 'mail@example.com',
};
it('stays out of the report while the page is off', async () => {
const body = await (await GET(req())).json();
expect(body.findings.find((f: { id: string }) => f.id === 'legal')).toBeUndefined();
});
it('names a contact URL that the page dropped', async () => {
readSettingsYamlMock.mockResolvedValueOnce({
legal: { ...complete, contactUrl: 'javascript:alert(1)' },
});
const body = await (await GET(req())).json();
const finding = body.findings.find((f: { id: string }) => f.id === 'legal');
expect(finding?.level).toBe('warn');
expect(finding?.detail).toContain('contact form URL is ignored');
});
});

View File

@@ -75,3 +75,23 @@ describe('PUT /api/admin/settings', () => {
expect(writeSettingsYaml).toHaveBeenCalledOnce(); expect(writeSettingsYaml).toHaveBeenCalledOnce();
}); });
}); });
/** The panel sends the site password as typed; the file must not keep it that way (#690). */
describe('PUT /api/admin/settings — site password', () => {
beforeEach(() => vi.clearAllMocks());
it('writes the site password as an scrypt hash', async () => {
const res = await put({ title: 'Folio', sitePassword: 'letmein' });
expect(res.status).toBe(200);
const written = vi.mocked(writeSettingsYaml).mock.calls[0][0] as { sitePassword: string };
expect(written.sitePassword).toMatch(/^scrypt:/);
expect(JSON.stringify(written)).not.toContain('letmein');
});
it('leaves an open site open', async () => {
await put({ title: 'Folio', sitePassword: '' });
const written = vi.mocked(writeSettingsYaml).mock.calls[0][0] as { sitePassword: string };
expect(written.sitePassword).toBe('');
});
});

View File

@@ -6,7 +6,7 @@ import { listJournalBackups, restoreJournalBackup } from '@/lib/admin/journal-se
import { invalidateConfigCache } from '@/lib/config'; import { invalidateConfigCache } from '@/lib/config';
import { immich } from '@/lib/immich'; import { immich } from '@/lib/immich';
export type BackupTarget = 'gallery' | 'settings' | 'about' | 'journal'; export type BackupTarget = 'gallery' | 'settings' | 'about' | 'privacy' | 'journal';
export interface BackupItem { export interface BackupItem {
filename: string; filename: string;
@@ -51,6 +51,7 @@ export const GET = withAdmin(async () => {
parseBackupInfo(f, 'settings'), parseBackupInfo(f, 'settings'),
); );
const about = (await listBackups('about.md')).map((f) => parseBackupInfo(f, 'about')); const about = (await listBackups('about.md')).map((f) => parseBackupInfo(f, 'about'));
const privacy = (await listBackups('privacy.md')).map((f) => parseBackupInfo(f, 'privacy'));
// Newest first across all entries, so a just-deleted entry sits on top. // Newest first across all entries, so a just-deleted entry sits on top.
const journal = (await listJournalBackups()) const journal = (await listJournalBackups())
.map((b) => ({ .map((b) => ({
@@ -65,6 +66,7 @@ export const GET = withAdmin(async () => {
gallery, gallery,
settings, settings,
about, about,
privacy,
journal, journal,
}, },
}); });

View File

@@ -5,12 +5,18 @@ import path from 'node:path';
import { getConfig, slugify } from '@/lib/config'; import { getConfig, slugify } from '@/lib/config';
import { env } from '@/lib/env'; import { env } from '@/lib/env';
import { listJournalEntries } from '@/lib/admin/journal-service'; import { listJournalEntries } from '@/lib/admin/journal-service';
import { readSettingsYaml } from '@/lib/admin/yaml-service';
import { readPrivacy } from '@/lib/privacy';
import { import {
checkAlbumIds, checkAlbumIds,
checkAlbumSlugCollisions, checkAlbumSlugCollisions,
checkAlbumsShared, checkAlbumsShared,
checkAuthSecret, checkAuthSecret,
checkCdn,
checkImmichCalls, checkImmichCalls,
checkContact,
checkLegal,
checkPrivacy,
checkPasswords, checkPasswords,
checkProxyHops, checkProxyHops,
checkWritable, checkWritable,
@@ -20,6 +26,8 @@ import {
type AlbumRef, type AlbumRef,
type AlbumSlugGroup, type AlbumSlugGroup,
type DoctorFinding, type DoctorFinding,
type ContactRef,
type LegalRef,
type PasswordRef, type PasswordRef,
} from '@/lib/admin/doctor'; } from '@/lib/admin/doctor';
@@ -44,6 +52,9 @@ export const GET = withAdmin(async (request: NextRequest) => {
), ),
); );
const cdn = checkCdn(env.CDN_URL, !!config.sitePassword, config.trustedProxyHops);
if (cdn) findings.push(cdn);
// ── Immich: the three calls Folio actually depends on ──────────────── // ── Immich: the three calls Folio actually depends on ────────────────
const calls: Array<{ endpoint: string; ok: boolean }> = []; const calls: Array<{ endpoint: string; ok: boolean }> = [];
let albums: AlbumRef[] = []; let albums: AlbumRef[] = [];
@@ -135,6 +146,27 @@ export const GET = withAdmin(async (request: NextRequest) => {
} }
findings.push(checkPasswords(passwords)); findings.push(checkPasswords(passwords));
// ── Impressum and contact form: judged on the raw blocks, see checkLegal ──
let rawLegal: LegalRef = config.legal;
let rawContact: ContactRef = config.contact;
try {
const settings = await readSettingsYaml();
rawLegal = settings?.legal ?? rawLegal;
rawContact = settings?.contact ?? rawContact;
} catch {
// Fall back to the resolved blocks; only a dropped URL goes unseen.
}
const legal = checkLegal(rawLegal, config.contact.enabled);
if (legal) findings.push(legal);
const contact = checkContact(rawContact, env.CONTACT_NOTIFY_URL);
if (contact) findings.push(contact);
const privacy = checkPrivacy({
legalEnabled: config.legal.enabled,
privacyEnabled: config.privacy.enabled,
hasText: readPrivacy() !== '',
});
if (privacy) findings.push(privacy);
// ── Writability of the content volume ──────────────────────────────── // ── Writability of the content volume ────────────────────────────────
const contentDir = path.join(process.cwd(), 'content'); const contentDir = path.join(process.cwd(), 'content');
const unwritable: string[] = []; const unwritable: string[] = [];

View File

@@ -5,6 +5,9 @@ import { readGalleryYaml, writeGalleryYaml } from '@/lib/admin/yaml-service';
import { invalidateConfigCache, deriveGallery } from '@/lib/config'; import { invalidateConfigCache, deriveGallery } from '@/lib/config';
import { immich } from '@/lib/immich'; import { immich } from '@/lib/immich';
import type { GalleryYaml } from '@/lib/config/schema'; import type { GalleryYaml } from '@/lib/config/schema';
import { hashPasswordKeys } from '@/lib/admin/passwordHashing';
const PASSWORD_KEY = new Set(['password']);
/** GET: Read current gallery.yaml config. */ /** GET: Read current gallery.yaml config. */
export const GET = withAdmin(async () => { export const GET = withAdmin(async () => {
@@ -44,7 +47,13 @@ export const PUT = withAdmin(async (request: Request) => {
} }
try { try {
await writeGalleryYaml(gallery); // Subpage and album passwords, wherever the entry sits (#690).
const toWrite = await hashPasswordKeys(
gallery,
PASSWORD_KEY,
await readGalleryYaml().catch(() => null),
);
await writeGalleryYaml(toWrite);
invalidateConfigCache(); invalidateConfigCache();
immich.invalidateAll(); immich.invalidateAll();
// Revalidate all pages so the homepage picks up new hero images immediately // Revalidate all pages so the homepage picks up new hero images immediately
@@ -53,6 +62,8 @@ export const PUT = withAdmin(async (request: Request) => {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
message: 'Saved successfully. Backup of previous version created.', message: 'Saved successfully. Backup of previous version created.',
// What was written, passwords hashed, so the editor can take it over.
gallery: toWrite,
}); });
} catch (err) { } catch (err) {
console.error('[Admin] Failed to write gallery.yaml:', err); console.error('[Admin] Failed to write gallery.yaml:', err);

View File

@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { withAdmin } from '@/lib/admin/withAdmin'; import { withAdmin } from '@/lib/admin/withAdmin';
import { isValidSlug, sanitizeSlug, parseJournalMarkdown } from '@/lib/journal'; import { isValidSlug, sanitizeSlug, parseJournalMarkdown } from '@/lib/journal';
import { hashFrontmatterPassword } from '@/lib/admin/passwordHashing';
import { import {
readJournalEntry, readJournalEntry,
writeJournalEntry, writeJournalEntry,
@@ -59,10 +60,18 @@ export const PUT = withAdmin(async (request: Request, context: RouteContext) =>
if (existing) { if (existing) {
return NextResponse.json({ error: 'Target slug already exists' }, { status: 409 }); return NextResponse.json({ error: 'Target slug already exists' }, { status: 409 });
} }
await writeJournalEntry(targetSlug, rawMarkdown); }
// The entry's password is stored hashed (#690). The file as it is now
// supplies the hash to keep when the password did not change.
const current = await readJournalEntry(slug);
const markdown = await hashFrontmatterPassword(rawMarkdown, current?.rawMarkdown ?? null);
if (targetSlug !== slug) {
await writeJournalEntry(targetSlug, markdown);
await deleteJournalEntry(slug); await deleteJournalEntry(slug);
} else { } else {
await writeJournalEntry(slug, rawMarkdown); await writeJournalEntry(slug, markdown);
} }
const updated = await readJournalEntry(targetSlug); const updated = await readJournalEntry(targetSlug);

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAdmin } from '@/lib/admin/withAdmin';
import { getConfig } from '@/lib/config';
import { deleteMessage, isMessageId, listMessages, setRead } from '@/lib/contact';
/**
* The contact form inbox (#702). Listing also applies the retention period,
* so opening the inbox is enough to purge what has expired.
*/
export const GET = withAdmin(async () => {
const { contact } = getConfig();
const messages = await listMessages(contact.retentionDays);
return NextResponse.json(
{
enabled: contact.enabled,
notifyConfigured: !!contact.notifyUrl,
retentionDays: contact.retentionDays,
messages,
},
{ headers: { 'Cache-Control': 'no-store' } },
);
});
/** PATCH { id, read } — mark a message read or unread. */
export const PATCH = withAdmin(async (request: NextRequest) => {
const body = (await request.json().catch(() => null)) as { id?: unknown; read?: unknown } | null;
if (!body || !isMessageId(body.id) || typeof body.read !== 'boolean') {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
return (await setRead(body.id, body.read))
? NextResponse.json({ ok: true })
: NextResponse.json({ error: 'Not found' }, { status: 404 });
});
/** DELETE ?id=<id> */
export const DELETE = withAdmin(async (request: NextRequest) => {
const id = request.nextUrl.searchParams.get('id');
if (!isMessageId(id)) {
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
}
return (await deleteMessage(id))
? NextResponse.json({ ok: true })
: NextResponse.json({ error: 'Not found' }, { status: 404 });
});

View File

@@ -0,0 +1,78 @@
import { NextResponse } from 'next/server';
import { promises as fs } from 'fs';
import path from 'path';
import { revalidatePath } from 'next/cache';
import { withAdmin } from '@/lib/admin/withAdmin';
import { atomicWrite } from '@/lib/atomicWrite';
import { getConfig } from '@/lib/config';
import { env } from '@/lib/env';
import { PRIVACY_FILENAME, processingFacts, readPrivacy, starterHeadings } from '@/lib/privacy';
const CONTENT_DIR = path.resolve(process.cwd(), 'content');
const MAX_BACKUPS = 10;
/** Far beyond any real policy; keeps a runaway paste out of the content volume. */
const MAX_LENGTH = 200_000;
/**
* The privacy policy (#699). GET also returns what this installation
* processes (lib/privacy.ts), which the editor shows next to the text.
*/
export const GET = withAdmin(async () => {
const config = getConfig();
const facts = processingFacts(config, {
CDN_URL: env.CDN_URL,
hasPasswords:
!!config.sitePassword ||
config.subpages.some((sp) => !!sp.password) ||
Object.values(config.albumPasswords).some(Boolean),
});
return NextResponse.json(
{
body: readPrivacy(CONTENT_DIR),
enabled: config.privacy.enabled,
facts,
starter: starterHeadings(config.lang, facts),
},
{ headers: { 'Cache-Control': 'no-store' } },
);
});
/** PUT { body } — writes content/privacy.md, keeping a backup like about.md. */
export const PUT = withAdmin(async (request: Request) => {
const data = (await request.json().catch(() => null)) as { body?: unknown } | null;
if (!data || typeof data.body !== 'string' || data.body.length > MAX_LENGTH) {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}
const filePath = path.join(CONTENT_DIR, PRIVACY_FILENAME);
await fs.mkdir(CONTENT_DIR, { recursive: true });
// Same rule as the about route (#630): only a missing file means there is
// nothing to back up. Any other failure aborts before the live file changes.
let fileExists = true;
try {
await fs.access(filePath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
fileExists = false;
}
if (fileExists) {
const backupDir = path.join(CONTENT_DIR, '.backups');
await fs.mkdir(backupDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
await fs.copyFile(filePath, path.join(backupDir, `${PRIVACY_FILENAME}.${timestamp}.bak`));
const backups = (await fs.readdir(backupDir))
.filter((e) => e.startsWith(PRIVACY_FILENAME) && e.endsWith('.bak'))
.sort();
while (backups.length > MAX_BACKUPS) {
await fs.unlink(path.join(backupDir, backups.shift()!));
}
}
const body = data.body.trim();
await atomicWrite(filePath, body ? `${body}\n` : '');
revalidatePath('/', 'layout');
return NextResponse.json({ success: true, message: 'Privacy policy saved.' });
});

View File

@@ -5,6 +5,9 @@ import { readSettingsYaml, writeSettingsYaml } from '@/lib/admin/yaml-service';
import { invalidateConfigCache, getConfigOrNull } from '@/lib/config'; import { invalidateConfigCache, getConfigOrNull } from '@/lib/config';
import { immich } from '@/lib/immich'; import { immich } from '@/lib/immich';
import { validateSettings } from '@/lib/config/settingsSchema'; import { validateSettings } from '@/lib/config/settingsSchema';
import { hashPasswordKeys } from '@/lib/admin/passwordHashing';
const SITE_PASSWORD_KEY = new Set(['sitePassword']);
import type { SettingsYaml } from '@/lib/config/schema'; import type { SettingsYaml } from '@/lib/config/schema';
/** GET: Read current settings.yaml config. */ /** GET: Read current settings.yaml config. */
@@ -42,9 +45,13 @@ export const PUT = withAdmin(async (request: Request) => {
); );
} }
const settings = body.settings as SettingsYaml;
try { try {
// Only the top-level site password: settings.yaml has no other password.
const settings = await hashPasswordKeys(
body.settings as SettingsYaml,
SITE_PASSWORD_KEY,
await readSettingsYaml().catch(() => null),
);
await writeSettingsYaml(settings); await writeSettingsYaml(settings);
invalidateConfigCache(); invalidateConfigCache();
immich.invalidateAll(); immich.invalidateAll();
@@ -52,6 +59,8 @@ export const PUT = withAdmin(async (request: Request) => {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
message: 'Saved successfully. Backup of previous version created.', message: 'Saved successfully. Backup of previous version created.',
// The stored site password, hashed, so the field can read "Protected".
sitePassword: settings.sitePassword,
}); });
} catch (err) { } catch (err) {
console.error('[Admin] Failed to write settings.yaml:', err); console.error('[Admin] Failed to write settings.yaml:', err);

71
app/api/contact/route.ts Normal file
View File

@@ -0,0 +1,71 @@
/**
* Contact form submission (#702). POST /api/contact
* { name, email, message, website (honeypot), startedAt }
*
* The message is stored under content/messages/ and the owner is notified
* through contact.notifyUrl when one is set (lib/contact.ts).
*/
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
import { siteLockResponse } from '@/lib/auth';
import { notifyNewMessage, saveMessage, validateContact } from '@/lib/contact';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
/** A person sends one message, maybe a correction. Three a minute is generous. */
const CONTACT_RPM = 3;
/** Bodies larger than any valid message plus its fields are refused unread. */
const MAX_BODY_BYTES = 16 * 1024;
export async function POST(request: NextRequest) {
const config = getConfig();
if (!config.contact.enabled) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
// The page sits behind the site password; so does the endpoint it posts to.
const locked = siteLockResponse(request);
if (locked) return locked;
const ip = getClientIp(request);
const { success, resetAt } = checkRateLimit(`contact:${ip}`, CONTACT_RPM);
if (!success) {
return NextResponse.json(
{ error: 'Too many requests' },
{ status: 429, headers: { 'Retry-After': String(retryAfterSeconds(resetAt)) } },
);
}
if (Number(request.headers.get('content-length') ?? 0) > MAX_BODY_BYTES) {
return NextResponse.json({ error: 'Too large' }, { status: 413 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
const result = validateContact((body ?? {}) as Record<string, unknown>);
if (!result.ok) {
// A bot gets the same answer as a person, so it has nothing to tune against.
if (result.reason === 'spam') return NextResponse.json({ ok: true });
return NextResponse.json({ error: 'Invalid message' }, { status: 400 });
}
const saved = await saveMessage(
{ name: result.name, email: result.email, message: result.message },
config.contact.retentionDays,
);
if (saved === 'full') {
console.warn('[Folio] contact: inbox is full, message refused');
return NextResponse.json({ error: 'Inbox full' }, { status: 503 });
}
const inboxUrl = config.siteUrl ? `${config.siteUrl.replace(/\/$/, '')}/admin/messages` : null;
await notifyNewMessage(config.contact.notifyUrl, inboxUrl);
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
import { getGoogleCss, parseFamilies, rewriteCss } from '@/lib/fonts';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
/**
* The theme's font stylesheet, fetched from Google by the server and served
* from this origin (lib/fonts.ts).
*
* Not behind the site password: the password gate itself is set in the
* theme's fonts, and a stylesheet naming three public fonts gives nothing away.
*/
export async function GET(request: NextRequest) {
const ip = getClientIp(request);
const { success, resetAt } = checkRateLimit(`fonts:${ip}`, getConfig().rateLimitRpm);
if (!success) {
return new NextResponse(null, {
status: 429,
headers: { 'Retry-After': String(retryAfterSeconds(resetAt)), 'Cache-Control': 'no-store' },
});
}
const families = parseFamilies(request.nextUrl.searchParams.getAll('family'));
if (!families) {
return new NextResponse('/* invalid font families */', {
status: 400,
headers: { 'Content-Type': 'text/css; charset=utf-8', 'Cache-Control': 'no-store' },
});
}
const css = await getGoogleCss(families);
if (css === null) {
// The theme CSS names a system fallback after every family, so an empty
// stylesheet degrades to those. A short max-age lets the page pick the
// fonts up once Google answers again.
return new NextResponse('/* fonts unavailable, using system fallbacks */', {
headers: {
'Content-Type': 'text/css; charset=utf-8',
'Cache-Control': 'public, max-age=300',
},
});
}
return new NextResponse(rewriteCss(css), {
headers: {
'Content-Type': 'text/css; charset=utf-8',
'Cache-Control': 'public, max-age=86400',
},
});
}

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
import { getFontFile } from '@/lib/fonts';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
/**
* One font file named by a stylesheet from /api/fonts/css (lib/fonts.ts).
* The name is a hash of the gstatic URL, so a file never changes under it.
*/
export async function GET(request: NextRequest, { params }: { params: Promise<{ name: string }> }) {
const ip = getClientIp(request);
const { success, resetAt } = checkRateLimit(`fonts:${ip}`, getConfig().rateLimitRpm);
if (!success) {
return new NextResponse(null, {
status: 429,
headers: { 'Retry-After': String(retryAfterSeconds(resetAt)), 'Cache-Control': 'no-store' },
});
}
const data = await getFontFile((await params).name);
if (!data) {
return new NextResponse(null, { status: 404, headers: { 'Cache-Control': 'no-store' } });
}
return new NextResponse(data as BodyInit, {
headers: {
'Content-Type': 'font/woff2',
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
}

112
app/contact/contact.css Normal file
View File

@@ -0,0 +1,112 @@
.contact-page {
padding: 4rem 2rem;
min-height: 80vh;
}
.contact-page__header {
margin-bottom: 3rem;
text-align: center;
}
.contact-page__title {
font-family: var(--font-serif);
font-size: clamp(2.5rem, 8vw, 4rem);
font-weight: 400;
margin-bottom: 1rem;
}
.contact-page__subtitle {
font-family: var(--font-caption);
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
}
.contact-page__content {
max-width: 560px;
margin: 0 auto;
}
.contact-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.contact-form__field {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.contact-form__label {
font-family: var(--font-caption);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-secondary);
}
.contact-form__input {
width: 100%;
padding: 12px 14px;
background: var(--bg-secondary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-primary);
font-family: var(--font-sans);
font-size: 1rem;
transition: border-color var(--transition-fast);
}
.contact-form__input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-color: var(--accent);
}
textarea.contact-form__input {
min-height: 10rem;
resize: vertical;
line-height: 1.5;
}
/* The honeypot. Off-screen rather than display:none, which some bots skip. */
.contact-form__trap {
position: absolute;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
}
.contact-form__privacy {
font-size: 0.85rem;
line-height: 1.5;
color: var(--text-muted);
}
.contact-form__submit {
align-self: flex-start;
padding: 12px 28px;
background: var(--accent);
color: var(--on-accent);
border: none;
border-radius: var(--radius-md);
font-family: var(--font-sans);
font-size: 0.9rem;
font-weight: 500;
letter-spacing: 0.05em;
cursor: pointer;
transition: opacity var(--transition-fast);
}
.contact-form__submit:disabled {
opacity: 0.6;
cursor: wait;
}
.contact-form__error {
color: #e74c3c;
font-size: 0.9rem;
}
.contact-form__done {
text-align: center;
}
.contact-form__done h2 {
font-family: var(--font-serif);
font-weight: 400;
font-size: 1.8rem;
margin-bottom: 0.75rem;
}
.contact-form__done p {
color: var(--text-secondary);
line-height: 1.6;
}

43
app/contact/page.tsx Normal file
View File

@@ -0,0 +1,43 @@
/**
* Contact page (#702). The form posts to /api/contact, which stores the
* message on this server; see lib/contact.ts.
*/
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getConfig } from '@/lib/config';
import { BackLink } from '@/components/BackLink';
import ContactForm from '@/components/ContactForm';
import { getServerDictionary } from '@/lib/i18n/server';
import './contact.css';
export function generateMetadata(): Metadata {
// No title for a page that is switched off: it renders the 404 page.
if (!getConfig().contact.enabled) return {};
return { title: getServerDictionary().contact.title };
}
export const dynamic = 'force-dynamic';
export default function ContactPage() {
const { contact } = getConfig();
const t = getServerDictionary();
if (!contact.enabled) {
notFound();
}
return (
<div className="contact-page">
<header className="contact-page__header">
<BackLink href="/" label={t.common.home} />
<h1 className="contact-page__title">{t.contact.title}</h1>
<p className="contact-page__subtitle">{t.contact.subtitle}</p>
</header>
<main className="contact-page__content">
<ContactForm retentionDays={contact.retentionDays} />
</main>
</div>
);
}

View File

@@ -1645,7 +1645,7 @@
.empty-state__button { .empty-state__button {
padding: 12px 24px; padding: 12px 24px;
background: var(--accent); background: var(--accent);
color: var(--bg-primary); color: var(--on-accent);
border: none; border: none;
border-radius: var(--radius-md); border-radius: var(--radius-md);
font-family: var(--font-sans); font-family: var(--font-sans);

View File

@@ -1,18 +1,18 @@
.subpage-container { .legal-page {
padding: 4rem 2rem; padding: 4rem 2rem;
min-height: 80vh; min-height: 80vh;
} }
.subpage-header { .legal-header {
margin-bottom: 4rem; margin-bottom: 4rem;
text-align: center; text-align: center;
} }
.subpage-title { .legal-title {
font-family: var(--font-serif); font-family: var(--font-serif);
font-size: clamp(2.5rem, 8vw, 4rem); font-size: clamp(2.5rem, 8vw, 4rem);
font-weight: 400; font-weight: 400;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.subpage-subtitle { .legal-subtitle {
font-family: var(--font-caption); font-family: var(--font-caption);
font-size: 1rem; font-size: 1rem;
text-transform: uppercase; text-transform: uppercase;
@@ -21,7 +21,7 @@
an opacity on top of them is not. */ an opacity on top of them is not. */
color: var(--text-muted); color: var(--text-muted);
} }
.subpage-content { .legal-content {
max-width: 600px; max-width: 600px;
margin: 0 auto; margin: 0 auto;
} }
@@ -39,8 +39,14 @@
.legal-section__text--pre { .legal-section__text--pre {
white-space: pre-wrap; white-space: pre-wrap;
} }
.legal-source { /* tokens.css strips link underlines site-wide; here the links sit inside
margin-top: 4rem; running text and would otherwise be indistinguishable from it. */
font-size: 0.85rem; .legal-section__text a {
color: var(--text-muted); text-decoration: underline;
text-underline-offset: 3px;
text-decoration-color: var(--text-muted);
transition: text-decoration-color var(--transition-fast);
}
.legal-section__text a:hover {
text-decoration-color: currentColor;
} }

View File

@@ -7,10 +7,14 @@ import { Metadata } from 'next';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { getConfig } from '@/lib/config'; import { getConfig } from '@/lib/config';
import { BackLink } from '@/components/BackLink'; import { BackLink } from '@/components/BackLink';
import EmailLink from '@/components/EmailLink';
import { encodeEmail, spelledOutEmail } from '@/lib/emailObfuscation';
import { getServerDictionary } from '@/lib/i18n/server'; import { getServerDictionary } from '@/lib/i18n/server';
import './impressum.css'; import './impressum.css';
export function generateMetadata(): Metadata { export function generateMetadata(): Metadata {
// No title for a page that is switched off: it renders the 404 page.
if (!getConfig().legal.enabled) return {};
return { return {
title: getServerDictionary().legal.title, title: getServerDictionary().legal.title,
robots: { index: false, follow: true }, // Usually no need to index legal pages robots: { index: false, follow: true }, // Usually no need to index legal pages
@@ -19,23 +23,31 @@ export function generateMetadata(): Metadata {
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
/** `tel:` takes digits and a leading +; the displayed number keeps its spacing. */
function telHref(phone: string): string {
return `tel:${phone.replace(/[^\d+]/g, '')}`;
}
export default function ImpressumPage() { export default function ImpressumPage() {
const { legal } = getConfig(); const { legal, contact } = getConfig();
const t = getServerDictionary(); const t = getServerDictionary();
// The built-in form stands in as the second contact channel unless an
// external one is configured.
const contactUrl = legal.contactUrl ?? (contact.enabled ? '/contact' : undefined);
if (!legal.enabled) { if (!legal.enabled) {
notFound(); notFound();
} }
return ( return (
<div className="subpage-container"> <div className="legal-page">
<header className="subpage-header"> <header className="legal-header">
<BackLink href="/" label={t.common.home} /> <BackLink href="/" label={t.common.backToGallery} />
<h1 className="subpage-title">{t.legal.title}</h1> <h1 className="legal-title">{t.legal.title}</h1>
<p className="subpage-subtitle">{t.legal.subtitle}</p> <p className="legal-subtitle">{legal.heading ?? t.legal.subtitle}</p>
</header> </header>
<main className="subpage-content"> <div className="legal-content">
<section className="legal-section"> <section className="legal-section">
<h2 className="legal-section__title">{t.legal.address}</h2> <h2 className="legal-section__title">{t.legal.address}</h2>
<p className="legal-section__text"> <p className="legal-section__text">
@@ -49,21 +61,29 @@ export default function ImpressumPage() {
</p> </p>
</section> </section>
{(legal.email || legal.phone) && ( {(legal.email || legal.phone || contactUrl) && (
<section className="legal-section"> <section className="legal-section">
<h2 className="legal-section__title">{t.legal.contact}</h2> <h2 className="legal-section__title">{t.legal.contact}</h2>
<p className="legal-section__text"> <p className="legal-section__text">
{legal.email && ( {legal.email && (
<> <>
{t.legal.email}: {legal.email} {t.legal.email}: <EmailLink encoded={encodeEmail(legal.email)} />
{/* § 5 DDG wants the address reachable without JavaScript too. */}
<noscript>{spelledOutEmail(legal.email)}</noscript>
<br /> <br />
</> </>
)} )}
{legal.phone && ( {legal.phone && (
<> <>
{t.legal.phone}: {legal.phone} {t.legal.phone}: <a href={telHref(legal.phone)}>{legal.phone}</a>
<br />
</> </>
)} )}
{contactUrl && (
<a href={contactUrl} rel="noopener">
{legal.contactLabel ?? t.legal.contactForm}
</a>
)}
</p> </p>
</section> </section>
)} )}
@@ -93,11 +113,7 @@ export default function ImpressumPage() {
<p className="legal-section__text legal-section__text--pre">{legal.extraInfo}</p> <p className="legal-section__text legal-section__text--pre">{legal.extraInfo}</p>
</section> </section>
)} )}
</div>
<section className="legal-source">
<p>{t.legal.source}</p>
</section>
</main>
</div> </div>
); );
} }

View File

@@ -18,6 +18,7 @@ import { getConfig } from '@/lib/config';
import { immich } from '@/lib/immich'; import { immich } from '@/lib/immich';
import { import {
imageUrl, imageUrl,
videoUrl,
exifUrl, exifUrl,
assetPlaceholder, assetPlaceholder,
assetAspectRatio, assetAspectRatio,
@@ -291,6 +292,7 @@ export default async function JournalDetailPage({ params }: JournalDetailPagePro
type: isVideo ? 'video' : 'image', type: isVideo ? 'video' : 'image',
thumbUrl: imageUrl(a.id, 'preview'), thumbUrl: imageUrl(a.id, 'preview'),
previewUrl: imageUrl(a.id, 'preview'), previewUrl: imageUrl(a.id, 'preview'),
...(isVideo ? { videoUrl: videoUrl(a.id) } : {}),
exifUrl: exifUrl(a.id), exifUrl: exifUrl(a.id),
...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}), ...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}),
...(exif ?? {}), ...(exif ?? {}),

View File

@@ -14,7 +14,8 @@ import { ThemeToggle } from '@/components/ThemeToggle';
import { ScrollToTop } from '@/components/ScrollToTop'; import { ScrollToTop } from '@/components/ScrollToTop';
import { Footer } from '@/components/Footer'; import { Footer } from '@/components/Footer';
import { SetupScreen } from '@/components/SetupScreen'; import { SetupScreen } from '@/components/SetupScreen';
import { getConfigOrNull, getGoogleFontsUrl, AppConfig } from '@/lib/config'; import { getConfigOrNull, getThemeFontsUrl, AppConfig } from '@/lib/config';
import { accentForMode, onAccent } from '@/lib/config/theme';
import { isAdminPath } from '@/lib/admin/paths'; import { isAdminPath } from '@/lib/admin/paths';
import { isInstallPath } from '@/lib/install'; import { isInstallPath } from '@/lib/install';
import { isSiteLocked, isSiteUnlocked } from '@/lib/auth'; import { isSiteLocked, isSiteUnlocked } from '@/lib/auth';
@@ -95,11 +96,15 @@ export default async function RootLayout({ children }: { children: React.ReactNo
} }
const { theme } = config; const { theme } = config;
const fontsUrl = getGoogleFontsUrl(theme); const fontsUrl = getThemeFontsUrl(theme);
const themeVars: Record<string, string> = { const themeVars: Record<string, string> = {
'--accent': theme.accent, // One accent per colour mode; tokens.css picks the active one as --accent,
'--accent-dim': `${theme.accent}1f`, // so the mode toggle switches it without a server round trip.
'--accent-dark': accentForMode(theme, 'dark'),
'--accent-light': accentForMode(theme, 'light'),
'--on-accent-dark': onAccent(accentForMode(theme, 'dark')),
'--on-accent-light': onAccent(accentForMode(theme, 'light')),
'--font-serif': `'${theme.fonts.heading}', Georgia, 'Times New Roman', serif`, '--font-serif': `'${theme.fonts.heading}', Georgia, 'Times New Roman', serif`,
'--font-sans': `'${theme.fonts.body}', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`, '--font-sans': `'${theme.fonts.body}', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`,
'--font-caption': `'${theme.fonts.caption}', Georgia, serif`, '--font-caption': `'${theme.fonts.caption}', Georgia, serif`,
@@ -157,8 +162,6 @@ export default async function RootLayout({ children }: { children: React.ReactNo
data-default-theme={config.colorMode} data-default-theme={config.colorMode}
> >
<head> <head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link rel="stylesheet" href={fontsUrl} /> <link rel="stylesheet" href={fontsUrl} />
</head> </head>
<body> <body>

View File

@@ -3,6 +3,19 @@
page and inside a journal entry alike. Leaflet's own stylesheet is page and inside a journal entry alike. Leaflet's own stylesheet is
emitted by components/LeafletMap.tsx. */ emitted by components/LeafletMap.tsx. */
/* ── Tiles ─────────────────────────────────────────────────── */
/* OpenStreetMap's tiles are bright and colourful. Photographs should carry
the colour, so the map is muted in both modes, and inverted into a dark
map in dark mode (the default; light mode sets data-theme='light'). */
.leaflet-tile-pane {
filter: invert(1) hue-rotate(180deg) grayscale(0.85) brightness(0.9) contrast(0.9);
}
[data-theme='light'] .leaflet-tile-pane {
filter: grayscale(0.85) contrast(0.95);
}
/* ── Popups ────────────────────────────────────────────────── */ /* ── Popups ────────────────────────────────────────────────── */
.leaflet-popup-content-wrapper { .leaflet-popup-content-wrapper {
@@ -93,7 +106,7 @@
height: 32px; height: 32px;
border-radius: 50%; border-radius: 50%;
background: var(--accent, #e60012); background: var(--accent, #e60012);
color: #fff; color: var(--on-accent, #fff);
font-family: var(--font-caption); font-family: var(--font-caption);
font-size: 0.6875rem; font-size: 0.6875rem;
font-weight: 700; font-weight: 700;

View File

@@ -9,6 +9,9 @@ import { MapView } from '@/components/MapView';
import { BackLink } from '@/components/BackLink'; import { BackLink } from '@/components/BackLink';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { getServerDictionary } from '@/lib/i18n/server'; import { getServerDictionary } from '@/lib/i18n/server';
// Imported here as well as in LeafletMap: this page has the stylesheet in its
// server HTML, so the tiles never render before their CSS.
import 'leaflet/dist/leaflet.css';
import './map.css'; import './map.css';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -33,15 +36,6 @@ export default function MapPage() {
return ( return (
<> <>
{/* LeafletMap emits this too, but only once /api/map has answered; on
this page the stylesheet should be in the server HTML so the tiles
never render before their CSS. Same href — the browser loads it once. */}
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossOrigin=""
/>
<div className="map-page"> <div className="map-page">
<div className="map-page__header"> <div className="map-page__header">
<div className="map-page__header-main"> <div className="map-page__header-main">

View File

@@ -7,7 +7,9 @@
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { immich, type ImmichAsset } from '@/lib/immich'; 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 { imageUrl, assetPlaceholder } from '@/lib/urls';
import { HeroCarousel } from '@/components/HeroCarousel'; import { HeroCarousel } from '@/components/HeroCarousel';
import { FadeIn } from '@/components/FadeIn'; import { FadeIn } from '@/components/FadeIn';
@@ -46,16 +48,26 @@ function heroNavEntries(subpages: HeroSubpage[], albums: HeroAlbum[], t: Diction
/** /**
* Camera line for the hero chip: "Q3 · 28MM · ƒ/5.6 · 1/250 · ISO 200". * Camera line for the hero chip: "Q3 · 28MM · ƒ/5.6 · 1/250 · ISO 200".
* Returns undefined when the asset carries no usable EXIF. * 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; const e = asset.exifInfo;
if (!e) return undefined; if (!e) return undefined;
const city = show.location
? placeLabel(await assetLocationPrecision(asset.id), { city: e.city ?? '', country: '' }).city
: '';
const parts = [ const parts = [
e.city || e.model || undefined, city || (show.camera ? e.model : undefined) || undefined,
e.focalLength ? `${Math.round(e.focalLength)}mm` : undefined, show.camera && e.focalLength ? `${Math.round(e.focalLength)}mm` : undefined,
e.fNumber ? `ƒ/${e.fNumber}` : undefined, show.settings && e.fNumber ? `ƒ/${e.fNumber}` : undefined,
e.exposureTime ? `${e.exposureTime}s` : undefined, show.settings && e.exposureTime ? `${e.exposureTime}s` : undefined,
e.iso ? `ISO ${e.iso}` : undefined, show.settings && e.iso ? `ISO ${e.iso}` : undefined,
].filter(Boolean); ].filter(Boolean);
return parts.length > 0 ? parts.join(' · ') : undefined; return parts.length > 0 ? parts.join(' · ') : undefined;
} }
@@ -129,7 +141,7 @@ export default async function HomePage() {
config.heroImages.map(async (id) => { config.heroImages.map(async (id) => {
const asset = await immich.getAssetInfo(id); const asset = await immich.getAssetInfo(id);
const ph = asset ? assetPlaceholder(asset) : null; const ph = asset ? assetPlaceholder(asset) : null;
const exif = asset ? heroExifLine(asset) : undefined; const exif = asset ? await heroExifLine(asset, config.exif) : undefined;
return { return {
src: imageUrl(id, 'preview'), src: imageUrl(id, 'preview'),
...(ph ? { blurDataURL: ph.blurDataURL } : {}), ...(ph ? { blurDataURL: ph.blurDataURL } : {}),

61
app/privacy/page.tsx Normal file
View File

@@ -0,0 +1,61 @@
/**
* Privacy policy (#699): content/privacy.md, written by the owner in the
* admin panel. Folio ships no legal text of its own.
*/
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getConfig } from '@/lib/config';
import { BackLink } from '@/components/BackLink';
import { getServerDictionary } from '@/lib/i18n/server';
import { parseMarkdownBlocks } from '@/lib/markdownBlocks';
import { privacyAvailable, readPrivacy } from '@/lib/privacy';
import './privacy.css';
export function generateMetadata(): Metadata {
if (!privacyAvailable(getConfig())) return {};
return {
title: getServerDictionary().privacy.title,
robots: { index: false, follow: true },
};
}
export const dynamic = 'force-dynamic';
export default function PrivacyPage() {
const t = getServerDictionary();
if (!privacyAvailable(getConfig())) notFound();
const blocks = parseMarkdownBlocks(readPrivacy());
return (
<div className="privacy-page">
<header className="privacy-page__header">
<BackLink href="/" label={t.common.home} />
<h1 className="privacy-page__title">{t.privacy.title}</h1>
</header>
{/* Every html string below comes from renderInlineMarkdown, which escapes
the author's text before adding its own tags. */}
<main className="privacy-page__content">
{blocks.map((block, i) => {
if (block.type === 'heading') {
const Tag = block.level === 2 ? 'h2' : 'h3';
return <Tag key={i} dangerouslySetInnerHTML={{ __html: block.html }} />;
}
if (block.type === 'list') {
const List = block.ordered ? 'ol' : 'ul';
return (
<List key={i}>
{block.items.map((item, j) => (
<li key={j} dangerouslySetInnerHTML={{ __html: item }} />
))}
</List>
);
}
return <p key={i} dangerouslySetInnerHTML={{ __html: block.html }} />;
})}
</main>
</div>
);
}

43
app/privacy/privacy.css Normal file
View File

@@ -0,0 +1,43 @@
.privacy-page {
padding: 4rem 2rem;
min-height: 80vh;
}
.privacy-page__header {
margin-bottom: 3rem;
text-align: center;
}
.privacy-page__title {
font-family: var(--font-serif);
font-size: clamp(2.2rem, 7vw, 3.5rem);
font-weight: 400;
}
.privacy-page__content {
max-width: 680px;
margin: 0 auto;
line-height: 1.7;
color: var(--text-secondary);
}
.privacy-page__content h2 {
margin: 2.5rem 0 0.75rem;
font-size: 1.3rem;
color: var(--text-primary);
}
.privacy-page__content h3 {
margin: 1.75rem 0 0.5rem;
font-size: 1.05rem;
color: var(--text-primary);
}
.privacy-page__content p,
.privacy-page__content ul,
.privacy-page__content ol {
margin: 0 0 1rem;
}
.privacy-page__content ul,
.privacy-page__content ol {
padding-left: 1.4rem;
}
/* tokens.css strips link underlines site-wide; in running text they are needed. */
.privacy-page__content a {
text-decoration: underline;
text-underline-offset: 3px;
}

View File

@@ -31,8 +31,11 @@
--border-subtle: #2a2a28; --border-subtle: #2a2a28;
--border-hover: #3a3a38; --border-hover: #3a3a38;
--accent: #e60012; /* The layout sets --accent-dark/--accent-light (and --on-accent-*) from the
--accent-dim: rgba(230, 0, 18, 0.12); theme; the fallbacks only cover a page rendered without it. */
--accent: var(--accent-dark, #e60012);
--accent-dim: color-mix(in srgb, var(--accent) 12%, transparent);
--on-accent: var(--on-accent-dark, #ffffff);
--font-sans: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; --font-sans: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-serif: 'Playfair Display', Georgia, 'Times New Roman', serif; --font-serif: 'Playfair Display', Georgia, 'Times New Roman', serif;
@@ -65,8 +68,9 @@
--border-subtle: #ddddd8; --border-subtle: #ddddd8;
--border-hover: #c5c5c0; --border-hover: #c5c5c0;
--accent: #c8000f; --accent: var(--accent-light, #c8000f);
--accent-dim: rgba(200, 0, 15, 0.08); --accent-dim: color-mix(in srgb, var(--accent) 12%, transparent);
--on-accent: var(--on-accent-light, #ffffff);
} }
/* ── Studio Modern: Leica Precision Palette ──────── */ /* ── Studio Modern: Leica Precision Palette ──────── */

View File

@@ -4,7 +4,6 @@
*/ */
import Link from 'next/link'; import Link from 'next/link';
import { getServerDictionary } from '@/lib/i18n/server';
interface BackLinkProps { interface BackLinkProps {
href: string; href: string;
@@ -12,9 +11,10 @@ interface BackLinkProps {
} }
export function BackLink({ href, label }: BackLinkProps) { export function BackLink({ href, label }: BackLinkProps) {
const t = getServerDictionary();
return ( return (
<Link href={href} className="album-header__back" aria-label={t.common.backTo(label)}> // No aria-label: every caller's label already reads "Back to …", and an
// aria-label built from it announced "Back to Back to …".
<Link href={href} className="album-header__back">
<svg <svg
aria-hidden="true" aria-hidden="true"
viewBox="0 0 24 24" viewBox="0 0 24 24"

133
components/ContactForm.tsx Normal file
View File

@@ -0,0 +1,133 @@
'use client';
/**
* Contact form (#702). Posts to /api/contact; see lib/contact.ts for what
* happens to the message.
*
* Spam protection without a captcha, which would be another third party: a
* honeypot field people never see, and `startedAt`, the time the form was
* rendered, which the server compares against the submission.
*/
import { useState } from 'react';
import { useDictionary } from './I18nProvider';
interface Props {
retentionDays: number;
}
type State = 'idle' | 'sending' | 'done';
export default function ContactForm({ retentionDays }: Props) {
const t = useDictionary().contact;
// When the form became usable. A bot posting straight to the endpoint has
// none; one filling the form on load is faster than MIN_FILL_MS.
const [startedAt] = useState(() => Date.now());
const [state, setState] = useState<State>('idle');
const [error, setError] = useState('');
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const data = new FormData(e.currentTarget);
setError('');
setState('sending');
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: data.get('name'),
email: data.get('email'),
message: data.get('message'),
website: data.get('website'),
startedAt,
}),
});
if (res.ok) {
setState('done');
return;
}
setError(res.status === 400 ? t.invalid : res.status === 429 ? t.tooMany : t.failed);
} catch {
setError(t.failed);
}
setState('idle');
}
if (state === 'done') {
return (
<div className="contact-form__done" role="status">
<h2>{t.successTitle}</h2>
<p>{t.successText}</p>
</div>
);
}
const sending = state === 'sending';
return (
<form className="contact-form" onSubmit={handleSubmit}>
<div className="contact-form__field">
<label className="contact-form__label" htmlFor="contact-name">
{t.name}
</label>
<input
id="contact-name"
name="name"
className="contact-form__input"
autoComplete="name"
maxLength={100}
required
disabled={sending}
/>
</div>
<div className="contact-form__field">
<label className="contact-form__label" htmlFor="contact-email">
{t.email}
</label>
<input
id="contact-email"
name="email"
type="email"
className="contact-form__input"
autoComplete="email"
maxLength={200}
required
disabled={sending}
/>
</div>
<div className="contact-form__field">
<label className="contact-form__label" htmlFor="contact-message">
{t.message}
</label>
<textarea
id="contact-message"
name="message"
className="contact-form__input"
maxLength={5000}
required
disabled={sending}
/>
</div>
<div className="contact-form__trap" aria-hidden="true">
<label htmlFor="contact-website">Website</label>
<input id="contact-website" name="website" tabIndex={-1} autoComplete="off" />
</div>
<p className="contact-form__privacy">{t.privacy(retentionDays)}</p>
{error && (
<p className="contact-form__error" role="alert">
{error}
</p>
)}
<button type="submit" className="contact-form__submit" disabled={sending}>
{sending ? t.sending : t.submit}
</button>
</form>
);
}

View File

@@ -16,6 +16,7 @@
import React, { useState, useEffect, useCallback, useLayoutEffect } from 'react'; import React, { useState, useEffect, useCallback, useLayoutEffect } from 'react';
import { IconPalette, IconX } from './Icons'; import { IconPalette, IconX } from './Icons';
import { getFontsCssUrl } from '@/lib/config/theme';
const PRESETS = ['studio', 'studio-modern', 'minimal', 'editorial', 'classic', 'noir', 'monograph']; const PRESETS = ['studio', 'studio-modern', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
const FRAMES = ['none', 'passepartout', 'shadow']; const FRAMES = ['none', 'passepartout', 'shadow'];
@@ -108,14 +109,8 @@ export function DevToolbar() {
`'${fonts.body}', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`, `'${fonts.body}', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`,
); );
setVar('--font-caption', `'${fonts.caption}', Georgia, serif`); setVar('--font-caption', `'${fonts.caption}', Georgia, serif`);
// Dynamically load Google Fonts if not already loaded // Load the fonts through the same-origin proxy the layout uses
const families = [fonts.heading, fonts.body, fonts.caption] const href = getFontsCssUrl([fonts.heading, fonts.body, fonts.caption]);
.filter((f, i, a) => a.indexOf(f) === i)
.map(
(f) => `family=${f.replace(/\s+/g, '+')}:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400`,
)
.join('&');
const href = `https://fonts.googleapis.com/css2?${families}&display=swap`;
// Check if already loaded // Check if already loaded
const existing = document.querySelector(`link[href="${href}"]`); const existing = document.querySelector(`link[href="${href}"]`);
if (!existing) { if (!existing) {

46
components/EmailLink.tsx Normal file
View File

@@ -0,0 +1,46 @@
'use client';
/**
* A mailto: link whose address is only put together in the browser; see
* lib/emailObfuscation.ts.
*
* Until hydration there is no address and no href. A page that must keep the
* address reachable without JavaScript (the Impressum) renders its own
* <noscript> with spelledOutEmail(). That is not a prop here: props travel in
* the RSC payload, so even an unused fallback would land in every page.
*/
import { useSyncExternalStore, type ReactNode } from 'react';
import { decodeEmail } from '@/lib/emailObfuscation';
interface Props {
/** encodeEmail() of the address. */
encoded: string;
className?: string;
'aria-label'?: string;
/** Link content, e.g. an icon. Without it the address itself is shown. */
children?: ReactNode;
}
const subscribe = () => () => {};
export default function EmailLink({ encoded, className, children, ...rest }: Props) {
// False on the server and during hydration, true afterwards: the decoded
// address never appears in server HTML and never causes a mismatch.
const inBrowser = useSyncExternalStore(
subscribe,
() => true,
() => false,
);
const email = inBrowser ? decodeEmail(encoded) : '';
return (
<a
href={email ? `mailto:${email}` : undefined}
className={className}
aria-label={rest['aria-label']}
>
{children ?? email}
</a>
);
}

View File

@@ -6,15 +6,21 @@
import Link from 'next/link'; import Link from 'next/link';
import { getConfig } from '@/lib/config'; import { getConfig } from '@/lib/config';
import { getServerDictionary } from '@/lib/i18n/server'; import { getServerDictionary } from '@/lib/i18n/server';
import { privacyAvailable } from '@/lib/privacy';
import EmailLink from './EmailLink';
import { encodeEmail } from '@/lib/emailObfuscation';
export function Footer() { export function Footer() {
const config = getConfig(); const config = getConfig();
const { footer, legal } = config; const { footer, legal, contact } = config;
const t = getServerDictionary(); const t = getServerDictionary();
const privacy = privacyAvailable(config);
// Don't render if no footer config AND no legal config // Don't render if there is no footer config and no legal or contact page to link
if ( if (
!legal.enabled && !legal.enabled &&
!contact.enabled &&
!privacy &&
(!footer || (!footer.name && !footer.instagram && !footer.email && !footer.website)) (!footer || (!footer.name && !footer.instagram && !footer.email && !footer.website))
) { ) {
return null; return null;
@@ -30,6 +36,16 @@ export function Footer() {
{t.legal.navLabel} {t.legal.navLabel}
</Link> </Link>
)} )}
{privacy && (
<Link href="/privacy" className="footer__legal-link">
{t.privacy.navLabel}
</Link>
)}
{contact.enabled && (
<Link href="/contact" className="footer__legal-link">
{t.contact.navLabel}
</Link>
)}
</div> </div>
<div className="footer__links"> <div className="footer__links">
@@ -59,7 +75,11 @@ export function Footer() {
</a> </a>
)} )}
{footer?.email && ( {footer?.email && (
<a href={`mailto:${footer.email}`} className="footer__link" aria-label={t.common.email}> <EmailLink
encoded={encodeEmail(footer.email)}
className="footer__link"
aria-label={t.common.email}
>
<svg <svg
aria-hidden="true" aria-hidden="true"
width="16" width="16"
@@ -74,7 +94,7 @@ export function Footer() {
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" /> <path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
<polyline points="22,6 12,13 2,6" /> <polyline points="22,6 12,13 2,6" />
</svg> </svg>
</a> </EmailLink>
)} )}
{footer?.website && ( {footer?.website && (
<a <a

View File

@@ -5,12 +5,14 @@
* blocks: tiles, markers from props, an optional line through them, fit to * blocks: tiles, markers from props, an optional line through them, fit to
* bounds. It knows nothing about /api/map; MapView fetches and hands over. * bounds. It knows nothing about /api/map; MapView fetches and hands over.
* *
* The Leaflet stylesheet is emitted here rather than by each page, so a map * The Leaflet stylesheet is imported here rather than by each page, so a map
* anywhere brings its own CSS. Marker and popup styling lives in * anywhere brings its own CSS. It comes from the npm package, bundled, not
* app/leaflet.css, loaded globally. * from a CDN: no visitor request leaves this origin for it (#699). Marker
* and popup styling lives in app/leaflet.css, loaded globally.
*/ */
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import 'leaflet/dist/leaflet.css';
export interface LeafletMarker { export interface LeafletMarker {
lat: number; lat: number;
@@ -41,9 +43,6 @@ export function escapeHtml(str: string): string {
.replace(/'/g, '&#039;'); .replace(/'/g, '&#039;');
} }
const LEAFLET_CSS_HREF = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
const LEAFLET_CSS_INTEGRITY = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=';
export function LeafletMap({ export function LeafletMap({
markers, markers,
line = false, line = false,
@@ -75,11 +74,13 @@ export function LeafletMap({
const map = L.map(containerRef.current, { zoomControl: true, attributionControl: true }); const map = L.map(containerRef.current, { zoomControl: true, attributionControl: true });
mapRef.current = map; mapRef.current = map;
// Dark-themed tiles (CartoDB Dark Matter) // OpenStreetMap's own tiles: free to use with attribution and a Referer,
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { // which the Referrer-Policy sends. CARTO's Dark Matter, used before,
// started answering every tile with "API KEY REQUIRED". The dark look
// comes from a filter in app/leaflet.css, so it follows the colour mode.
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>', '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
subdomains: 'abcd',
maxZoom: 19, maxZoom: 19,
}).addTo(map); }).addTo(map);
@@ -136,15 +137,5 @@ export function LeafletMap({
}; };
}, [markersKey, line, fitMaxZoom]); }, [markersKey, line, fitMaxZoom]);
return ( return <div ref={containerRef} className={className} />;
<>
<link
rel="stylesheet"
href={LEAFLET_CSS_HREF}
integrity={LEAFLET_CSS_INTEGRITY}
crossOrigin=""
/>
<div ref={containerRef} className={className} />
</>
);
} }

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