release: v0.19.0

Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of
[CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md)
for the full list.

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
This commit is contained in:
Ralksta
2026-09-29 22:50:30 +02:00
committed by GitHub
parent 5f251923f0
commit ad8b09700b
297 changed files with 19398 additions and 1800 deletions

View File

@@ -34,6 +34,11 @@ STALE_MAX_AGE=86400
# refetch. Leave it unset in normal operation.
# IMAGE_CACHE_VERSION=1
# Client proofing links: POST a JSON notification here when a client submits
# a selection (Discord, Slack, Gotify, n8n, ...). See
# docs/gallery-config.md#client-proofing-links.
# PROOFING_WEBHOOK_URL=https://discord.com/api/webhooks/...
# CDN mode: serve photos and videos through a pull CDN (default: off).
# Point a CDN (Cloudflare, Bunny, CloudFront, ...) at this server as its origin,
# cache /api/image/* and /api/video/* including the query string, and put its

1
.gitignore vendored
View File

@@ -82,6 +82,7 @@ content/.backups/
# gets its own .backups/ — the top-level pattern above does not reach it.
content/**/.backups/
content/analytics.json
content/proofing.json
content/*.screenshot-bak
# Credentials written by the /install wizard (never committed)
content/install.json

View File

@@ -7,6 +7,466 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Releases up to and including v0.9.2 are documented in the
[GitHub releases](https://github.com/ralksta/immich-folio/releases).
## [0.19.0] — 2026-09-29
### Security
- **Photo and video URLs stop working once the photo is no longer on the
site** ([#737](https://github.com/ralksta/immich-folio/pull/737),
[#787](https://github.com/ralksta/immich-folio/pull/787),
[GHSA-gfh4-6275-9gqv](https://github.com/ralksta/immich-folio/security/advisories/GHSA-gfh4-6275-9gqv),
low). Asset tokens are deterministic and never expire, so a media URL that
was ever served kept working after its album was unpublished or a client
proofing link was revoked or expired. `/api/image`, `/api/video` and
`/api/exif` now answer `404` (`no-store`) for any asset the site does not
currently show; signed-in admins still pass. The check follows content-file
changes on the next request, and the first views of a newly published album
wait for its rebuild instead of being refused. Copies already held by a
browser or CDN are not recalled.
- **Subpages taken offline with `enabled: false` are unpublished everywhere**
([#755](https://github.com/ralksta/immich-folio/pull/755),
[GHSA-w3wp-6qff-q96p](https://github.com/ralksta/immich-folio/security/advisories/GHSA-w3wp-6qff-q96p),
medium). An offline subpage keeps its configuration, so its albums stayed on
the album allowlist: the map still showed them, and the media and download
routes still served their photos and ZIPs, while the page itself answered 404. The map, the published-asset check and the download checks now consider
only albums that are standalone or on an enabled subpage. `hidden: true`
subpages are unchanged: reachable by link, their photos still load.
- **The sitemap no longer lists password-protected journal entries**
([#759](https://github.com/ralksta/immich-folio/pull/759),
[GHSA-7c42-5pgh-xppr](https://github.com/ralksta/immich-folio/security/advisories/GHSA-7c42-5pgh-xppr),
low). It already left out protected subpages, albums and content pages, but
filtered journal entries on `draft` alone. It now matches what the journal
index shows a visitor who has not unlocked the entry.
- **Share cards and page titles carry only text the site wrote**
([#767](https://github.com/ralksta/immich-folio/pull/767),
[#783](https://github.com/ralksta/immich-folio/pull/783)). `/api/og`
rendered any `title` and `subtitle` from its query string, on the site's
domain and in its accent colour. Card URLs are now signed with the site
secret; an unsigned or altered one gets the plain site-title card, so
previews already cached by social networks keep working. Page metadata no
longer falls back to the requested path: unknown paths get the not-found
title and no card of their own, and locked albums and subpages the generic
protected title with `noindex`.
- **A locked journal entry's title stays off its password gate**
([#765](https://github.com/ralksta/immich-folio/pull/765)). The index and
the page metadata already hid it; the gate itself printed it. It now shows
the generic journal title, and a subpage that embeds a locked entry through
`essayFile` shows its own title.
- **Password attempts no longer reveal which pages have a password**
([#769](https://github.com/ralksta/immich-folio/pull/769),
[#785](https://github.com/ralksta/immich-folio/pull/785)). `POST /api/auth`
answered a key without a password with a different status and message than a
wrong password, and much faster than a key with an scrypt hash. That gave
away drafts and offline subpages, which otherwise answer 404. Both cases now
get the same `401` and run exactly one scrypt verification. Input validation
and the rate limit are unchanged.
- **ZIP downloads are capped per visitor, and stalled ones are closed**
([#774](https://github.com/ralksta/immich-folio/pull/774)). The album and
proofing ZIP routes limited how often an archive starts, not how long one
stays open, so a client that read slowly or stopped reading held an Immich
connection and its buffers for as long as it liked. At most two archives per
client IP may be open at once, shared between both routes (a third gets
`429` with `Retry-After: 30`), and an archive whose client takes nothing for
60 seconds is torn down. A download that keeps reading is never cut off,
however slowly it reads. See the upgrade notes.
- **The Immich webhook caps its request body before checking the signature**
([#780](https://github.com/ralksta/immich-folio/pull/780)). The body was
read in full, at any size, before the HMAC check. It is now limited to 64
KiB (`413` above), the signature is computed over the raw bytes, and the
signature header must be exactly 64 hex characters; a valid digest followed
by junk was accepted before.
- **The ZIP refusal page's back link stays on the site**
([#786](https://github.com/ralksta/immich-folio/pull/786)). A `Referer`
whose path started with `//` became a protocol-relative link to another
host; such paths are now rejected. The link compares the `Referer` with the
`Host` header (and `X-Forwarded-Host` only when `TRUSTED_PROXY_HOPS` is
set), so behind a reverse proxy it now leads back to the album instead of
always to `/`.
- **Client proofing tokens are never written to the view statistics**
([#748](https://github.com/ralksta/immich-folio/pull/748)). The cookieless
counter stored every path as visited, and in `/proof/<token>` the token is
the link's only credential. Those visits are counted as `/proof`.
### Added
- **Client proofing links**
([#683](https://github.com/ralksta/immich-folio/pull/683),
[#758](https://github.com/ralksta/immich-folio/pull/758)). Under the new
_Proofing_ tab the admin creates a private link, `/proof/<token>`, per
client and album — any Immich album, published on the site or not — with an
optional expiry date, an optional ZIP download (the selection or the whole
album) and an optional download limit. The client hearts photos as on a
public album; the selection is saved on the server as they go, so they can
stop and continue later or on another device, and _Submit selection_ locks
it. The photographer watches the picks come in, reopens a submitted
selection, resets the download counter or revokes the link, and exports the
picks as a Lightroom or Capture One filename filter (which finds the RAW as
well as the JPEG), CSV or TXT. `PROOFING_WEBHOOK_URL` gets one JSON POST on
the first submit, shaped for Discord, Slack, Gotify and generic receivers.
Links are stored in `content/proofing.json` (mode `0600`); proof pages are
`noindex`, send no `Referer` and stay out of the sitemap, and a site
password still applies. The selection ZIP waits until pending hearts are
saved, so it never misses the last pick. The anonymous proofing on public
albums is unchanged. See `docs/gallery-config.md#client-proofing-links`.
- **Custom content pages**
([#738](https://github.com/ralksta/immich-folio/pull/738),
[#760](https://github.com/ralksta/immich-folio/pull/760),
[#764](https://github.com/ralksta/immich-folio/pull/764), closes
[#722](https://github.com/ralksta/immich-folio/issues/722)).
`content/pages/<slug>.md` — frontmatter plus the journal's block markdown —
is served at `/<slug>` and rendered like a journal entry without date or
reading time. A `- page: <slug>` line among the subpages in `gallery.yaml`
puts it in the menu at that position; a page without one is reachable by
link but not listed. Pages can be drafts (visible to signed-in admins only)
and can carry a password (cookie `lb_auth_page_<slug>`); public pages appear
in the sitemap. In the page builder, pages sit in one _Menu_ group with the
subpages, _+ New page_ derives the slug from the title with a collision
check, and the content is edited in the journal studio. Renaming or deleting
a page updates its menu entry, with a backup first; page backups rotate in
`content/pages/.backups/`. A new doctor check, `content-pages`, reports slug
collisions and menu entries without a file. A missing or draft page is still
a soft 404 (status `200`), like other slugs on this route.
- **The asset picker searches the library and picks several photos at once**
([#740](https://github.com/ralksta/immich-folio/pull/740),
[#752](https://github.com/ralksta/immich-folio/pull/752), closes
[#602](https://github.com/ralksta/immich-folio/issues/602)). A date (`2024`,
`2024-05`, `2024-05-17`) searches by capture date, any other text by file
name and Immich description. Tiles are buttons with a numbered badge in pick
order, and a bar at the bottom offers _Add N photos_. The homepage hero
takes several at once, _+ Photos_ in the journal and page editor adds one
photo block per pick, and pairs and grids fill their slots. The search stays
read-only against Immich.
- **Admin editors detect concurrent edits**
([#742](https://github.com/ralksta/immich-folio/pull/742),
[#757](https://github.com/ralksta/immich-folio/pull/757), closes
[#601](https://github.com/ralksta/immich-folio/issues/601)). Every editor
that saves a content file — gallery, settings, About, privacy, journal
entries and content pages — notices when the file changed since it was
loaded, in another tab or on another device, by hand or through a backup
restore. Instead of overwriting it silently, it offers _Reload (discard
mine)_, _Overwrite anyway_ or _Keep editing_. A save from a tab opened
before the upgrade still goes through as before. Deletes are not
version-checked.
- **Settings set by the environment are shown as locked**
([#739](https://github.com/ralksta/immich-folio/pull/739), closes
[#605](https://github.com/ralksta/immich-folio/issues/605)). `sitePassword`
(overridden by `SITE_PASSWORD`) and `contact.notifyUrl`
(`CONTACT_NOTIFY_URL`) are disabled in the settings editor with a note
naming the variable, and a save keeps what `settings.yaml` holds for them.
`SITE_TITLE`, `SITE_SUBTITLE` and `SITE_URL` are fallbacks, not overrides,
and stay editable.
- **An accessibility baseline**
([#741](https://github.com/ralksta/immich-folio/pull/741), closes
[#696](https://github.com/ralksta/immich-folio/issues/696)). The hero
carousel stops auto-advancing under `prefers-reduced-motion` and has a pause
button, and one reduced-motion rule covers the site, the admin and
`/install`. Theme toggle, menu button and scroll-to-top are 44×44 px
targets; EXIF and cover overlays also show on keyboard focus, and always on
touch screens. The lightbox keeps Tab inside and returns focus to the photo
shown last, the mobile menu moves focus in and back out, and the admin and
`/install` have skip links. Photo counts in descriptions and share cards,
the lightbox position and the _Gallery_ fallback title are translated.
Header and hero navigation now share one order — Home, subpages and content
pages in `gallery.yaml` order, standalone albums, Journal, About, Map — with
external `navLinks` last.
### Changed
- **Less work per request**
([#753](https://github.com/ralksta/immich-folio/pull/753),
[#756](https://github.com/ralksta/immich-folio/pull/756),
[#749](https://github.com/ralksta/immich-folio/pull/749),
[#761](https://github.com/ralksta/immich-folio/pull/761),
[#775](https://github.com/ralksta/immich-folio/pull/775)). The configuration
is derived once per content-file change instead of on every read (one
`/api/image` request did it seven times), and the Immich client reads it
once per operation. Album blocks in journal entries, content pages and
essays go through the album cache with request coalescing, and a page's
blocks load in parallel instead of one after another. Parsed journal and
page listings are kept while their files are unchanged, and the journal and
site navigation are read once per request. Content edits still show on the
next request; album blocks now follow `CACHE_TTL` like every other album.
- **One image URL per photo and size tier**
([#762](https://github.com/ralksta/immich-folio/pull/762)). The proxy
answers every width within a tier with the same bytes, but `next/image`
wrote ten widths into each `srcset` and the lightbox asked for an eleventh
spelling, so opening a grid photo downloaded the same preview again, and so
did rotating a phone. Grid, hero, essay and lightbox now request one URL per
tier, and the unused `q` parameter is gone. An album viewed in full in the
lightbox transfers each preview once instead of twice, and a CDN sees at
most one miss per photo and tier. The URLs change once; see the upgrade
notes.
- **Pages ship less code they do not use**
([#770](https://github.com/ralksta/immich-folio/pull/770),
[#766](https://github.com/ralksta/immich-folio/pull/766)). The proofing UI
loads only on pages where proofing is on, and Leaflet's stylesheet and map
wrapper only when an essay contains a map block. Album, subpage and journal
pages drop 3–5 KB of gzipped JavaScript and 14% of their render-blocking
CSS.
- **Settings the site would ignore are refused on save**
([#795](https://github.com/ralksta/immich-folio/pull/795)). A site URL
without a scheme, an accent that is not hex, or grid columns and gap outside
their bounds were saved with a success message and then silently replaced
when the site rendered. The settings form now marks such fields as you type,
the save answers `400` naming them and writes nothing, and a new doctor
check, `settings-values`, reports values already in `settings.yaml` that the
site ignores.
- **Admin colours: blue accent, amber warnings, readable muted text**
([#795](https://github.com/ralksta/immich-folio/pull/795)). Outside Studio
Modern the admin accent was the same amber as its warnings, so every active
marker and focus ring read as a warning; it is now blue. The unsaved-changes
bar and the order editor's drift notice use the warning colour instead of
the accent (red under Studio Modern), the status badge has its own amber
tone for doctor warnings, and muted text reaches 4.5:1 on every admin
surface in every preset.
### Fixed
- **Public pages follow admin saves on the next request**
([#790](https://github.com/ralksta/immich-folio/pull/790)). After a save,
changed album titles, descriptions and slugs, and newly published albums,
could take up to `CACHE_TTL` (five minutes by default) to appear, and a new
album answered 404 meanwhile: the save cleared a copy of the Immich cache
that the public pages never read. The cache now holds only Immich's data and
applies `gallery.yaml` on the way out, and it is one store per process, so
_Clear cache_ in Diagnostics, the webhook and the status panel act on the
cache the pages use. A subpage's cover grid and its albums' previous/next
links follow the order from `gallery.yaml` and the page builder instead of
Immich's. See the upgrade notes.
- **Photo captions with parentheses no longer break journal entries**
([#788](https://github.com/ralksta/immich-folio/pull/788)). A caption like
`Summit (560 m)`, or one with a link, ended the photo block at the first
`)`: the public page rendered the line as a paragraph showing raw asset IDs,
and the editor turned it into a text block, dropping the photo on the next
save. Captions now run to the block's last `)`, with no change to the file
format, and existing entries parse as before. Photo lines without a blank
line between them or directly under a heading are recognised too, and a link
URL may hold one level of parentheses.
- **Journal and essay text fields show Markdown, not stored HTML**
([#789](https://github.com/ralksta/immich-folio/pull/789)). After a reload,
text blocks, quotes, captions and fact values showed raw `<strong>` and
`<a>` tags, and text typed inside a tag could be lost on save. The fields
now show and take inline Markdown, the live preview renders bold, italics
and links while you type, and an untouched block saves byte for byte as
before.
- **Contact messages from devices with a wrong clock are no longer dropped**
([#771](https://github.com/ralksta/immich-folio/pull/771)). The fill-time
check compared the visitor's clock with the server's. A device ten minutes
fast, or a day slow, made a genuine message look like spam, which is
discarded silently while the sender sees the thank-you page. The form is now
stamped with the server's time.
- **Missing pages answer a real 404**
([#781](https://github.com/ralksta/immich-folio/pull/781),
[#772](https://github.com/ralksta/immich-folio/pull/772)). Since 0.18.0, a
switched-off `/contact`, `/impressum`, `/privacy` or `/map`, or an unknown
`/journal/<slug>`, could hang for about 30 seconds and fail with `500` when
the server was bound to `127.0.0.1`, because the proxy's rewrite looped back
through `localhost`. They now get `404` at once. Paths deeper than two
segments, such as `/travel/iceland/anything`, rendered the parent subpage or
album with `200` and its title; they answer `404` as well.
- **_Email to photographer_ addresses the photographer**
([#746](https://github.com/ralksta/immich-folio/pull/746) by
[@lancetm714](https://github.com/lancetm714), follow-up
[#747](https://github.com/ralksta/immich-folio/pull/747); reported in
[#736](https://github.com/ralksta/immich-folio/issues/736) by
[@RichKidsDev](https://github.com/RichKidsDev)). The proofing dialog opened a
mail with an empty _To:_ line. The recipient is now the footer contact
email, or the new optional `proofing.email` (Settings → General → _Proofing
email_); with neither set, the button is hidden. The address stays out of
the served HTML, as the footer's does.
- **Proofing on public albums counts and filters correctly**
([#763](https://github.com/ralksta/immich-folio/pull/763),
[#773](https://github.com/ralksta/immich-folio/pull/773),
[#787](https://github.com/ralksta/immich-folio/pull/787)). The selection
count included favourites stored under the same album key but not in the
grid — removed photos, or another album with the same name — so the bar
could read "3 selected" over one heart; only this album's photos count now.
With the favourites filter on, un-hearting the photo open in the lightbox
crashed the page when it was the last in the list, and un-hearting the last
favourite left an empty grid with no way back. The viewer now steps to the
previous favourite or closes, and an empty filter shows _Show all_.
- **Hidden subpages stay off the map**
([#782](https://github.com/ralksta/immich-folio/pull/782)). `hidden: true`
subpages are reachable by link only and were already left out of the
sitemap, but `/map` showed markers for albums only they carry — name, link
and thumbnail — and counted them in its header. Their photos still load for
anyone with the link. The map header also no longer counts an album on two
subpages twice.
- **Edits made while an admin save is in flight are kept**
([#768](https://github.com/ralksta/immich-folio/pull/768)). Editors marked
themselves clean when the save answered, so edits typed meanwhile showed as
saved, were missing from the draft and were lost on leaving; the journal
editor and the page builder even replaced them with the written copy.
Editors now compare with what they sent and stay unsaved when there is more
to save. This covers the journal and page editor, the page builder,
Settings, About and the privacy policy.
- **Slug fields take hyphens and keep accented letters**
([#751](https://github.com/ralksta/immich-folio/pull/751)). The new-page,
page-URL and new-journal-entry fields stripped a trailing hyphen on every
keystroke, so `about-us` could only be pasted. Accented letters became
hyphens (`Über mich` → `ber-mich`); they now fold to the base letter and `ß`
to `ss`, as subpage slugs already did. Existing slugs do not change.
- **Journal dates show the written day everywhere**
([#776](https://github.com/ralksta/immich-folio/pull/776)). On a server west
of Greenwich a date-only `2026-03-15` showed as 14 March on the index,
hand-typed dates showed "Invalid Date", and the entry page printed the raw
frontmatter. Index and entry now format dates the same way, in the site's
language and without a timezone shift; `YYYY-MM` shows month and year,
anything else is shown as written. Entries with the same date are ordered by
slug, so the index and previous/next links no longer depend on the
filesystem.
- **Password gates and refusal pages**
([#786](https://github.com/ralksta/immich-folio/pull/786)). Once the rate
limit kicked in, the gate said "wrong password" even for the right one and
cleared the field; it now says how many seconds to wait and keeps the input.
A locked journal entry has its own gate subtitle instead of the gallery's.
The ZIP refusal page uses the preset's colours in the site's colour mode
instead of plain white, and an expired proofing link is titled as expired
rather than "not found".
- **The lightbox and error messages are readable in light mode**
([#792](https://github.com/ralksta/immich-folio/pull/792)). The lightbox
overlay is dark in both modes, but its close button, arrows, counter and
focus ring used the page's near-black light-mode text colours, down to
1.1:1. They now have their own tokens, the round controls get a dark
translucent fill that stays visible over a white sky, and the favourite
toggle keeps its colour on hover. A new per-mode `--error` colour brings
form and gate errors to 4.5:1, and text on accent-filled buttons in the
proofing dialogs and on Studio Modern's 404 page follows `--on-accent`.
- **No sideways scrolling on phones and tablets**
([#791](https://github.com/ralksta/immich-folio/pull/791),
[#797](https://github.com/ralksta/immich-folio/pull/797)). The footer's name
and legal links made every Studio Modern page 441px wide on a 390px phone,
which also pushed the lightbox's close and next buttons off-screen. The
footer wraps, the header switches to the menu whenever its links do not fit
in one row (not only below 641px), the map loses a second side gutter and
its grey bands, and Studio Modern's smaller phone and tablet gutters take
effect. Long hero, subpage, privacy and album titles wrap or hyphenate
instead of widening the page, and phone content no longer starts under the
header.
- **The admin on phones, keyboards and screen readers**
([#793](https://github.com/ralksta/immich-folio/pull/793),
[#794](https://github.com/ralksta/immich-folio/pull/794),
[#795](https://github.com/ralksta/immich-folio/pull/795)). On a phone the
subpage sheet was clipped after one screen, hiding its albums and _Delete_;
it now grows with its content and scrolls into view on tap, and the journal
list, status dropdown and Story Settings password field fit. The album
drawer, order editor, Story Settings and new-entry dialog are modal dialogs
with Escape and a focus trap, Escape closes only the innermost of two open
dialogs, and album picker rows are buttons reachable from the keyboard. An
unsaved privacy policy survives switching settings sections, like About. The
admin is marked `lang="en"`, so a screen reader on a German site no longer
reads it with a German voice, and the analytics chart shows the last 14
calendar days, empty ones included.
- **The admin overview no longer crashes on the older map form of
`subpages:`** in `gallery.yaml`
([#754](https://github.com/ralksta/immich-folio/pull/754)).
- **Translations** ([#784](https://github.com/ralksta/immich-folio/pull/784),
[#777](https://github.com/ralksta/immich-folio/pull/777)). Navigation, next
and back links and the subpage password gate name a subpage by its `title`
instead of its `gallery.yaml` key (`south-korea`). The map's zoom buttons
and Leaflet credit are translated, the share card no longer lowercases its
subtitle ("25 fotos"), German says "Diashow" throughout, French uses the
singular for 0, and the proofing mail subject no longer reads "1 items".
### Internal
- **The unit tests no longer write into the checkout's `content/`**
([#778](https://github.com/ralksta/immich-folio/pull/778)).
`journal.test.ts` created and deleted a real journal entry there, which in a
self-hosted checkout briefly published it and left a
`test-journey-nordkap.md.<timestamp>.deleted.bak` in
`content/journal/.backups/`; remove any such file by hand. A setup file now
fails every test that writes under `content/`.
- **Every public API route must declare whether the site password gates it**
([#750](https://github.com/ralksta/immich-folio/pull/750)); a test fails on
a route that is in neither list.
- **The standalone build no longer traces the whole project**
([#796](https://github.com/ralksta/immich-folio/pull/796)), which the config
memo of [#753](https://github.com/ralksta/immich-folio/pull/753) had caused
before release.
- Admin screenshots in the README and `docs/journal.md` show the redesigned
panel ([#735](https://github.com/ralksta/immich-folio/pull/735)).
### Upgrade notes
**Image URLs change once.** Each photo now has one URL per size tier, and the
`q` parameter is gone
([#762](https://github.com/ralksta/immich-folio/pull/762)). Browsers and a CDN
fetch every photo once more after the upgrade, as after an
`IMAGE_CACHE_VERSION` bump. No `IMAGE_CACHE_VERSION` bump is needed this time.
**At most two ZIP downloads run at once per client IP**
([#774](https://github.com/ralksta/immich-folio/pull/774)). Behind a reverse
proxy, `TRUSTED_PROXY_HOPS` must be set correctly; otherwise every visitor has
the proxy's address and all of them share the same two slots.
**Admin saves no longer clear the Immich album cache**
([#790](https://github.com/ralksta/immich-folio/pull/790)). Changes to
`gallery.yaml` and settings show on the next request as before. Changes made
inside Immich — new photos in an album, a renamed album — appear after
`CACHE_TTL`, after _Clear cache_ under Diagnostics, or at once when the Immich
webhook is set up.
**Every password attempt costs one scrypt verification** (about 40 ms),
including attempts on keys that have no password
([#785](https://github.com/ralksta/immich-folio/pull/785)). The auth rate
limit keeps the total small.
**The admin's default accent is blue**
([#795](https://github.com/ralksta/immich-folio/pull/795)); warnings stay
amber. Studio Modern keeps its red accent.
**Paths deeper than two segments answer `404`**
([#781](https://github.com/ralksta/immich-folio/pull/781)), where they used to
render the parent subpage or album. Links of that shape were never generated
by the site.
**Header and hero navigation share one order**
([#741](https://github.com/ralksta/immich-folio/pull/741)): external
`navLinks` from `settings.yaml` now come after About and Map.
**`content/` gains `proofing.json` and `pages/`**, both created on demand, so
a writable `content/` is enough, as before. `/proof` is a fixed route now: a
subpage slugged `proof` is hidden behind it.
**New, optional settings:** `proofing.email` and the `PROOFING_WEBHOOK_URL`
environment variable. See `content/settings.yaml.example`,
`.env.local.example` and `docs/gallery-config.md#client-proofing-links`.
## [0.18.1] — 2026-09-27
### Fixed

View File

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

View File

@@ -13,7 +13,7 @@
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.1** — built-in contact form, privacy page and a reworked admin panel. → [What's New](#whats-new)
**Latest: v0.19.0** — private proofing links for clients, custom content pages and a round of security fixes. → [What's New](#whats-new)
## Contents
@@ -67,6 +67,7 @@ A self-hosted portfolio powered by [Immich](https://immich.app). It acts as a **
- **Subpage on/off toggle** — take a page offline without deleting it
- **External navigation links** _(experimental)_ — point the header at a shop, a blog, or a social profile
- **Client proofing** — clients favorite photos and export the selection; picks are encoded in the URL, nothing is stored server-side
- **Client proofing links** — a private link per client and album, picks saved server-side, a submit that locks the selection and pings you via webhook, expiry dates, download limits, and a Lightroom-ready export of the chosen file names ([guide](docs/gallery-config.md#client-proofing-links))
- **Originals delivery** — per-album opt-in lets visitors download a whole album or their proofing selection as a ZIP of the originals
- **Lightbox watermark** — configurable overlay on fullscreen images
- **Six interface languages** — English, German, French, Spanish, Italian and Dutch for everything visitors see
@@ -225,6 +226,7 @@ ADMIN_PASSWORD=your-secure-password # enables /admin panel
WEBHOOK_SECRET=long-random-string # enables POST /api/webhook cache invalidation
CDN_URL=https://cdn.example.com # serve photos and videos through a pull CDN
CONTACT_NOTIFY_URL=https://ntfy.sh/x # push for new contact form messages, overrides settings.yaml
PROOFING_WEBHOOK_URL=https://… # notify on submitted client proofing selections
```
> Login and setup endpoints have their own, much lower limits that `RATE_LIMIT_RPM` does not raise — see [Rate Limiting](docs/gallery-config.md#rate-limiting).
@@ -330,11 +332,11 @@ own password, separate from any album passwords, and writes straight to
<p align="center">
<img src="docs/screenshots/admin-login.png" width="49%" alt="Admin panel login screen" />
<img src="docs/screenshots/admin-page-builder.png" width="49%" alt="Visual page builder with hero images, standalone albums and subpages" />
<img src="docs/screenshots/admin-page-builder.png" width="49%" alt="Page builder: the site structure on the left, the selected page with its albums on the right" />
</p>
<p align="center">
<img src="docs/screenshots/admin-album-picker.png" width="49%" alt="Album picker listing shared Immich albums with photo counts" />
<img src="docs/screenshots/admin-settings.png" width="49%" alt="Settings editor with site identity and feature toggles" />
<img src="docs/screenshots/admin-settings.png" width="49%" alt="Settings editor: sections on the left, site identity and feature switches grouped by meaning" />
</p>
<p align="center"><em>Login · page builder · album picker · settings editor</em></p>
@@ -342,15 +344,15 @@ own password, separate from any album passwords, and writes straight to
## What's New
v0.18.0 — a contact form, a privacy page and a reworked admin:
v0.19.0 — client proofing links, content pages and a big quality pass:
- **Built-in contact form** — no SMTP or form service: messages land in a new admin inbox, with an optional ntfy push that carries nothing about the sender
- **Privacy page and fewer third parties** — `/privacy` with a list of what your installation actually processes; fonts and map styles are served from your own server, and email addresses stay out of the page source
- **A reworked admin panel** — a collapsible sidebar, an overview page, the page builder as a structure list with Undo, and ON/OFF switches grouped by meaning
- **Four more languages and a CDN mode** — French, Spanish, Italian and Dutch, and `CDN_URL` to serve photos through a pull CDN
- **Two security fixes** — draft and password-protected journal entries used as subpage essays, and unescaped Immich descriptions ([advisories](https://github.com/ralksta/immich-folio/security/advisories))
- **Private proofing links for clients** — one link per client and album, with an optional expiry, download limit and ZIP; the selection is saved on the server, the client submits it, and you export the picks as a Lightroom or Capture One filter
- **Custom content pages** — `content/pages/<slug>.md` pages (pricing, workshops, anything) written in the journal editor and placed in the menu next to your subpages
- **A calmer admin** — library search and multi-select in the photo picker, a warning instead of a silent overwrite when a file changed elsewhere, settings from the environment shown as locked, and invalid values refused on save
- **Better on phones and in light mode** — no sideways scrolling, a menu whenever the navigation does not fit, readable lightbox controls, and an accessibility baseline (reduced motion, focus handling, touch targets)
- **Security fixes** — media URLs stop working once a photo leaves the site, offline subpages are unpublished everywhere, and the sitemap skips protected journal entries ([advisories](https://github.com/ralksta/immich-folio/security/advisories))
**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.
**Upgrade note:** image URLs change once (browser and CDN caches refill by themselves), at most two ZIP downloads run at once per visitor — set `TRUSTED_PROXY_HOPS` behind a reverse proxy — and changes made inside Immich now show after `CACHE_TTL` or _Clear cache_. Details in the [changelog](CHANGELOG.md).
Security fixes ship in normal releases, so **running the latest release is the
recommended baseline**.
@@ -381,12 +383,14 @@ easier to live with.
picked, as one archive of the originals — streamed, so even a large shoot
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.
turning every button invisible. In v0.19.0, gave the proofing dialog's
_Email to photographer_ an actual recipient.
- **[@RichKidsDev](https://github.com/RichKidsDev)** — noticed that the
Impressum still cited the TMG, which the DDG replaced in 2024. That report
turned into a proper overhaul in v0.18.0: the right law by default, a heading
you can change for other countries, clickable contacts and a contact form as
a second channel.
a second channel. In v0.19.0, reported that _Email to photographer_ opened a
mail with nobody in the _To:_ line.
- **[@ImScheinox](https://github.com/ImScheinox)** — found and fixed portrait
photos rendering as landscape tiles, because the grid ignored the camera's
EXIF orientation flag. That brought the masonry layout's stagger back for

View File

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

View File

@@ -2,17 +2,32 @@
import { useState, useMemo } from 'react';
import Image from 'next/image';
import dynamic from 'next/dynamic';
import { Lightbox, type LightboxWatermark } from '@/components/Lightbox';
import { ProofingProvider, useProofing } from '@/components/ProofingContext';
import { ProofingModal } from '@/components/ProofingModal';
import { useProofing } from '@/components/useProofing';
import { ProofingProvider, ProofingModal } from '@/components/ProofingLazy';
import { FadeIn } from '@/components/FadeIn';
import { LeafletMap, escapeHtml } from '@/components/LeafletMap';
import { escapeHtml } from '@/lib/escapeHtml';
import type { ParsedEssay, EssayBlock } from '@/lib/essay';
import { renderInlineMarkdown } from '@/lib/essay';
import { essayPhotoSequence } from '@/lib/essaySequence';
import type { PhotoItem } from './PhotoGrid';
import './essay.css';
import { useDictionary } from '@/components/I18nProvider';
import { formatJournalDate } from '@/lib/journalDate';
/**
* The map block is the only thing in an essay that needs Leaflet, and most
* essays have none. Imported statically, LeafletMap put its wrapper and
* Leaflet's stylesheet (render-blocking CSS) on every album, subpage and
* journal page. The map only ever drew on the client — Leaflet itself was
* already imported inside an effect — so the server renders the same empty
* container it rendered before, and the wrapper arrives with the map.
*/
const LeafletMap = dynamic(() => import('@/components/LeafletMap').then((m) => m.LeafletMap), {
ssr: false,
loading: () => <div className="essay-map" />,
});
interface EssayViewProps {
essay: ParsedEssay;
@@ -28,6 +43,8 @@ interface EssayViewProps {
proofing?: boolean;
/** Offer the "send by email" button in the proofing modal. */
allowMailto?: boolean;
/** `encodeEmail()` of the proofing recipient, when one is configured (#736). */
encodedMailto?: string;
}
function EssayViewContent({
@@ -389,6 +406,9 @@ function EssayViewContent({
}
};
// The story's photos that are selected, not the whole stored set (see PhotoGrid).
const selectedCount = proofing ? proofing.getSelectedTokens().length : 0;
return (
<div className="essay-container">
<header className="essay-header">
@@ -396,7 +416,9 @@ function EssayViewContent({
{displaySubtitle && <p className="essay-header__subtitle">{displaySubtitle}</p>}
{(essay.frontmatter.author || essay.frontmatter.date) && (
<div className="essay-header__meta">
{[essay.frontmatter.author, essay.frontmatter.date].filter(Boolean).join(' • ')}
{[essay.frontmatter.author, formatJournalDate(essay.frontmatter.date, t.dateLocale)]
.filter(Boolean)
.join(' • ')}
</div>
)}
</header>
@@ -429,9 +451,9 @@ function EssayViewContent({
</figure>
)}
<main>{essay.blocks.map(renderBlock)}</main>
<div>{essay.blocks.map(renderBlock)}</div>
{proofing && proofing.favorites.size > 0 && (
{proofing && selectedCount > 0 && (
<div
className="proofing-sticky-bar"
style={{
@@ -454,7 +476,7 @@ function EssayViewContent({
{/* A count, not the album grid's filter toggle: a story's layout is
authored, so there is nothing for "show favourites" to narrow. */}
<span style={{ fontSize: '0.85rem', fontWeight: 500, padding: '6px 4px' }}>
{t.proofing.selected(proofing.favorites.size)}
{t.proofing.selected(selectedCount)}
</span>
<button
type="button"
@@ -513,6 +535,7 @@ export function EssayView(props: EssayViewProps) {
// default key with every album (and story) on the site.
albumName={props.title || props.essay.frontmatter.title}
allowMailto={props.allowMailto ?? true}
encodedMailto={props.encodedMailto}
>
<EssayViewContent {...props} />
</ProofingProvider>

View File

@@ -15,8 +15,9 @@ import { useState, useCallback, useEffect, useMemo } from 'react';
import Image from 'next/image';
import { Lightbox, type LightboxWatermark } from '@/components/Lightbox';
import { FadeIn } from '@/components/FadeIn';
import { ProofingProvider, useProofing } from '@/components/ProofingContext';
import { ProofingModal } from '@/components/ProofingModal';
import type { ProofSessionInit } from '@/components/ProofingContext';
import { useProofing } from '@/components/useProofing';
import { ProofingProvider, ProofingModal, ProofSessionControls } from '@/components/ProofingLazy';
import { useDictionary } from '@/components/I18nProvider';
import { parsePhotoHash, parsePhotoQuery, buildPhotoQuery } from '@/lib/photoHash';
@@ -60,6 +61,12 @@ interface PhotoGridProps {
proofing?: boolean;
/** Offer the "send by email" button in the proofing modal. */
allowMailto?: boolean;
/**
* `encodeEmail()` of where the proofing dialog's "email to photographer"
* goes, when one is configured (#736). Lets the modal address the compose;
* without it the button is hidden.
*/
encodedMailto?: string;
/**
* The ZIP endpoint for this album, when it offers downloads. Lets the
* proofing modal offer a "download selected" action (#475).
@@ -71,6 +78,11 @@ interface PhotoGridProps {
* selection made in one album leaks into the next.
*/
albumName?: string;
/**
* A client proofing link's session (/proof/<token>). Turns proofing on and
* keeps the selection on the server instead of in the browser.
*/
proofSession?: ProofSessionInit;
}
function PhotoGridInner({
@@ -124,6 +136,33 @@ function PhotoGridInner({
}
}, [lightboxIndex, displayedAssets]);
// ── The photo on screen can leave the list ─────────────────────
// With "selected only" on, un-hearting the photo in the viewer drops it from
// `displayedAssets`. The viewer keeps its position, which now shows the next
// favourite; past the end it steps back to the last one, and with nothing
// left it closes. `shownIndex` keeps this render valid (it used to hand the
// viewer an index past the end, and the page crashed); the effect settles
// the state and the URL.
const shownIndex =
lightboxIndex === null || displayedAssets.length === 0
? null
: Math.min(lightboxIndex, displayedAssets.length - 1);
useEffect(() => {
if (lightboxIndex === null || lightboxIndex < displayedAssets.length) return;
if (displayedAssets.length > 0) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setLightboxIndex(displayedAssets.length - 1);
return;
}
setLightboxIndex(null);
window.history.replaceState(
null,
'',
window.location.pathname + buildPhotoQuery(window.location.search, null),
);
}, [lightboxIndex, displayedAssets.length]);
// ── Listen for browser back/forward ────────────────────────────
useEffect(() => {
const handlePopState = () => {
@@ -204,6 +243,7 @@ function PhotoGridInner({
}}
role="button"
tabIndex={0}
data-lightbox-index={index}
aria-label={t.lightbox.openPhoto(index + 1)}
aria-haspopup="dialog"
style={{
@@ -285,13 +325,38 @@ function PhotoGridInner({
});
}, [displayedAssets, layout, openLightbox, proofing, t]);
// Photos of this album that are selected. Not `favorites.size`: the stored set
// can also hold a photo since removed, or a favourite from another album whose
// name slugs the same way (they share `folio_fav_<name>`).
const selectedCount = proofing ? proofing.getSelectedTokens().length : 0;
return (
<>
<div className={`photo-grid photo-grid--${layout}`} style={gridStyle}>
{gridItems}
</div>
{proofing?.isFilterActive && displayedAssets.length === 0 ? (
// Un-hearting the last favourite with the filter on left a blank page
// between the header and the footer. Say why, and offer the way back
// here as well as in the bar in the corner.
<div className="empty-state" role="status">
<p className="empty-state__text">{t.proofing.filterEmpty}</p>
<div className="empty-state__actions">
<button
type="button"
className="empty-state__button"
onClick={() => proofing.setIsFilterActive(false)}
>
{t.proofing.showAll}
</button>
</div>
</div>
) : (
<div className={`photo-grid photo-grid--${layout}`} style={gridStyle} data-lightbox-group>
{gridItems}
</div>
)}
{proofing && proofing.favorites.size > 0 && (
{/* Kept while the filter is on: un-hearting the last favourite there
empties the grid, and the bar holds the only way back to all photos. */}
{proofing && !proofing.session && (selectedCount > 0 || proofing.isFilterActive) && (
<div
className="proofing-sticky-bar"
style={{
@@ -327,9 +392,7 @@ function PhotoGridInner({
cursor: 'pointer',
}}
>
{proofing.isFilterActive
? t.proofing.showAll
: t.proofing.selected(proofing.favorites.size)}
{proofing.isFilterActive ? t.proofing.showAll : t.proofing.selected(selectedCount)}
</button>
<button
type="button"
@@ -349,12 +412,13 @@ function PhotoGridInner({
</div>
)}
{proofing && <ProofingModal />}
{proofing && !proofing.session && <ProofingModal />}
{proofing?.session && <ProofSessionControls />}
{lightboxIndex !== null && (
{shownIndex !== null && (
<Lightbox
assets={displayedAssets}
currentIndex={lightboxIndex}
currentIndex={shownIndex}
onClose={closeLightbox}
onNext={goNext}
onPrev={goPrev}
@@ -372,7 +436,7 @@ export function PhotoGrid(props: PhotoGridProps) {
// Without the provider useProofing() returns null, and every proofing control
// (hearts in the grid and in the lightbox, selection bar, modal) drops out on
// its own.
if (!props.proofing) {
if (!props.proofing && !props.proofSession) {
return <PhotoGridInner {...props} />;
}
@@ -381,7 +445,9 @@ export function PhotoGrid(props: PhotoGridProps) {
albumTokens={albumTokens}
albumName={props.albumName}
allowMailto={props.allowMailto ?? true}
encodedMailto={props.encodedMailto}
downloadArchiveUrl={props.downloadArchiveUrl}
session={props.proofSession}
>
<PhotoGridInner {...props} />
</ProofingProvider>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,30 +3,25 @@
*
* Single segment:
* - If slug matches a subpage → render subpage album grid
* - If slug matches a content page (content/pages/<slug>.md) → render it
* - If slug matches a standalone album → render album detail
*
* Two segments:
* - Treat as subpage-slug/album-slug → render album detail
* with back-link to the subpage
*
* Three or more segments: 404.
*/
import { cookies } from 'next/headers';
import type { Metadata } from 'next';
import { immich, type ImmichAsset } from '@/lib/immich';
import { notFound } from 'next/navigation';
import type { PhotoItem } from './PhotoGrid';
import {
imageUrl,
exifUrl,
videoUrl,
assetPlaceholder,
assetCaption,
assetExifSummary,
downloadUrl,
archiveUrl,
assetAspectRatio,
} from '@/lib/urls';
import { toPhotoItems } from './photoItems';
import { imageUrl, assetPlaceholder, archiveUrl } from '@/lib/urls';
import { ogImageUrl } from '@/lib/ogImage';
import { encodeAssetId, decodeAssetId } from '@/lib/tokens';
import { encodeEmail } from '@/lib/emailObfuscation';
import {
buildCoverGridVars,
getConfig,
@@ -52,6 +47,8 @@ import { mapBlockAssetIds } from '@/lib/journal';
import { strictestPrecision, type LocationPrecision } from '@/lib/mapPrecision';
import { resolveEssayFile, generatedEssayCaption } from '@/lib/essaySource';
import { getServerDictionary } from '@/lib/i18n/server';
import { contentPageMetadata, renderContentPage } from './contentPage';
import { loadAlbumBlocks } from './essayPayload';
// Render at request time — requires live Immich connection
export const dynamic = 'force-dynamic';
@@ -75,13 +72,32 @@ async function isLocked(key: string, type: 'subpage' | 'album'): Promise<boolean
return !isAuthenticated(key, getCookie, type);
}
/**
* Metadata for a path that names nothing this site publishes. The title is
* the dictionary's, never the requested slug: generateMetadata runs for any
* URL a visitor types, and echoing it would put their text in the `<title>`
* and, through a signed share card, in an image served from this domain.
* Leaving `openGraph` out keeps the root layout's site card. `robots: null`
* drops the layout's `index, follow`, which would otherwise contradict the
* `noindex` Next adds to every not-found response.
*/
function notFoundMetadata(): Metadata {
return { title: getServerDictionary().error.notFoundTitle, robots: null };
}
/** Metadata for a path behind a password this request has not given. */
function lockedMetadata(): Metadata {
return { title: getServerDictionary().password.protectedPage, robots: { index: false } };
}
export async function generateMetadata({ params, searchParams }: PathPageProps): Promise<Metadata> {
// Next hands catch-all segments over percent-encoded, so a non-ASCII slug
// ("/家族相册") would never match a stored one. Decode once, here, and every
// comparison downstream works on the same form (#522).
const { path: rawPath } = await params;
const path = rawPath?.map(normalizeSlug);
if (!path || path.length === 0) return {};
// Deeper paths are a 404 (see the page below); they name nothing to describe.
if (!path || path.length === 0 || path.length > 2) return notFoundMetadata();
// A shared photo link's whole point is that it reaches the server — unlike
// the #photo-N hash it replaces, a `photo` query param is visible here, so
@@ -92,45 +108,51 @@ export async function generateMetadata({ params, searchParams }: PathPageProps):
const photoAssetId = photoToken ? decodeAssetId(photoToken) : null;
let photoAsset: ImmichAsset | undefined;
// Each branch follows the page body's order of gates and 404s, and takes
// the title only from site-authored text (an album or subpage name). A
// branch that finds nothing, or finds it locked, returns early, so the
// requested slug never becomes a title or share-card text.
const slug = path[0];
let title = slug;
let title: string;
let subtitle = '';
let description: string | undefined = undefined;
const photoCount = (assets: ImmichAsset[]) =>
getServerDictionary().common.photos(
assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length,
);
if (path.length === 1 && immich.isSubpageSlug(slug)) {
const subpageLocked = await isLocked(slug, 'subpage');
if (await isLocked(slug, 'subpage')) return lockedMetadata();
const result = await immich.getSubpageAlbums(slug);
if (result) {
if (!subpageLocked && result.subpage.subtitle) {
description = result.subpage.subtitle;
}
if (result.albums.length === 1) {
const album = await immich.getAlbumBySlug(result.albums[0].slug, slug);
if (album && !subpageLocked && !(await isLocked(album.id, 'album'))) {
title = album.albumName;
const count = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length;
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
if (photoAssetId) photoAsset = album.assets.find((a) => a.id === photoAssetId);
}
} else if (!subpageLocked) {
title = result.subpage.title || result.subpage.name;
}
if (!result || result.albums.length === 0) return notFoundMetadata();
if (result.subpage.subtitle) description = result.subpage.subtitle;
if (result.albums.length === 1) {
const album = await immich.getAlbumBySlug(result.albums[0].slug, slug);
if (!album) return notFoundMetadata();
if (await isLocked(album.id, 'album')) return lockedMetadata();
title = album.albumName;
subtitle = photoCount(album.assets);
if (photoAssetId) photoAsset = album.assets.find((a) => a.id === photoAssetId);
} else {
title = result.subpage.title || result.subpage.name;
}
} else if (path.length === 2) {
if (await isLocked(slug, 'subpage')) return lockedMetadata();
const album = await immich.getAlbumBySlug(path[1], slug);
if (album && !(await isLocked(slug, 'subpage')) && !(await isLocked(album.id, 'album'))) {
title = album.albumName;
const count = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length;
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
if (photoAssetId) photoAsset = album.assets.find((a) => a.id === photoAssetId);
}
if (!album) return notFoundMetadata();
if (await isLocked(album.id, 'album')) return lockedMetadata();
title = album.albumName;
subtitle = photoCount(album.assets);
if (photoAssetId) photoAsset = album.assets.find((a) => a.id === photoAssetId);
} else {
const pageMeta = await contentPageMetadata(slug);
if (pageMeta) return pageMeta;
const album = await immich.getAlbumBySlug(slug);
if (album && !(await isLocked(album.id, 'album'))) {
title = album.albumName;
const count = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length;
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
if (photoAssetId) photoAsset = album.assets.find((a) => a.id === photoAssetId);
}
if (!album) return notFoundMetadata();
if (await isLocked(album.id, 'album')) return lockedMetadata();
title = album.albumName;
subtitle = photoCount(album.assets);
if (photoAssetId) photoAsset = album.assets.find((a) => a.id === photoAssetId);
}
// The title stays the album's — it's still the context a reader wants —
@@ -140,9 +162,7 @@ export async function generateMetadata({ params, searchParams }: PathPageProps):
photoAsset?.exifInfo?.description?.trim() ||
description ||
(subtitle ? `${title} — ${subtitle}` : undefined);
const ogImage = photoAsset
? imageUrl(photoAsset.id, 'preview')
: `/api/og?title=${encodeURIComponent(title)}${subtitle ? `&subtitle=${encodeURIComponent(subtitle)}` : ''}`;
const ogImage = photoAsset ? imageUrl(photoAsset.id, 'preview') : ogImageUrl(title, subtitle);
return {
title,
@@ -161,44 +181,6 @@ export async function generateMetadata({ params, searchParams }: PathPageProps):
};
}
/** Map Immich assets to PhotoItem props for the grid/lightbox. */
/**
* `showExif` covers the hover overlay only, and that overlay carries camera,
* lens and focal length — so it follows the `camera` group, not the panel.
*
* `showCaption` follows the `caption` group and decides whether the Immich
* description becomes alt text; see `assetCaption`.
*/
function toPhotoItems(
assets: ImmichAsset[],
showExif: boolean,
showCaption: boolean,
/** The album offering downloads, or undefined when it does not. */
downloadAlbumId?: string,
): PhotoItem[] {
return assets
.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO')
.map((a) => {
const ph = assetPlaceholder(a);
const exif = showExif && a.type === 'IMAGE' ? assetExifSummary(a) : undefined;
const caption = assetCaption(a, showCaption);
const isVideo = a.type === 'VIDEO';
return {
id: encodeAssetId(a.id),
type: isVideo ? 'video' : 'image',
thumbUrl: imageUrl(a.id, 'preview'),
previewUrl: imageUrl(a.id, 'preview'),
...(isVideo ? { videoUrl: videoUrl(a.id) } : {}),
exifUrl: exifUrl(a.id),
...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}),
...(exif ?? {}),
...(caption ? { caption } : {}),
...(downloadAlbumId ? { downloadUrl: downloadUrl(downloadAlbumId, a.id) } : {}),
aspectRatio: assetAspectRatio(a),
};
});
}
/**
* JSON-LD for an album page. Null unless a site URL is configured — structured
* data is a set of claims about absolute URLs, and there is nothing truthful to
@@ -242,7 +224,8 @@ async function gateIfProtected(
let title = titleOverride || key;
if (!titleOverride && type === 'subpage') {
const subpageData = await immich.getSubpageAlbums(key);
title = subpageData?.subpage.name ?? key;
const sp = subpageData?.subpage;
title = sp ? sp.title || sp.name : key;
}
return <PasswordGate slug={key} title={title} type={type} />;
@@ -280,6 +263,12 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
const config = getConfig();
// The proofing dialog's "email to photographer" needs a recipient: the
// configured `proofing.email`, else the footer contact email (#736). Encoded
// here so the plain address stays out of the HTML and the RSC payload, like
// every other address the site renders.
const encodedMailto = config.proofing.email ? encodeEmail(config.proofing.email) : undefined;
// Build grid CSS custom properties, optionally merging subpage overrides
const buildGridStyle = (overrides?: Partial<GridConfig>): React.CSSProperties => {
const g = { ...config.grid, ...overrides };
@@ -322,7 +311,10 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
return { ...spGrid, ...albumGrid };
};
if (!path || path.length === 0) {
// One segment or two: nothing on this site lives deeper. A third segment
// used to fall through to the one-segment branch below, which reads only
// `path[0]`, so `/travel/iceland/anything` rendered the Travel subpage.
if (!path || path.length === 0 || path.length > 2) {
notFound();
}
@@ -351,7 +343,9 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
// Look up subpage config for grid overrides and back link
const subpageData = await immich.getSubpageAlbums(subpageSlug, forceFresh);
const spGrid = subpageData?.subpage.grid;
const subpageName = subpageData?.subpage.name ?? subpageSlug;
const subpageName = subpageData
? subpageData.subpage.title || subpageData.subpage.name
: subpageSlug;
const images = toPhotoItems(
album.assets,
@@ -385,6 +379,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
showGear={config.exif.camera}
proofing={proofingFor(subpageData?.subpage)}
allowMailto={config.proofing.allowMailto}
encodedMailto={encodedMailto}
downloadArchiveUrl={config.albumDownloads[album.id] ? archiveUrl(album.id) : undefined}
{...heroData}
/>
@@ -435,7 +430,15 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
getCookie: (name) => cookieStore.get(name)?.value,
});
if (file.status === 'locked') {
return <PasswordGate slug={result.subpage.essayFile} title={file.title} type="journal" />;
// The subpage's own title, which the nav already shows — not the
// borrowed entry's, which the journal index hides while it is locked.
return (
<PasswordGate
slug={result.subpage.essayFile}
title={result.subpage.title || result.subpage.name}
type="journal"
/>
);
}
if (file.status === 'open') essayParsed = file.parsed;
}
@@ -453,16 +456,10 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
// does, from a raw fetch, and add those photos to the page's assets.
// Their ids are encoded here because this path hands EssayView tokens.
if (essayParsed?.blocks.some((b) => b.type === 'album')) {
const byAlbum = new Map<string, typeof allAssets>();
for (const b of essayParsed.blocks) {
if (b.type !== 'album' || !b.albumId || byAlbum.has(b.albumId)) continue;
try {
byAlbum.set(b.albumId, await immich.getAlbumAssetsRaw(b.albumId));
} catch (error) {
// eslint-disable-next-line no-console
console.warn(`[essay] ${slug}: album ${b.albumId} could not be loaded:`, error);
}
}
const byAlbum = await loadAlbumBlocks(essayParsed.blocks, (albumId, error) => {
// eslint-disable-next-line no-console
console.warn(`[essay] ${slug}: album ${albumId} could not be loaded:`, error);
});
const expanded = expandAlbumBlocks(
essayParsed.blocks,
(id) => byAlbum.get(id),
@@ -551,6 +548,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
watermark={config.watermark}
proofing={essayProofing}
allowMailto={config.proofing.allowMailto}
encodedMailto={encodedMailto}
/>
);
}
@@ -599,6 +597,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
showGear={config.exif.camera}
proofing={proofingFor(result.subpage)}
allowMailto={config.proofing.allowMailto}
encodedMailto={encodedMailto}
downloadArchiveUrl={config.albumDownloads[album.id] ? archiveUrl(album.id) : undefined}
{...heroData}
/>
@@ -653,12 +652,22 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
gridStyle={buildCoverGridStyle(result.subpage.coverGrid)}
{...(subpageIndex >= 0 ? { index: subpageIndex + 1 } : {})}
{...(nextSubpage
? { nextSubpage: { slug: nextSubpage.slug, name: nextSubpage.name } }
? {
nextSubpage: { slug: nextSubpage.slug, name: nextSubpage.title || nextSubpage.name },
}
: {})}
/>
);
}
// A content page (#722). The admin refuses a page slug that collides with a
// subpage or an album, so the order here only matters for a hand-edited file.
if (path.length === 1) {
const page = await renderContentPage(slug);
if (page === 'not-found') notFound();
if (page) return page;
}
// Otherwise treat as a standalone album slug
const album = await immich.getAlbumBySlug(slug, undefined, forceFresh);
if (!album) {
@@ -701,6 +710,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
showGear={config.exif.camera}
proofing={proofingFor()}
allowMailto={config.proofing.allowMailto}
encodedMailto={encodedMailto}
downloadArchiveUrl={config.albumDownloads[album.id] ? archiveUrl(album.id) : undefined}
{...heroData}
/>

View File

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

View File

@@ -49,8 +49,9 @@ export default function AdminShell({ children }: { children: ReactNode }) {
if (authenticated === null) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
<div className="admin-loading" role="status">
<div className="admin-spinner" aria-hidden="true" />
<span className="sr-only">Loading admin panel</span>
</div>
);
}

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
// @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';
import { ConfirmProvider, useChoice, useConfirm } from '../components/ConfirmDialog';
afterEach(cleanup);
@@ -60,3 +60,46 @@ describe('ConfirmDialog', () => {
await waitFor(() => expect(results).toEqual([false, false]));
});
});
/** The concurrent-edit prompt (#601): more than two ways out. */
function ChoiceTrigger({ onResult }: { onResult: (id: string | null) => void }) {
const choose = useChoice();
return (
<button
onClick={async () =>
onResult(
await choose({
title: 'Changed elsewhere',
actions: [
{ id: 'overwrite', label: 'Overwrite anyway' },
{ id: 'reload', label: 'Reload (discard mine)' },
],
cancelLabel: 'Keep editing',
}),
)
}
>
choose
</button>
);
}
describe('useChoice', () => {
it('resolves to the picked action, or null on cancel', async () => {
const results: (string | null)[] = [];
render(
<ConfirmProvider>
<ChoiceTrigger onResult={(id) => results.push(id)} />
</ConfirmProvider>,
);
fireEvent.click(screen.getByText('choose'));
fireEvent.click(screen.getByRole('button', { name: 'Reload (discard mine)' }));
await waitFor(() => expect(results).toEqual(['reload']));
fireEvent.click(screen.getByText('choose'));
fireEvent.click(screen.getByRole('button', { name: 'Overwrite anyway' }));
await waitFor(() => expect(results).toEqual(['reload', 'overwrite']));
fireEvent.click(screen.getByText('choose'));
fireEvent.click(screen.getByRole('button', { name: 'Keep editing' }));
await waitFor(() => expect(results).toEqual(['reload', 'overwrite', null]));
});
});

View File

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

View File

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

View File

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

View File

@@ -160,3 +160,29 @@ describe('subpage keys', () => {
});
});
});
describe('content page references (#722)', () => {
it('keeps `- page:` entries where they were among the subpages', () => {
const yaml = {
subpages: [
{ page: 'pricing' },
{ name: 'Japan', albums: [A] },
{ page: 'faq' },
{ name: 'Korea', albums: [B] },
{ page: 'contact-me' },
],
};
expect(roundTrip(yaml)).toEqual(yaml);
});
it('parses page references apart from the subpages', () => {
const g = parseGalleryYaml({ subpages: [{ name: 'Japan', albums: [A] }, { page: 'faq' }] });
expect(g.subpages.map((s) => s.name)).toEqual(['Japan']);
expect(g.pageRefs).toEqual([{ slug: 'faq', position: 1 }]);
});
it('writes a menu of pages only', () => {
const yaml = { subpages: [{ page: 'pricing' }] };
expect(roundTrip(yaml)).toEqual(yaml);
});
});

View File

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

View File

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

View File

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

View File

@@ -34,4 +34,5 @@
@import './styles/diagnostics.css';
@import './styles/messages.css';
@import './styles/notifications.css';
@import './styles/proofing.css';
@import './styles/redesign.css';

View File

@@ -47,7 +47,7 @@ const NAV: {
{
label: 'Pages',
href: '/admin/pages',
match: /^\/admin\/pages$/,
match: /^\/admin\/pages(\/|$)/,
icon: <Icons.IconGrid size={16} />,
},
{
@@ -68,6 +68,12 @@ const NAV: {
icon: <Icons.IconFileText size={16} />,
badge: 'messages',
},
{
label: 'Proofing',
href: '/admin/proofing',
match: /^\/admin\/proofing/,
icon: <Icons.IconHeart size={16} />,
},
{
label: 'Analytics',
href: '/admin/analytics',
@@ -277,6 +283,9 @@ export default function AdminDashboard({ onLogout, children }: Props) {
menuOpen ? ' menu-open' : ''
}`}
>
<a href="#admin-main" className="skip-link">
Skip to content
</a>
<aside className="admin-sidebar" aria-label="Admin">
<button
type="button"
@@ -506,7 +515,9 @@ export default function AdminDashboard({ onLogout, children }: Props) {
</div>
</aside>
<main className="admin-main admin-workspace">{children}</main>
<main id="admin-main" tabIndex={-1} className="admin-main admin-workspace">
{children}
</main>
<BackupManagerModal
isOpen={showBackupModal}

View File

@@ -15,9 +15,11 @@ import * as Icons from './Icons';
import { adminGet } from './useAdminFetch';
import type { DoctorFinding, DoctorLevel } from '@/lib/admin/doctor';
import { DOCTOR_LEVEL_EVENT } from './systemHealth';
import { parseGalleryYaml } from './page-builder/galleryYaml';
interface OverviewData {
gallery: { hero?: unknown; subpages?: Array<{ enabled?: boolean }> } | null;
/** gallery.yaml as stored; `subpages` may be a list or the older name → albums map. */
gallery: Record<string, unknown> | null;
journal: Array<{ frontmatter: { draft?: boolean } }> | null;
messages: Array<{ read: boolean; name: string }> | null;
analytics: { days?: Record<string, { pageviews?: number }> } | null;
@@ -32,6 +34,7 @@ const FIX_ROUTE: Record<string, string> = {
passwords: '/admin/settings/security',
'album-ids': '/admin/pages',
'albums-shared': '/admin/pages',
'content-pages': '/admin/pages',
};
function viewsLastDays(
@@ -85,7 +88,9 @@ export default function AdminOverview() {
};
}, []);
const subpages = data?.gallery?.subpages ?? [];
// Read the way the page builder reads it: both shapes of `subpages:`, and
// `- page:` entries, which are content pages in the menu, not subpages (#722).
const subpages = data?.gallery ? parseGalleryYaml(data.gallery).subpages : [];
const published = subpages.filter((sp) => sp.enabled !== false).length;
const heroRaw = data?.gallery?.hero;
const heroCount = Array.isArray(heroRaw) ? heroRaw.length : heroRaw ? 1 : 0;

View File

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

View File

@@ -4,6 +4,7 @@ import * as Icons from './Icons';
import { useAdminFetch } from './useAdminFetch';
import AdminLoadState from './AdminLoadState';
import PageHeader from './PageHeader';
import { lastDays } from './analyticsDays';
interface AnalyticsData {
trackingEnabled?: boolean;
@@ -40,10 +41,9 @@ export default function AnalyticsView() {
const totalViews = data.summary?.totalViews || 0;
const maxPageViews = data.topPages[0]?.count || 1;
const daysList = Object.entries(data.days || {})
.sort((a, b) => a[0].localeCompare(b[0]))
.slice(-14); // Last 14 days
const maxDayViews = Math.max(...daysList.map(([, d]) => d.pageviews || 0), 1);
const hasDays = Object.keys(data.days || {}).length > 0;
const daysList = lastDays(data.days || {}, 14, new Date());
const maxDayViews = Math.max(...daysList.map((d) => d.pageviews), 1);
const desktopRatio =
data.devices.desktop + data.devices.mobile > 0
@@ -105,24 +105,24 @@ export default function AnalyticsView() {
<Icons.IconGrid size={16} /> Daily Traffic Trend (Last 14 Days)
</h3>
</div>
{daysList.length === 0 ? (
{!hasDays ? (
<p className="analytics-empty">
No pageviews recorded yet. Visit portfolio pages to start tracking!
</p>
) : (
<div className="analytics-bar-chart">
{daysList.map(([dateStr, dayData]) => {
const heightPct = Math.round((dayData.pageviews / maxDayViews) * 100);
const formattedDate = dateStr.slice(5); // MM-DD
{daysList.map(({ date, pageviews }) => {
const heightPct = Math.round((pageviews / maxDayViews) * 100);
const formattedDate = date.slice(5); // MM-DD
return (
<div
key={dateStr}
className="bar-col"
title={`${dateStr}: ${dayData.pageviews} views`}
>
<div key={date} className="bar-col" title={`${date}: ${pageviews} views`}>
<div className="bar-wrapper">
<div className="bar-fill" style={{ height: `${Math.max(heightPct, 8)}%` }}>
<span className="bar-val">{dayData.pageviews}</span>
{/* A day with views keeps a sliver of bar; an empty one has none. */}
<div
className="bar-fill"
style={{ height: `${pageviews > 0 ? Math.max(heightPct, 3) : 0}%` }}
>
<span className="bar-val">{pageviews}</span>
</div>
</div>
<span className="bar-label">{formattedDate}</span>

View File

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

View File

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

View File

@@ -20,6 +20,7 @@ const TABS: { target: BackupTarget; label: string; file: string }[] = [
{ target: 'about', label: 'About', file: 'about.md' },
{ target: 'privacy', label: 'Privacy', file: 'privacy.md' },
{ target: 'journal', label: 'Journal', file: 'journal entries' },
{ target: 'pages', label: 'Content pages', file: 'content pages' },
];
type BackupLists = Record<BackupTarget, BackupItem[]>;
@@ -30,6 +31,7 @@ const EMPTY_LISTS: BackupLists = {
about: [],
privacy: [],
journal: [],
pages: [],
};
export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }: Props) {
@@ -87,7 +89,7 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
setSuccessMsg(
item.slug
? `Journal entry "${item.slug}" restored.`
? `${item.target === 'pages' ? 'Page' : 'Journal entry'} "${item.slug}" restored.`
: `Backup restored successfully! (${filename})`,
);
setConfirmItem(null);
@@ -121,8 +123,8 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
<div>
<h2 id="backup-modal-title">Backup History &amp; Restoration</h2>
<p className="backup-modal-subtitle">
Restore an earlier version of your pages, settings, about page or journal — including
deleted journal entries.
Restore an earlier version of your pages, settings, about page, journal or content
pages — including deleted journal entries and pages.
</p>
</div>
<button className="backup-modal-close-btn" onClick={onClose} aria-label="Close">
@@ -194,7 +196,7 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
const dateObj = item.timestamp ? new Date(item.timestamp) : null;
const formattedDate =
dateObj && !isNaN(dateObj.getTime())
? dateObj.toLocaleString('de-DE', {
? dateObj.toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'medium',
})
@@ -251,8 +253,8 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
}}
/>
{showAllBackups
? 'Ältere Einträge einklappen'
: `Ältere Einträge anzeigen (${hiddenCount} weitere)`}
? 'Hide older backups'
: `Show ${hiddenCount} older ${hiddenCount === 1 ? 'backup' : 'backups'}`}
</button>
)}
</div>

View File

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

View File

@@ -45,7 +45,16 @@ const GROUPS: { id: string; title: string; checks: string[] }[] = [
{
id: 'content',
title: 'Content',
checks: ['albums-shared', 'content-writable', 'alt-text', 'legal', 'contact', 'privacy'],
checks: [
'albums-shared',
'content-pages',
'content-writable',
'alt-text',
'legal',
'contact',
'privacy',
'settings-values',
],
},
];
@@ -65,9 +74,12 @@ const FIXES: Record<string, { label: string; href: string }> = {
passwords: { label: 'Open settings', href: '/admin/settings/security' },
'album-ids': { label: 'Open pages', href: '/admin/pages' },
'albums-shared': { label: 'Open pages', href: '/admin/pages' },
'content-pages': { label: 'Open pages', href: '/admin/pages' },
legal: { label: 'Open settings', href: '/admin/settings/legal' },
contact: { label: 'Open settings', href: '/admin/settings/legal' },
privacy: { label: 'Open settings', href: '/admin/settings/legal' },
// The href is replaced by the finding's own settingsSection.
'settings-values': { label: 'Open settings', href: '/admin/settings/general' },
'content-writable': { label: 'How to fix', href: `${DOCS}/docs/admin-panel.md#docker-usage` },
'immich-api': {
label: 'API key permissions',
@@ -320,8 +332,12 @@ export default function DiagnosticsView() {
}
return null; // The photo list toggle is rendered separately.
}
const fix = FIXES[id];
if (!fix) return null;
const known = FIXES[id];
if (!known) return null;
// A finding that knows which settings section holds its fix goes there.
const fix = f.settingsSection
? { ...known, href: `/admin/settings/${f.settingsSection}` }
: known;
return fix.href.startsWith('/') ? (
<Link href={fix.href} className="admin-btn admin-btn-sm">
{fix.label}

View File

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

View File

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

View File

@@ -24,7 +24,16 @@ import SaveBar, { type SaveStatus } from './SaveBar';
import AlbumDrawer from './page-builder/AlbumDrawer';
import { SortableAlbumCard } from './page-builder/AlbumCard';
import SubpageDrawer from './page-builder/SubpageDrawer';
import { SortableHeroTile, SortableSubpageRow } from './page-builder/SortableTiles';
import {
OffMenuDropZone,
SortableHeroTile,
SortablePageRow,
SortableSubpageRow,
} from './page-builder/SortableTiles';
import PagePanel from './page-builder/PagePanel';
import { isValidSlug } from '@/lib/journal';
import NewPageDialog from './page-builder/NewPageDialog';
import type { PageSummary, SlugTakenBy } from '@/lib/pages';
import { findAlbumAddress } from './page-builder/findAlbumAddress';
import { parseGalleryYaml, serializeGallery, type GalleryState } from './page-builder/galleryYaml';
import * as ops from './page-builder/galleryOps';
@@ -41,11 +50,22 @@ import {
import { useScrollLock } from './useScrollLock';
import { useUnsavedGuard } from './useUnsavedGuard';
import { useDraft } from './useDraft';
import { useLatest } from './useLatest';
import { useVersionedSave } from './useVersionedSave';
import type { GalleryVersionChange } from '@/lib/admin/pageRefs';
import DraftNotice from './DraftNotice';
import { reportIfSessionExpired } from './sessionExpiry';
import { IconCamera, IconHome, IconPlus, IconSearch } from './Icons';
import { useNotify } from './Notifications';
/** Drop target id of the "Not in menu" group. */
const OFF_MENU_ID = 'offmenu-zone';
/** Sortable id of a menu row. Subpages keep their old `subpage-<index>` id. */
function menuRowId(item: ops.MenuItem): string {
return item.kind === 'subpage' ? `subpage-${item.index}` : `page-${item.slug}`;
}
export default function PageBuilder() {
const [gallery, setGallery] = useState<GalleryState>({ hero: [], albums: [], subpages: [] });
const notify = useNotify();
@@ -79,6 +99,21 @@ export default function PageBuilder() {
/** Album a `?album=` link pointed at, marked in its subpage sheet for a moment. */
const [linkedAlbumId, setLinkedAlbumId] = useState<string | null>(null);
// ── Content pages (#722) ──
/** Every page file; the menu itself lives in `gallery.pageRefs`. */
const [pages, setPages] = useState<PageSummary[]>([]);
const [takenSlugs, setTakenSlugs] = useState<SlugTakenBy>({
subpages: [],
albums: [],
journal: [],
});
/** The page shown in the panel; exclusive with `expandedSubpage`. */
const [selectedPage, setSelectedPage] = useState<string | null>(null);
const [showNewPage, setShowNewPage] = useState(false);
const [creatingPage, setCreatingPage] = useState(false);
/** The edit panel beside (or, on narrow screens, below) the structure list. */
const panelRef = useRef<HTMLDivElement>(null);
// Keep the builder still behind either drawer. One combined lock rather than
// one per drawer: the album drawer opens from inside the subpage drawer, and
// a single condition avoids two locks racing over the same inline style.
@@ -121,7 +156,12 @@ export default function PageBuilder() {
// Unsaved edits survive leaving the builder — a tab link, back, Reload,
// Logout (#592). `serverState` is what a discard goes back to.
const draft = useDraft<GalleryState>('page-builder', gallery, dirty);
/** The tree as last rendered: tells a finished save whether editing went on meanwhile. */
const latestGallery = useLatest(gallery);
const serverState = useRef<GalleryState | null>(null);
/** gallery.yaml as loaded, sent back on save so a change elsewhere is caught (#601). */
const versionRef = useRef<string | null>(null);
const versionedSave = useVersionedSave();
// A subpage opened from a `?album=` link can hold dozens of albums: bring the
// linked one into view, and let the mark fade after a moment. The mark itself
@@ -147,9 +187,10 @@ export default function PageBuilder() {
setLoading(true);
setLoadError(null);
try {
const [galleryRes, albumsRes] = await Promise.all([
const [galleryRes, albumsRes, pagesRes] = await Promise.all([
fetch('/api/admin/gallery'),
fetch('/api/admin/albums'),
fetch('/api/admin/pages'),
]);
if (!galleryRes.ok) {
@@ -160,7 +201,8 @@ export default function PageBuilder() {
);
}
const { gallery: raw } = await galleryRes.json();
const { gallery: raw, version } = await galleryRes.json();
versionRef.current = typeof version === 'string' ? version : null;
const parsed = parseGalleryYaml(raw);
serverState.current = parsed;
const restored = draft.load(JSON.stringify(parsed));
@@ -176,6 +218,15 @@ export default function PageBuilder() {
const { albums } = await albumsRes.json();
setImmichAlbums(albums);
}
// Pages are survivable the same way: without the list the menu still
// shows their references, and saving keeps them.
if (pagesRes.ok) {
const data = (await pagesRes.json()) as { pages: PageSummary[]; taken: SlugTakenBy };
setPages(data.pages);
setTakenSlugs(data.taken);
}
openPageFromLink();
} catch (err) {
console.error('Failed to load admin data:', err);
setLoadError(err instanceof Error ? err.message : 'The page structure could not be loaded.');
@@ -213,6 +264,21 @@ export default function PageBuilder() {
}
}
/** `?page=<slug>` selects a page — the page editor's back link uses it. One-shot. */
function openPageFromLink() {
const params = new URLSearchParams(window.location.search);
const slug = params.get('page');
if (slug === null) return;
params.delete('page');
const query = params.toString();
window.history.replaceState(null, '', window.location.pathname + (query ? `?${query}` : ''));
// The slug comes from the address bar and becomes the page's live link, so
// only a real page slug may select a page — `//host` would leave the site.
if (!isValidSlug(slug)) return;
setExpandedSubpage(null);
setSelectedPage(slug);
}
function discardDraft() {
draft.discard();
if (serverState.current) setGallery(serverState.current);
@@ -240,17 +306,24 @@ export default function PageBuilder() {
setSaving(true);
setSaveStatus(null);
const yamlData = serializeGallery(gallery);
const sent = gallery;
const yamlData = serializeGallery(sent);
try {
const res = await fetch('/api/admin/gallery', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gallery: yamlData }),
});
const result = await versionedSave(
'/api/admin/gallery',
{ gallery: yamlData },
versionRef,
'The page structure (gallery.yaml)',
);
if (res.ok) {
const data = await res.json();
if (result.kind === 'reload') {
draft.discard();
await loadData();
} else if (result.kind === 'keep') {
setSaveStatus({ kind: 'error', message: 'Not saved — gallery.yaml changed elsewhere.' });
} else if (result.kind === 'saved') {
const data = result.data as { gallery?: unknown; message?: string };
// Fingerprint what the next load will see, the way it will see it: the
// file as written, read back through the same parser. The editor's own
// state can differ in shape (an `undefined` here, a default there) and
@@ -262,15 +335,22 @@ export default function PageBuilder() {
const written = (data.gallery ?? yamlData) as Record<string, unknown>;
const asLoaded = parseGalleryYaml(JSON.parse(JSON.stringify(written)));
serverState.current = asLoaded;
draft.saved(JSON.stringify(asLoaded));
// Show the hashes, so a new password reads "Protected" right away.
if (JSON.stringify(written) !== JSON.stringify(yamlData)) setGallery(asLoaded);
setDirty(false);
// Edited while the request was out: those edits are not saved yet, so
// they stay dirty and are not replaced by the file as written.
const editedMeanwhile = latestGallery.current !== sent;
draft.saved(JSON.stringify(asLoaded), editedMeanwhile);
if (!editedMeanwhile) {
// Show the hashes, so a new password reads "Protected" right away.
if (JSON.stringify(written) !== JSON.stringify(yamlData)) setGallery(asLoaded);
setDirty(false);
}
setSaveStatus({ kind: 'success', message: data.message || 'Saved successfully!' });
setTimeout(() => setSaveStatus(null), 5000);
} else if (!reportIfSessionExpired(res)) {
const err = await res.json();
setSaveStatus({ kind: 'error', message: `Error: ${err.error}` });
} else if (!reportIfSessionExpired(result.res)) {
setSaveStatus({
kind: 'error',
message: `Error: ${result.data?.error ?? `HTTP ${result.res.status}`}`,
});
}
} catch {
setSaveStatus({ kind: 'error', message: 'Error: Failed to save' });
@@ -286,8 +366,8 @@ export default function PageBuilder() {
}
// ── Pickers ──────────────────────────────────────────────────
function handleHeroSelect(assetId: string) {
edit((g) => ops.addHero(g, assetId));
function handleHeroSelectMany(assetIds: string[]) {
edit((g) => ops.addHeroes(g, assetIds));
setHeroPickerTarget(null);
}
@@ -322,17 +402,158 @@ export default function PageBuilder() {
}
}
function handleSubpageDragEnd(event: DragEndEvent) {
/**
* The menu list holds subpages and pages in one order (#722). A page can
* also be dragged into or out of "Not in menu", which toggles its "Show in
* menu" switch.
*/
function handleMenuDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const activeId = String(active.id);
const overId = String(over.id);
const items = ops.menuItems(gallery);
const rowIndex = (id: string) => items.findIndex((item) => menuRowId(item) === id);
const overOffMenu = overId === OFF_MENU_ID || overId.startsWith('off-');
const oldIndex = gallery.subpages.findIndex((_, i) => `subpage-${i}` === active.id);
const newIndex = gallery.subpages.findIndex((_, i) => `subpage-${i}` === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
edit((g) => ops.moveSubpage(g, oldIndex, newIndex));
setExpandedSubpage(ops.followMovedIndex(expandedSubpage, oldIndex, newIndex));
if (activeId.startsWith('off-')) {
if (overOffMenu) return;
const at = rowIndex(overId);
if (at === -1) return;
const slug = activeId.slice('off-'.length);
edit((g) => ops.setPageInMenu(g, slug, true, at));
return;
}
const from = rowIndex(activeId);
if (from === -1) return;
if (overOffMenu) {
const item = items[from];
if (item.kind === 'page') edit((g) => ops.setPageInMenu(g, item.slug, false));
return;
}
const to = rowIndex(overId);
if (to === -1) return;
setExpandedSubpage(ops.followMenuMove(gallery, expandedSubpage, from, to));
edit((g) => ops.moveMenuItem(g, from, to));
}
// ── Content pages ────────────────────────────────────────────
function selectPage(slug: string) {
setExpandedSubpage(null);
setLinkedAlbumId(null);
setSelectedPage(slug);
revealPanel();
}
function selectSubpage(index: number) {
setSelectedPage(null);
setExpandedSubpage(index);
revealPanel();
}
function selectOverview(which: 'hero' | 'albums') {
setExpandedSubpage(null);
setSelectedPage(null);
setOverview(which);
revealPanel();
}
/**
* Below 1100px the panel stacks under the structure list, so on a phone a
* tapped row changed something a screen further down and looked like it
* did nothing. Scroll the panel up once it has rendered.
*/
function revealPanel() {
if (!window.matchMedia?.('(max-width: 1100px)').matches) return;
requestAnimationFrame(() => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
panelRef.current?.scrollIntoView({ block: 'start', behavior: reduce ? 'auto' : 'smooth' });
});
}
const togglePageMenu = (slug: string) =>
edit((g) => ops.setPageInMenu(g, slug, !ops.isPageInMenu(g, slug)));
/**
* gallery.yaml was changed on the server by a page rename or delete. The
* same change goes into the builder's state and into what it considers
* saved, so neither a pending edit nor the draft check trips over it.
*/
function applyServerMenuChange(
op: (g: GalleryState) => GalleryState,
change?: GalleryVersionChange | null,
) {
setGallery(op);
// Follow the rewrite only when it was the sole change since this editor
// loaded; otherwise the old version stays and the next save asks (#601).
if (change && versionRef.current === change.from) versionRef.current = change.to;
if (serverState.current) {
serverState.current = op(serverState.current);
draft.saved(JSON.stringify(serverState.current));
}
}
async function createPage(input: { title: string; slug: string; showInMenu: boolean }) {
setCreatingPage(true);
try {
const res = await fetch('/api/admin/pages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: input.title, slug: input.slug }),
});
const data = await res.json().catch(() => null);
if (!res.ok) {
if (!reportIfSessionExpired(res))
notify('error', data?.error || 'Failed to create the page');
return;
}
const slug: string = data.page.slug;
setPages((prev) => [
...prev.filter((p) => p.slug !== slug),
{ slug, frontmatter: { title: input.title, draft: true } },
]);
if (input.showInMenu) edit((g) => ops.setPageInMenu(g, slug, true));
setShowNewPage(false);
selectPage(slug);
notify(
'success',
input.showInMenu
? 'Page created as a draft. Save Changes to put it in the menu.'
: 'Page created as a draft.',
);
} catch {
notify('error', 'Could not create the page. Check the connection and try again.');
} finally {
setCreatingPage(false);
}
}
function handlePageSaved(
oldSlug: string,
page: PageSummary,
menuRenamed: boolean,
galleryVersion?: GalleryVersionChange | null,
) {
setPages((prev) => [...prev.filter((p) => p.slug !== oldSlug), page]);
if (page.slug !== oldSlug) {
const rename = (g: GalleryState) => ops.renamePageRef(g, oldSlug, page.slug);
if (menuRenamed) applyServerMenuChange(rename, galleryVersion);
else setGallery(rename);
setSelectedPage(page.slug);
}
}
function handlePageDeleted(
slug: string,
removedFromMenu: boolean,
galleryVersion?: GalleryVersionChange | null,
) {
setPages((prev) => prev.filter((p) => p.slug !== slug));
const remove = (g: GalleryState) => ops.setPageInMenu(g, slug, false);
if (removedFromMenu) applyServerMenuChange(remove, galleryVersion);
else setGallery(remove);
setSelectedPage(null);
}
function handleSubpageAlbumDragEnd(spIndex: number) {
@@ -510,6 +731,16 @@ export default function PageBuilder() {
);
});
// The menu: subpages and pages in saved order (#722). Search filters it.
const pageBySlug = new Map(pages.map((p) => [p.slug, p]));
const pageTitle = (slug: string) => pageBySlug.get(slug)?.frontmatter.title || slug;
const query = searchQuery.toLowerCase();
const pageMatches = (slug: string) =>
!searchQuery || slug.includes(query) || pageTitle(slug).toLowerCase().includes(query);
const offMenuPages = pages
.filter((p) => !ops.isPageInMenu(gallery, p.slug) && pageMatches(p.slug))
.sort((a, b) => pageTitle(a.slug).localeCompare(pageTitle(b.slug)));
// Filter subpages
const filteredSubpages = gallery.subpages
.map((sp, index) => ({ sp, index }))
@@ -537,6 +768,12 @@ export default function PageBuilder() {
return hasMatchingAlbum;
});
const shownSubpages = new Set(filteredSubpages.map(({ index }) => index));
const menuRows = ops
.menuItems(gallery)
.filter((item) =>
item.kind === 'subpage' ? shownSubpages.has(item.index) : pageMatches(item.slug),
);
return (
<div className="page-builder">
@@ -593,10 +830,7 @@ export default function PageBuilder() {
<button
type="button"
className={`pb-row-main pb-row-solo ${expandedSubpage === null && overview === 'hero' ? 'active' : ''}`}
onClick={() => {
setExpandedSubpage(null);
setOverview('hero');
}}
onClick={() => selectOverview('hero')}
>
<span className="pb-row-thumb">
{gallery.hero[0] ? (
@@ -610,49 +844,102 @@ export default function PageBuilder() {
</button>
</div>
<div className="pb-group">
<div className="pb-group-head">
<span>Subpages</span>
<span>
{gallery.subpages.filter((sp) => sp.enabled !== false).length} of{' '}
{gallery.subpages.length} live
</span>
</div>
{gallery.subpages.length > 0 && filteredSubpages.length === 0 && (
<p className="empty-hint">No matching subpages.</p>
)}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleSubpageDragEnd}
>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleMenuDragEnd}
>
<div className="pb-group">
<div className="pb-group-head">
<span>Menu</span>
<span>
{gallery.subpages.filter((sp) => sp.enabled !== false).length} of{' '}
{gallery.subpages.length} subpages live
</span>
</div>
{(gallery.subpages.length > 0 || (gallery.pageRefs ?? []).length > 0) &&
menuRows.length === 0 && <p className="empty-hint">No matching entries.</p>}
<SortableContext
items={filteredSubpages.map(({ index }) => `subpage-${index}`)}
items={menuRows.map(menuRowId)}
strategy={verticalListSortingStrategy}
>
{filteredSubpages.map(({ sp, index }) => (
<SortableSubpageRow
key={`subpage-${index}`}
sp={sp}
spIndex={index}
isActive={expandedSubpage === index}
onClick={() => setExpandedSubpage(index)}
getFirstThumb={getFirstSubpageThumb}
/>
))}
{menuRows.map((item) => {
if (item.kind === 'subpage') {
const sp = gallery.subpages[item.index];
return (
<SortableSubpageRow
key={menuRowId(item)}
sp={sp}
spIndex={item.index}
isActive={expandedSubpage === item.index}
onClick={() => selectSubpage(item.index)}
getFirstThumb={getFirstSubpageThumb}
/>
);
}
const page = pageBySlug.get(item.slug);
return (
<SortablePageRow
key={menuRowId(item)}
id={menuRowId(item)}
title={pageTitle(item.slug)}
isActive={selectedPage === item.slug}
draft={page?.frontmatter.draft}
hasPassword={!!page?.frontmatter.password}
// Only once the list has loaded: an empty list is not proof.
missing={pages.length > 0 && !page}
onClick={() => selectPage(item.slug)}
/>
);
})}
</SortableContext>
</DndContext>
<button
type="button"
className="pb-add"
onClick={() => {
addSubpage();
setExpandedSubpage(gallery.subpages.length);
}}
>
<IconPlus size={13} /> New subpage
</button>
</div>
<div className="pb-add-row">
<button
type="button"
className="pb-add"
onClick={() => {
addSubpage();
selectSubpage(gallery.subpages.length);
}}
>
<IconPlus size={13} /> New subpage
</button>
<button type="button" className="pb-add" onClick={() => setShowNewPage(true)}>
<IconPlus size={13} /> New page
</button>
</div>
</div>
<div className="pb-group">
<div className="pb-group-head">
<span>Not in menu</span>
<span>{offMenuPages.length}</span>
</div>
<OffMenuDropZone id={OFF_MENU_ID}>
<SortableContext
items={offMenuPages.map((p) => `off-${p.slug}`)}
strategy={verticalListSortingStrategy}
>
{offMenuPages.map((p) => (
<SortablePageRow
key={`off-${p.slug}`}
id={`off-${p.slug}`}
title={pageTitle(p.slug)}
isActive={selectedPage === p.slug}
draft={p.frontmatter.draft}
hasPassword={!!p.frontmatter.password}
onClick={() => selectPage(p.slug)}
/>
))}
</SortableContext>
{offMenuPages.length === 0 && (
<p className="empty-hint">
Pages reachable only by their link. Drag a page here to take it out of the menu.
</p>
)}
</OffMenuDropZone>
</div>
</DndContext>
<div className="pb-group">
<div className="pb-group-head">
@@ -661,10 +948,7 @@ export default function PageBuilder() {
<button
type="button"
className={`pb-row-main pb-row-solo ${expandedSubpage === null && overview === 'albums' ? 'active' : ''}`}
onClick={() => {
setExpandedSubpage(null);
setOverview('albums');
}}
onClick={() => selectOverview('albums')}
>
<span className="pb-row-thumb">
<IconCamera size={14} />
@@ -676,8 +960,19 @@ export default function PageBuilder() {
</aside>
{/* The selected entry, edited in place instead of in an overlay. */}
<div className="pb-panel">
{expandedSubpage !== null && gallery.subpages[expandedSubpage] ? (
<div className="pb-panel" ref={panelRef}>
{selectedPage !== null ? (
<PagePanel
key={selectedPage}
slug={selectedPage}
inMenu={ops.isPageInMenu(gallery, selectedPage)}
onToggleMenu={() => togglePageMenu(selectedPage)}
taken={takenSlugs}
otherPageSlugs={pages.map((p) => p.slug).filter((s) => s !== selectedPage)}
onSaved={handlePageSaved}
onDeleted={handlePageDeleted}
/>
) : expandedSubpage !== null && gallery.subpages[expandedSubpage] ? (
<>
<SubpageDrawer
sp={gallery.subpages[expandedSubpage]}
@@ -782,9 +1077,9 @@ export default function PageBuilder() {
className="admin-btn admin-btn-sm"
onClick={() =>
setHeroPickerTarget({
onSelect: handleHeroSelect,
onSelectMany: handleHeroSelectMany,
currentAssetIds: gallery.hero,
title: 'Pick Hero Image for Homepage',
title: 'Pick Hero Images for Homepage',
})
}
>
@@ -824,6 +1119,16 @@ export default function PageBuilder() {
</div>
</div>
{showNewPage && (
<NewPageDialog
taken={takenSlugs}
existingPageSlugs={pages.map((p) => p.slug)}
creating={creatingPage}
onCreate={createPage}
onClose={() => setShowNewPage(false)}
/>
)}
{/* Album Picker Modal */}
{pickerTarget && (
<AlbumPicker
@@ -855,6 +1160,7 @@ export default function PageBuilder() {
<AssetPicker
albumId={heroPickerTarget.albumId}
onSelect={heroPickerTarget.onSelect}
onSelectMany={heroPickerTarget.onSelectMany}
onClose={() => setHeroPickerTarget(null)}
currentAssetIds={heroPickerTarget.currentAssetIds}
title={heroPickerTarget.title}

View File

@@ -11,10 +11,15 @@
* key in settings.yaml.
*/
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import * as Icons from './Icons';
import { reportIfSessionExpired } from './sessionExpiry';
import { useUnsavedGuard } from './useUnsavedGuard';
import { useContentRestored } from './contentRestored';
import { useVersionedSave } from './useVersionedSave';
import { useLatest } from './useLatest';
import { useDraft } from './useDraft';
import DraftNotice from './DraftNotice';
import type { ProcessingFact } from '@/lib/privacy';
interface PrivacyData {
@@ -22,6 +27,7 @@ interface PrivacyData {
enabled: boolean;
facts: ProcessingFact[];
starter: string;
version?: string;
}
type Status = { kind: 'success' | 'error'; message: string } | null;
@@ -35,44 +41,80 @@ export default function PrivacyEditor() {
const [status, setStatus] = useState<Status>(null);
useUnsavedGuard(dirty);
// The editor lives in the Legal section and unmounts when another section
// opens; without a draft an unsaved policy was simply gone (#554, #592).
const draft = useDraft<string>('privacy', body, dirty);
const loadDraft = draft.load;
/** privacy.md as loaded, sent back on save so a change elsewhere is caught (#601). */
const versionRef = useRef<string | null>(null);
const versionedSave = useVersionedSave();
/** The text as last rendered: tells a finished save whether typing went on meanwhile. */
const latestBody = useLatest(body);
const dirtyRef = useRef(dirty);
useEffect(() => {
dirtyRef.current = dirty;
});
const load = useCallback(async (): Promise<void> => {
try {
const res = await fetch('/api/admin/privacy');
if (!res.ok) {
reportIfSessionExpired(res);
throw new Error(`HTTP ${res.status}`);
}
const d = (await res.json()) as PrivacyData;
versionRef.current = d.version ?? null;
setData(d);
const restored = loadDraft(d.body);
setBody(restored ?? d.body);
setDirty(restored !== null);
} catch (err) {
setLoadError(err instanceof Error ? err.message : 'Could not load.');
}
}, [loadDraft]);
useEffect(() => {
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;
};
}, []);
void load();
}, [load]);
// A restored privacy.md replaces what this editor shows — unless there are
// unsaved edits, which stay; their save then meets the conflict prompt.
useContentRestored(({ target }) => {
if (target === 'privacy' && !dirtyRef.current) void load();
});
async function save() {
setSaving(true);
setStatus(null);
const sent = body;
try {
const 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}).` });
const result = await versionedSave(
'/api/admin/privacy',
{ body: sent },
versionRef,
'The privacy policy',
);
if (result.kind === 'reload') {
draft.discard();
await load();
return;
}
if (result.kind === 'keep') {
setStatus({ kind: 'error', message: 'Not saved — the policy changed elsewhere.' });
return;
}
if (result.kind === 'failed') {
if (!reportIfSessionExpired(result.res)) {
setStatus({ kind: 'error', message: `Could not save (HTTP ${result.res.status}).` });
}
return;
}
setDirty(false);
// Typed while the request was out: that part is not saved yet.
const editedMeanwhile = latestBody.current !== sent;
setData((d) => (d ? { ...d, body: sent } : d));
draft.saved(sent, editedMeanwhile);
if (!editedMeanwhile) setDirty(false);
setStatus({ kind: 'success', message: 'Privacy policy saved.' });
} catch {
setStatus({ kind: 'error', message: 'Could not reach the server.' });
@@ -99,6 +141,24 @@ export default function PrivacyEditor() {
return (
<div className="privacy-editor">
<DraftNotice
status={draft.status}
subject="privacy policy"
onDiscard={() => {
draft.discard();
setBody(data.body);
setDirty(false);
setStatus(null);
}}
onRestore={() => {
const value = draft.takeConflicting();
if (value === null) return;
setBody(value);
setDirty(true);
}}
onDismiss={draft.dismiss}
/>
<div className="privacy-editor__facts">
<h4>What this site processes</h4>
<p className="admin-field-hint">

View File

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

View File

@@ -7,16 +7,20 @@
* path through useAboutEditor().
*/
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import SaveBar, { type SaveStatus } from './SaveBar';
import { useUnsavedGuard } from './useUnsavedGuard';
import { useDraft } from './useDraft';
import { useLatest } from './useLatest';
import DraftNotice from './DraftNotice';
import { useContentRestored } from './contentRestored';
import { reportIfSessionExpired } from './sessionExpiry';
import { useVersionedSave } from './useVersionedSave';
import type { Settings, SectionProps } from './settings/types';
import type { EnvLocks } from '@/lib/admin/envLocks';
import { validateSettingValues } from '@/lib/config/settingValues';
import GeneralSection from './settings/GeneralSection';
import ThemeSection from './settings/ThemeSection';
import GridSection from './settings/GridSection';
@@ -65,6 +69,8 @@ export default function SettingsEditor() {
effective: string | null;
source: 'env' | 'settings' | 'none';
} | null>(null);
/** Fields an environment variable overrides, path to variable name (#605). */
const [envLocks, setEnvLocks] = useState<EnvLocks>({});
const [loading, setLoading] = useState(true);
/**
* Set when the settings could not be fetched. It blocks saving, because an
@@ -87,6 +93,11 @@ export default function SettingsEditor() {
// holds the password as typed. A restored draft takes the password from the
// server instead; a new one is typed again after a reload.
const settingsDraft = useDraft<Settings>('settings', withoutSitePassword(settings), dirty);
/** The form as last rendered: tells a finished save whether editing went on meanwhile. */
const latestSettings = useLatest(settings);
/** settings.yaml as loaded, sent back on save so a change elsewhere is caught (#601). */
const versionRef = useRef<string | null>(null);
const versionedSave = useVersionedSave();
useEffect(() => {
loadSettings();
@@ -149,14 +160,16 @@ export default function SettingsEditor() {
: `The server answered ${res.status}.`,
);
}
const { settings: data, siteUrl } = await res.json();
const { settings: data, siteUrl, envLocks: locks, version } = await res.json();
const loaded: Settings = data || {};
versionRef.current = typeof version === 'string' ? version : null;
setServerSettings(loaded);
const restoredDraft = settingsDraft.load(JSON.stringify(withoutSitePassword(loaded)));
const restored = restoredDraft && withSitePasswordOf(restoredDraft, loaded);
setSettings(restored ?? loaded);
setDirty(restored !== null);
setSiteUrlInfo(siteUrl ?? null);
setEnvLocks(locks ?? {});
} catch (err) {
console.error('Failed to load settings:', err);
setLoadError(err instanceof Error ? err.message : 'The settings could not be loaded.');
@@ -218,8 +231,9 @@ export default function SettingsEditor() {
setSaving(true);
setSaveStatus(null);
const sent = settings;
// Clean up empty objects
const cleaned = JSON.parse(JSON.stringify(settings));
const cleaned = JSON.parse(JSON.stringify(sent));
for (const key of Object.keys(cleaned)) {
if (typeof cleaned[key] === 'object' && Object.keys(cleaned[key]).length === 0) {
delete cleaned[key];
@@ -227,39 +241,56 @@ export default function SettingsEditor() {
}
try {
const res = await fetch('/api/admin/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings: cleaned }),
});
const result = await versionedSave(
'/api/admin/settings',
{ settings: cleaned },
versionRef,
'The settings file',
);
if (res.ok) {
const data = await res.json();
if (result.kind === 'reload') {
settingsDraft.discard();
await loadSettings();
} else if (result.kind === 'keep') {
setSaveStatus({ kind: 'error', message: 'Not saved — the settings changed elsewhere.' });
} else if (result.kind === 'saved') {
const data = result.data;
// The site password is stored hashed (#690); show that, not the typed text.
const stored: Settings =
typeof data.sitePassword === 'string'
? { ...cleaned, sitePassword: data.sitePassword }
: cleaned;
// Edited while the request was out: those edits are not saved yet, so
// they stay dirty, and a password retyped meanwhile is not replaced.
const editedMeanwhile = latestSettings.current !== sent;
if (typeof data.sitePassword === 'string') {
setSettings((s) => ({ ...s, sitePassword: data.sitePassword }));
const hashed = data.sitePassword;
setSettings((s) =>
s.sitePassword === sent.sitePassword ? { ...s, sitePassword: hashed } : s,
);
}
setServerSettings(stored);
settingsDraft.saved(JSON.stringify(withoutSitePassword(stored)));
setDirty(false);
setSaveStatus({ kind: 'success', message: data.message || 'Saved!' });
settingsDraft.saved(JSON.stringify(withoutSitePassword(stored)), editedMeanwhile);
if (!editedMeanwhile) setDirty(false);
setSaveStatus({
kind: 'success',
message: typeof data.message === 'string' ? data.message : 'Saved!',
});
router.refresh();
setTimeout(() => setSaveStatus(null), 5000);
} else if (!reportIfSessionExpired(res)) {
const err = await res.json();
} else if (!reportIfSessionExpired(result.res)) {
const err = result.data ?? {};
// A rejected save names the fields that caused it. Listing them beats
// "could not be saved" over a form with forty inputs; putting the
// message next to each input is the job of #600.
// "could not be saved" over a form with forty inputs; the value
// checks also stand next to their inputs (fieldErrors).
const fields: string[] = Array.isArray(err.fields)
? err.fields.map((f: { field?: string }) => f.field || 'settings')
? err.fields.map((f: { field?: string; message?: string }) =>
f.message ? `${f.field || 'settings'}: ${f.message}` : f.field || 'settings',
)
: [];
setSaveStatus({
kind: 'error',
message: `Error: ${err.error}${fields.length ? ` — ${fields.join(', ')}` : ''}`,
message: `Error: ${err.error ?? `HTTP ${result.res.status}`}${fields.length ? ` — ${fields.join(', ')}` : ''}`,
});
}
} catch {
@@ -311,7 +342,10 @@ export default function SettingsEditor() {
const saveBarStatus: SaveStatus =
[saveStatus, about.status].find((st) => st?.kind === 'error') ?? saveStatus ?? about.status;
const props: SectionProps = { settings, update, updateMany };
const fieldErrors = Object.fromEntries(
validateSettingValues(settings).map((e) => [e.field, e.message]),
);
const props: SectionProps = { settings, update, updateMany, envLocks, fieldErrors };
return (
<div className="settings-editor">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,12 +13,17 @@ import {
IconFolder,
IconX,
} from '../Icons';
import { isLegacyAssetRef } from './blockOps';
import { InlineMarkdownField } from '../InlineMarkdownField';
import { fillAssetSlots, isLegacyAssetRef } from './blockOps';
/** What the asset picker is opened for: its title, and where the pick goes. */
export interface AssetPickTarget {
title: string;
onSelect: (assetId: string) => void;
onSelect?: (assetId: string) => void;
/** Multi-select: several picks confirmed at once (#602). */
onSelectMany?: (assetIds: string[]) => void;
/** Most assets `onSelectMany` takes. */
max?: number;
}
interface BlockFieldsProps {
@@ -71,22 +76,26 @@ export function BlockFields({
)}
{block.type === 'paragraph' && (
<textarea
<InlineMarkdownField
multiline
aria-label="Text"
className="admin-input"
rows={3}
value={block.html}
onChange={(e) => onChange({ ...block, html: e.target.value })}
placeholder="Text (**bold**, *italic*, [link](https://…))"
html={block.html}
onChange={(html) => onChange({ ...block, html })}
/>
)}
{block.type === 'quote' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<textarea
<InlineMarkdownField
multiline
className="admin-input"
rows={2}
value={block.text}
html={block.text}
placeholder="Quote text..."
onChange={(e) => onChange({ ...block, text: e.target.value })}
onChange={(text) => onChange({ ...block, text })}
/>
<input
type="text"
@@ -176,12 +185,11 @@ export function BlockFields({
Change Photo
</button>
</div>
<input
type="text"
<InlineMarkdownField
className="admin-input"
placeholder="Caption (optional)"
value={block.caption || ''}
onChange={(e) => onChange({ ...block, caption: e.target.value })}
html={block.caption}
onChange={(caption) => onChange({ ...block, caption })}
/>
</div>
</div>
@@ -242,12 +250,31 @@ export function BlockFields({
</div>
))}
</div>
<input
type="text"
<div>
<button
type="button"
className="admin-btn admin-btn-xs"
onClick={() =>
onPickAsset({
title: 'Select Photos for Pair',
max: 2,
onSelectMany: (ids) => {
// Two picks replace the pair; a single pick fills a gap.
const base = ids.length >= 2 ? ['', ''] : [...block.assetIds];
const [a, b] = fillAssetSlots(base, ids, 2);
onChange({ ...block, assetIds: [a ?? '', b ?? ''] });
},
})
}
>
<IconCamera size={12} /> Pick both photos
</button>
</div>
<InlineMarkdownField
className="admin-input"
placeholder="Shared caption for pair (optional)"
value={block.caption || ''}
onChange={(e) => onChange({ ...block, caption: e.target.value })}
html={block.caption}
onChange={(caption) => onChange({ ...block, caption })}
/>
</div>
)}
@@ -316,16 +343,28 @@ export function BlockFields({
>
<IconPlus size={12} /> Add photo
</button>
<button
type="button"
className="admin-btn admin-btn-xs"
onClick={() =>
onPickAsset({
title: 'Select Photos for Grid',
onSelectMany: (ids) =>
onChange({ ...block, assetIds: fillAssetSlots(block.assetIds, ids) }),
})
}
>
<IconCamera size={12} /> Pick several
</button>
<span style={{ fontSize: '0.75rem', opacity: 0.6 }}>
Three or more photos, laid out in rows of three.
</span>
</div>
<input
type="text"
<InlineMarkdownField
className="admin-input"
placeholder="Shared caption for grid (optional)"
value={block.caption || ''}
onChange={(e) => onChange({ ...block, caption: e.target.value })}
html={block.caption}
onChange={(caption) => onChange({ ...block, caption })}
/>
</div>
)}
@@ -348,17 +387,14 @@ export function BlockFields({
})
}
/>
<input
type="text"
<InlineMarkdownField
className="admin-input"
placeholder="Value, e.g. 21 km"
value={item.value}
onChange={(e) =>
html={item.value}
onChange={(value) =>
onChange({
...block,
items: block.items.map((it, i) =>
i === fIdx ? { ...it, value: e.target.value } : it,
),
items: block.items.map((it, i) => (i === fIdx ? { ...it, value } : it)),
})
}
/>
@@ -467,17 +503,11 @@ export function BlockFields({
</select>
</label>
</div>
<input
type="text"
<InlineMarkdownField
className="admin-input"
placeholder="Caption for the set (optional)"
value={block.caption || ''}
onChange={(e) =>
onChange({
...block,
caption: e.target.value || undefined,
})
}
html={block.caption}
onChange={(caption) => onChange({ ...block, caption: caption || undefined })}
/>
<span style={{ fontSize: '0.75rem', opacity: 0.6 }}>
Photos follow the album&apos;s order (a manual order in the gallery comes first). Count
@@ -494,12 +524,11 @@ export function BlockFields({
enabled.
</p>
)}
<input
type="text"
<InlineMarkdownField
className="admin-input"
placeholder="Caption, e.g. Busan → Seoul (optional)"
value={block.caption || ''}
onChange={(e) => onChange({ ...block, caption: e.target.value })}
html={block.caption}
onChange={(caption) => onChange({ ...block, caption })}
/>
{block.items.map((item, mIdx) => {

View File

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

View File

@@ -3,7 +3,7 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { JournalEntrySummary } from '@/lib/journal';
import { serializeJournalMarkdown, sanitizeSlug } from '@/lib/journal';
import { serializeJournalMarkdown, sanitizeSlug, slugFieldValue } from '@/lib/journal';
import { JOURNAL_TEMPLATES } from '@/lib/journalTemplates';
import {
IconTrash,
@@ -22,6 +22,7 @@ import { useContentRestored } from '../contentRestored';
import AdminLoadState from '../AdminLoadState';
import PageHeader from '../PageHeader';
import { useConfirm } from '../ConfirmDialog';
import { useModalDialog } from '@/hooks/useModalDialog';
interface JournalStudioProps {
/** Entry to open, taken from the /admin/journal/[slug] route. */
@@ -50,6 +51,11 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
const [creating, setCreating] = useState(false);
/** null = start blank, matching the previous (only) behavior. */
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
const closeCreateModal = () => {
setShowCreateModal(false);
setSelectedTemplateId(null);
};
const createCardRef = useModalDialog(closeCreateModal, showCreateModal);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
@@ -254,8 +260,16 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
{/* Create Modal */}
{showCreateModal && (
<div className="journal-modal-overlay">
<div className="journal-modal-card">
<h3 style={{ margin: '0 0 1rem' }}>Create New Journal Entry</h3>
<div
className="journal-modal-card"
ref={createCardRef}
role="dialog"
aria-modal="true"
aria-labelledby="journal-new-dialog-title"
>
<h3 id="journal-new-dialog-title" style={{ margin: '0 0 1rem' }}>
Create New Journal Entry
</h3>
<form onSubmit={handleCreate}>
<div style={{ marginBottom: '1rem' }}>
<label
@@ -304,13 +318,13 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
className="admin-input"
placeholder="e.g. expedition-nordkap"
value={newSlug}
onChange={(e) => setNewSlug(sanitizeSlug(e.target.value))}
onChange={(e) => setNewSlug(slugFieldValue(e.target.value))}
required
/>
<span
style={{ fontSize: '0.75rem', opacity: 0.6, marginTop: '2px', display: 'block' }}
>
Will be accessible at /journal/{newSlug || 'slug'}
Will be accessible at /journal/{newSlug ? sanitizeSlug(newSlug) : 'slug'}
</span>
</div>
@@ -352,10 +366,7 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
<button
type="button"
className="admin-btn admin-btn-secondary"
onClick={() => {
setShowCreateModal(false);
setSelectedTemplateId(null);
}}
onClick={closeCreateModal}
>
Cancel
</button>

View File

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

View File

@@ -3,6 +3,7 @@
import type { ParsedJournal } from '@/lib/journal';
import type { AssetPickTarget } from './BlockFields';
import PasswordField from '../fields/PasswordField';
import { useModalDialog } from '@/hooks/useModalDialog';
type Frontmatter = ParsedJournal['frontmatter'];
@@ -20,10 +21,20 @@ export function StorySettingsModal({
onPickAsset,
onClose,
}: StorySettingsModalProps) {
const cardRef = useModalDialog(onClose);
return (
<div className="journal-modal-overlay">
<div className="journal-modal-card">
<h3 style={{ margin: '0 0 1.25rem' }}>Story Settings &amp; Metadata</h3>
<div
className="journal-modal-card"
ref={cardRef}
role="dialog"
aria-modal="true"
aria-labelledby="story-settings-title"
>
<h3 id="story-settings-title" style={{ margin: '0 0 1.25rem' }}>
Story Settings &amp; Metadata
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div>
@@ -153,6 +164,7 @@ export function StorySettingsModal({
<div>
<label
htmlFor="story-password"
style={{
display: 'block',
fontSize: '0.8rem',
@@ -162,11 +174,15 @@ export function StorySettingsModal({
>
Password Protection (Optional)
</label>
<PasswordField
value={frontmatter.password || undefined}
onChange={(password) => onChange({ password: password ?? '' })}
label="Entry password"
/>
{/* The input takes its styles from .admin-field, as everywhere else
PasswordField is used; without it the lock icon sat on the text. */}
<div className="admin-field" style={{ marginBottom: 0 }}>
<PasswordField
id="story-password"
value={frontmatter.password || undefined}
onChange={(password) => onChange({ password: password ?? '' })}
/>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', paddingTop: '4px' }}>

View File

@@ -17,6 +17,26 @@ export function isLegacyAssetRef(assetId: string): boolean {
return assetId.length > 0 && !ASSET_UUID.test(assetId);
}
/**
* Slots after a multi-pick (#602): picked photos fill the empty slots first,
* in pick order, and the rest are appended. With `capacity` (a pair holds two)
* the list never grows past it; overflow picks are dropped.
*/
export function fillAssetSlots(slots: string[], picked: string[], capacity?: number): string[] {
const next = [...slots];
const queue = [...picked];
for (let i = 0; i < next.length && queue.length > 0; i++) {
if (!next[i]) next[i] = queue.shift()!;
}
next.push(...queue);
return capacity === undefined ? next : next.slice(0, capacity);
}
/** One photo block per picked asset, for "+ Photos" (#602). */
export function createPhotoBlocks(assetIds: string[]): JournalBlock[] {
return assetIds.map((assetId) => ({ type: 'photo', assetId, caption: '', layout: 'contained' }));
}
/** The block the "Add Block" toolbar inserts for each type. */
export function createBlock(type: JournalBlock['type']): JournalBlock {
switch (type) {

View File

@@ -104,7 +104,9 @@
.journal-list-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
/* min(): on a phone the column is narrower than 320px, and a fixed minimum
pushed every card past the right edge. */
grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr));
gap: 1.5rem;
padding: 2rem;
}
@@ -680,6 +682,12 @@
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) {
/* The workspace already keeps a gutter; a second 2rem one left the cards
under 300px wide. */
.journal-list-grid {
padding: 1.25rem 0;
}
.journal-editor-topbar {
flex-wrap: wrap;
padding: 0.75rem 1rem;
@@ -710,3 +718,43 @@
justify-content: center;
}
}
/* ── Content page settings: a side panel, not a modal (#722) ── */
.page-settings-panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: min(360px, 100vw);
z-index: 100;
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.25rem;
overflow-y: auto;
background: var(--admin-bg-raised);
border-left: 1px solid var(--admin-border);
box-shadow: -12px 0 32px rgba(0, 0, 0, 0.25);
}
.page-settings-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.page-settings-panel-head h3 {
margin: 0;
font-size: 1rem;
}
.page-settings-panel textarea {
width: 100%;
resize: vertical;
}
.page-settings-panel-note {
margin: 0;
font-size: 0.75rem;
color: var(--admin-text-muted);
}

View File

@@ -6,6 +6,7 @@ import { Listbox } from '../Listbox';
import { SORT_OPTIONS } from './sortOptions';
import type { AlbumEntry, HeroPickerTarget, OrderEditorTarget } from './types';
import PasswordField from '../fields/PasswordField';
import { useModalDialog } from '@/hooks/useModalDialog';
interface AlbumDrawerProps {
album: AlbumEntry;
@@ -36,15 +37,29 @@ export default function AlbumDrawer({
onEditOrder,
}: AlbumDrawerProps) {
const heroThumb = album.heroImage || thumbnailId;
const cardRef = useModalDialog(onClose);
return (
<div className="album-drawer-overlay open" onClick={() => onClose()}>
<div className="album-drawer" onClick={(e) => e.stopPropagation()}>
<div
className="album-drawer"
ref={cardRef}
role="dialog"
aria-modal="true"
aria-labelledby="album-drawer-title"
onClick={(e) => e.stopPropagation()}
>
<div className="album-drawer-header">
<h3>
<h3 id="album-drawer-title">
<IconCamera /> Edit Album Details
</h3>
<button className="admin-btn-icon" onClick={() => onClose()} title="Close details">
<button
type="button"
className="admin-btn-icon"
onClick={() => onClose()}
title="Close details"
aria-label="Close details"
>
<IconX size={14} />
</button>
</div>

View File

@@ -0,0 +1,134 @@
'use client';
import { useState } from 'react';
import { sanitizeSlug, slugFieldValue } from '@/lib/journal';
import { describeCollision, pageSlugCollision, type SlugTakenBy } from '@/lib/pages';
import { useModalDialog } from '@/hooks/useModalDialog';
interface NewPageDialogProps {
taken: SlugTakenBy;
existingPageSlugs: string[];
creating: boolean;
onCreate: (input: { title: string; slug: string; showInMenu: boolean }) => void;
onClose: () => void;
}
/**
* "+ New page" (#722): a title, the slug derived from it (editable), and
* whether the page goes into the menu. v1 starts every page blank.
*/
export default function NewPageDialog({
taken,
existingPageSlugs,
creating,
onCreate,
onClose,
}: NewPageDialogProps) {
const cardRef = useModalDialog(onClose);
const [title, setTitle] = useState('');
const [slug, setSlug] = useState('');
const [slugEdited, setSlugEdited] = useState(false);
const [showInMenu, setShowInMenu] = useState(true);
/** What the field shows: the typed slug, or the one derived from the title. */
const fieldSlug = slug || (title.trim() ? sanitizeSlug(title) : '');
/** What gets created: the typed slug finished, e.g. without a trailing "-". */
const effectiveSlug = slug ? sanitizeSlug(slug) : fieldSlug;
const collision = !effectiveSlug
? null
: existingPageSlugs.includes(effectiveSlug)
? `A page "${effectiveSlug}" already exists.`
: (() => {
const c = pageSlugCollision(effectiveSlug, taken);
return c ? describeCollision(effectiveSlug, c) : null;
})();
const submit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim() || !effectiveSlug || collision || creating) return;
onCreate({ title: title.trim(), slug: effectiveSlug, showInMenu });
};
return (
<div className="confirm-backdrop" onClick={onClose}>
<div
ref={cardRef}
className="confirm-card new-page-card"
role="dialog"
aria-modal="true"
aria-labelledby="new-page-title"
onClick={(e) => e.stopPropagation()}
>
<h2 id="new-page-title" className="confirm-title">
New page
</h2>
<form onSubmit={submit} className="new-page-form">
<div className="admin-field">
<label htmlFor="new-page-name">Title</label>
<input
id="new-page-name"
value={title}
placeholder="e.g. Pricing"
autoFocus
onChange={(e) => {
setTitle(e.target.value);
if (!slugEdited) setSlug('');
}}
/>
</div>
<div className="admin-field">
<label htmlFor="new-page-slug">URL</label>
<div className="input-slug-wrapper">
<input
id="new-page-slug"
className="subpage-name-input"
value={fieldSlug}
placeholder="pricing"
aria-describedby="new-page-slug-hint"
onChange={(e) => {
setSlugEdited(e.target.value !== '');
setSlug(slugFieldValue(e.target.value));
}}
/>
<span className="input-slug-preview">/{effectiveSlug || 'slug'}</span>
</div>
<span
id="new-page-slug-hint"
className={`admin-field-hint${collision ? ' admin-field-hint--error' : ''}`}
>
{collision ?? 'Starts blank and as a draft. You can change the URL later.'}
</span>
</div>
<button
type="button"
className="setting-row"
onClick={() => setShowInMenu(!showInMenu)}
aria-pressed={showInMenu}
>
<span className="setting-row-info">
<span className="setting-row-title">Show in menu</span>
<span className="setting-row-desc">
Adds it to the end of the menu; saved with “Save Changes”.
</span>
</span>
<span className={`switch-toggle switch-toggle--sm ${showInMenu ? 'on' : ''}`}>
<span className="switch-slider" />
</span>
</button>
<div className="confirm-actions">
<button type="button" className="admin-btn admin-btn-ghost" onClick={onClose}>
Cancel
</button>
<button
type="submit"
className="admin-btn admin-btn-primary"
disabled={!title.trim() || !effectiveSlug || !!collision || creating}
>
{creating ? 'Creating…' : 'Create page'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
'use client';
import { useRouter } from 'next/navigation';
import { JournalEditor } from '../journal/JournalEditor';
/**
* The journal's block editor and live preview in page mode (#722). Back goes
* to Pages with the page selected again.
*/
export function PageContentEditor({ slug }: { slug: string }) {
const router = useRouter();
return (
<JournalEditor
slug={slug}
kind="page"
onBack={() => router.push(`/admin/pages?page=${encodeURIComponent(slug)}`)}
/>
);
}

View File

@@ -0,0 +1,394 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import {
parseJournalMarkdown,
sanitizeSlug,
serializeJournalMarkdown,
slugFieldValue,
type ParsedJournal,
} from '@/lib/journal';
import {
describeCollision,
pageSlugCollision,
type PageSummary,
type SlugTakenBy,
} from '@/lib/pages';
import PasswordField from '../fields/PasswordField';
import { IconFileText, IconTrash } from '../Icons';
import { useConfirm } from '../ConfirmDialog';
import { useNotify } from '../Notifications';
import { reportIfSessionExpired } from '../sessionExpiry';
import { useVersionedSave } from '../useVersionedSave';
import type { GalleryVersionChange } from '@/lib/admin/pageRefs';
interface PagePanelProps {
slug: string;
/** Whether gallery.yaml (as edited in the builder) lists the page. */
inMenu: boolean;
onToggleMenu: () => void;
/** Slugs a page may not take. */
taken: SlugTakenBy;
/** The other pages' slugs, which a rename must not take either. */
otherPageSlugs: string[];
/**
* `galleryVersion` is set when the server rewrote gallery.yaml's menu, so
* the builder can follow that change without mistaking it for a foreign
* edit (#601).
*/
onSaved: (
oldSlug: string,
page: PageSummary,
menuRenamed: boolean,
galleryVersion?: GalleryVersionChange | null,
) => void;
onDeleted: (
slug: string,
removedFromMenu: boolean,
galleryVersion?: GalleryVersionChange | null,
) => void;
}
/**
* A content page selected in the structure list (#722): its settings, saved
* to content/pages/<slug>.md with the panel's own button. "Show in menu" is
* the one exception — it edits gallery.yaml, so it goes with the builder's
* Save like every other change to the menu.
*/
export default function PagePanel({
slug,
inMenu,
onToggleMenu,
taken,
otherPageSlugs,
onSaved,
onDeleted,
}: PagePanelProps) {
const confirm = useConfirm();
const notify = useNotify();
const [parsed, setParsed] = useState<ParsedJournal | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [title, setTitle] = useState('');
const [slugInput, setSlugInput] = useState(slug);
const [draft, setDraft] = useState(false);
const [password, setPassword] = useState<string | undefined>(undefined);
const [description, setDescription] = useState('');
const [saving, setSaving] = useState(false);
/** The page file as loaded, sent back on save (#601). */
const versionRef = useRef<string | null>(null);
const versionedSave = useVersionedSave();
/** Bumped to load the file again — "Reload" in the conflict prompt. */
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
let cancelled = false;
setParsed(null);
setLoadError(null);
fetch(`/api/admin/pages/${encodeURIComponent(slug)}`)
.then(async (res) => {
if (!res.ok) {
reportIfSessionExpired(res);
throw new Error(
res.status === 404
? 'There is no file for this page in content/pages/.'
: `The server answered ${res.status}.`,
);
}
return res.json();
})
.then((data: { page: { rawMarkdown: string }; version?: string }) => {
if (cancelled) return;
versionRef.current = data.version ?? null;
const p = parseJournalMarkdown(data.page.rawMarkdown);
setParsed(p);
setTitle(p.frontmatter.title || '');
setSlugInput(slug);
setDraft(!!p.frontmatter.draft);
setPassword(p.frontmatter.password || undefined);
setDescription(p.frontmatter.description || '');
})
.catch((err: unknown) => {
if (!cancelled) setLoadError(err instanceof Error ? err.message : 'Could not load.');
});
return () => {
cancelled = true;
};
}, [slug, reloadKey]);
// The field keeps what is typed, a trailing "-" included; the page moves to
// the finished slug.
const targetSlug = slugInput ? sanitizeSlug(slugInput) : '';
const fm = parsed?.frontmatter;
const dirty =
!!fm &&
(title !== (fm.title || '') ||
targetSlug !== slug ||
draft !== !!fm.draft ||
(password || '') !== (fm.password || '') ||
description !== (fm.description || ''));
const renaming = targetSlug !== slug;
const collision = renaming
? otherPageSlugs.includes(targetSlug)
? `A page "${targetSlug}" already exists.`
: (() => {
const c = pageSlugCollision(targetSlug, taken);
return c ? describeCollision(targetSlug, c) : null;
})()
: null;
async function save() {
if (!parsed || !dirty || collision || !title.trim()) return;
if (renaming) {
const ok = await confirm({
title: `Move this page to /${targetSlug}?`,
message: `Links to /${slug} will stop working. There is no automatic redirect.`,
confirmLabel: 'Move page',
});
if (!ok) return;
}
setSaving(true);
try {
const markdown = serializeJournalMarkdown({
...parsed,
frontmatter: {
...parsed.frontmatter,
title: title.trim(),
draft: draft || undefined,
password: password || undefined,
description: description.trim() || undefined,
},
});
const result = await versionedSave(
`/api/admin/pages/${encodeURIComponent(slug)}`,
{ rawMarkdown: markdown, ...(renaming ? { newSlug: targetSlug } : {}) },
versionRef,
'This page',
);
if (result.kind === 'reload') {
setReloadKey((k) => k + 1);
return;
}
if (result.kind === 'keep') {
notify('error', 'Not saved — the page changed elsewhere. Your edits are still here.');
return;
}
if (result.kind === 'failed') {
if (!reportIfSessionExpired(result.res)) {
notify('error', result.data?.error || 'Failed to save the page');
}
return;
}
const data = result.data as {
page: { slug: string; rawMarkdown: string };
menuRenamed?: boolean;
galleryVersion?: GalleryVersionChange | null;
/** The page was saved, but its menu entry in gallery.yaml was not updated. */
warning?: string;
};
const written = parseJournalMarkdown(data.page.rawMarkdown);
setParsed(written);
setPassword(written.frontmatter.password || undefined);
const { title: t, description: d, password: pw, draft: dr } = written.frontmatter;
onSaved(
slug,
{
slug: data.page.slug,
frontmatter: { title: t, description: d, password: pw, draft: dr },
},
data.menuRenamed === true,
data.galleryVersion,
);
if (data.warning) notify('error', data.warning);
else notify('success', 'Page saved.');
} catch {
notify('error', 'Could not save the page. Check the connection and try again.');
} finally {
setSaving(false);
}
}
async function remove() {
const ok = await confirm({
title: `Delete “${title || slug}”?`,
message: inMenu
? 'The page is also removed from the menu in gallery.yaml. Both files are backed up first and can be restored under Backups.'
: 'A copy is kept and can be restored under Backups.',
confirmLabel: 'Delete page',
danger: true,
});
if (!ok) return;
try {
const res = await fetch(`/api/admin/pages/${encodeURIComponent(slug)}`, {
method: 'DELETE',
});
const data = await res.json().catch(() => null);
if (!res.ok) {
if (!reportIfSessionExpired(res))
notify('error', data?.error || 'Failed to delete the page');
return;
}
onDeleted(slug, data?.removedFromMenu === true, data?.galleryVersion);
if (typeof data?.warning === 'string') notify('error', data.warning);
else notify('success', 'Page deleted.');
} catch {
notify('error', 'Could not delete the page. Check the connection and try again.');
}
}
if (loadError) {
return (
<section className="builder-section">
<div className="builder-section-header">
<h2>
<IconFileText /> /{slug}
</h2>
</div>
<div className="admin-error" role="alert">
<strong>This page could not be loaded.</strong> {loadError}
{inMenu && (
<p>
The menu still lists it. Switch off “Show in menu” and save the structure to remove
the entry.
</p>
)}
</div>
{inMenu && (
<button type="button" className="admin-btn admin-btn-secondary" onClick={onToggleMenu}>
Remove from menu
</button>
)}
</section>
);
}
if (!parsed) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
return (
<section className="builder-section page-panel">
<div className="builder-section-header">
<h2>
<IconFileText /> {fm?.title || slug}
</h2>
<a
href={`/${encodeURIComponent(slug)}`}
target="_blank"
rel="noopener noreferrer"
className="admin-btn admin-btn-xs admin-btn-ghost"
title="Open the live page in a new tab"
>
/{slug} ↗
</a>
</div>
<div className="admin-field">
<label htmlFor="page-title">Title</label>
<input id="page-title" value={title} onChange={(e) => setTitle(e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor="page-slug">URL</label>
<div className="input-slug-wrapper">
<input
id="page-slug"
className="subpage-name-input"
value={slugInput}
onChange={(e) => setSlugInput(slugFieldValue(e.target.value))}
aria-describedby="page-slug-hint"
/>
<span className="input-slug-preview">/{targetSlug}</span>
</div>
<span
id="page-slug-hint"
className={`admin-field-hint${collision ? ' admin-field-hint--error' : renaming ? ' admin-field-hint--warn' : ''}`}
>
{collision
? collision
: renaming
? `Links to /${slug} will stop working. There is no automatic redirect.`
: 'Checked against subpages, albums, journal entries and built-in routes.'}
</span>
</div>
<div className="page-panel-switches">
<button type="button" className="setting-row" onClick={onToggleMenu} aria-pressed={inMenu}>
<span className="setting-row-info">
<span className="setting-row-title">Show in menu</span>
<span className="setting-row-desc">
{inMenu
? 'Listed in the header menu. Drag it in the structure list to move it.'
: 'Not in the menu, but reachable at its URL — for pages shared by link.'}{' '}
Saved with “Save Changes”.
</span>
</span>
<span className={`switch-toggle switch-toggle--sm ${inMenu ? 'on' : ''}`}>
<span className="switch-slider" />
</span>
</button>
<button
type="button"
className="setting-row"
onClick={() => setDraft(!draft)}
aria-pressed={draft}
>
<span className="setting-row-info">
<span className="setting-row-title">Draft</span>
<span className="setting-row-desc">
Only you can see a draft. It stays out of the menu and the sitemap.
</span>
</span>
<span className={`switch-toggle switch-toggle--sm ${draft ? 'on' : ''}`}>
<span className="switch-slider" />
</span>
</button>
</div>
<div className="admin-field">
<span className="admin-field-label">Password (optional)</span>
<PasswordField value={password} onChange={setPassword} label="Page password" />
</div>
<div className="admin-field">
<label htmlFor="page-description">SEO description</label>
<textarea
id="page-description"
rows={2}
value={description}
placeholder="One or two sentences for search engines and link previews"
onChange={(e) => setDescription(e.target.value.replace(/\s*\n\s*/g, ' '))}
/>
</div>
<div className="page-panel-actions">
<Link href={`/admin/pages/edit/${slug}`} className="admin-btn admin-btn-secondary">
Edit content →
</Link>
<button
type="button"
className="admin-btn admin-btn-primary"
onClick={save}
disabled={!dirty || saving || !!collision || !title.trim()}
>
{saving ? 'Saving…' : 'Save page'}
</button>
<button
type="button"
className="admin-btn admin-btn-danger"
onClick={remove}
style={{ marginLeft: 'auto' }}
>
<IconTrash size={14} /> Delete
</button>
</div>
</section>
);
}

View File

@@ -6,8 +6,10 @@
*/
import { useSortable } from '@dnd-kit/sortable';
import { useDroppable } from '@dnd-kit/core';
import { CSS } from '@dnd-kit/utilities';
import { IconFolder, IconGripVertical, IconLock, IconPencil, IconX } from '../Icons';
import type { ReactNode } from 'react';
import { IconFileText, IconFolder, IconGripVertical, IconLock, IconPencil, IconX } from '../Icons';
import type { Subpage } from './types';
// ── Sortable Hero Tile ─────────────────────────────────────────
@@ -211,3 +213,79 @@ export function SortableSubpageRow({
</div>
);
}
// ── Sortable Page Row (#722) ───────────────────────────────────
/**
* A content page in the structure list: document icon and a PAGE tag, so it
* reads as something other than a subpage. The same row sits in the menu
* list and in "Not in menu"; `id` says which, for the drag handler.
*/
export function SortablePageRow({
id,
title,
isActive,
draft,
hasPassword,
missing,
onClick,
}: {
id: string;
title: string;
isActive: boolean;
draft?: boolean;
hasPassword?: boolean;
/** Referenced from gallery.yaml, but there is no file for it. */
missing?: boolean;
onClick: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
});
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' : ''} ${draft || missing ? '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">
<IconFileText size={14} />
</span>
<span className="pb-row-name">
{title}
{hasPassword && <IconLock size={11} />}
</span>
{missing ? (
<span className="pb-row-state">Missing</span>
) : draft ? (
<span className="pb-row-state">Draft</span>
) : (
<span className="pb-row-tag">Page</span>
)}
</button>
</div>
);
}
/**
* The "Not in menu" group as a drop target, so a page dragged out of the
* menu lands there even when the group is empty (#722).
*/
export function OffMenuDropZone({ id, children }: { id: string; children: ReactNode }) {
const { setNodeRef, isOver } = useDroppable({ id });
return (
<div ref={setNodeRef} className={`pb-offmenu ${isOver ? 'is-over' : ''}`}>
{children}
</div>
);
}

View File

@@ -90,6 +90,15 @@ export const addHero = (g: GalleryState, assetId: string): GalleryState => ({
hero: [...g.hero, assetId],
});
/** Append several hero photos in pick order, skipping any already listed. */
export const addHeroes = (g: GalleryState, assetIds: string[]): GalleryState => ({
...g,
hero: [
...g.hero,
...assetIds.filter((id, i) => !g.hero.includes(id) && assetIds.indexOf(id) === i),
],
});
export const removeHero = (g: GalleryState, index: number): GalleryState => ({
...g,
hero: g.hero.filter((_, i) => i !== index),
@@ -113,6 +122,14 @@ export const addSubpage = (g: GalleryState): GalleryState => ({
export const removeSubpage = (g: GalleryState, index: number): GalleryState => ({
...g,
subpages: g.subpages.filter((_, i) => i !== index),
// A page after the removed subpage moves up with the rest of the menu.
...(g.pageRefs
? {
pageRefs: g.pageRefs.map((r) =>
r.position > index ? { ...r, position: r.position - 1 } : r,
),
}
: {}),
});
export const updateSubpage = (g: GalleryState, index: number, updates: Partial<Subpage>) =>
@@ -142,6 +159,98 @@ export const updateSection = (
updates: Partial<Section>,
) => mapSection(g, subpageIndex, sectionIndex, (sec) => ({ ...sec, ...updates }));
// ── Menu: subpages and content pages together (#722) ────────────
/** One row of the menu list: a subpage by its index, or a page by its slug. */
export type MenuItem = { kind: 'subpage'; index: number } | { kind: 'page'; slug: string };
/** The menu in the order it is saved and shown: subpages with pages interleaved. */
export function menuItems(g: GalleryState): MenuItem[] {
const refs = g.pageRefs ?? [];
const out: MenuItem[] = [];
for (let i = 0; i <= g.subpages.length; i++) {
for (const ref of refs) {
if (Math.min(ref.position, g.subpages.length) === i)
out.push({ kind: 'page', slug: ref.slug });
}
if (i < g.subpages.length) out.push({ kind: 'subpage', index: i });
}
return out;
}
/** Rebuild the state from a reordered menu list. */
function fromMenu(g: GalleryState, items: MenuItem[]): GalleryState {
const subpages: GalleryState['subpages'] = [];
const pageRefs: NonNullable<GalleryState['pageRefs']> = [];
for (const item of items) {
if (item.kind === 'subpage') subpages.push(g.subpages[item.index]);
else pageRefs.push({ slug: item.slug, position: subpages.length });
}
return { ...g, subpages, pageRefs };
}
/**
* Move a menu row from one position to another. Subpage indexes change with
* it; `followMenuMove` tells the builder where an open subpage went.
*/
export function moveMenuItem(g: GalleryState, from: number, to: number): GalleryState {
const items = menuItems(g);
if (!items[from] || !items[to]) return g;
return fromMenu(g, arrayMove(items, from, to));
}
/** The index an open subpage has after moveMenuItem(from, to). */
export function followMenuMove(
g: GalleryState,
open: number | null,
from: number,
to: number,
): number | null {
if (open === null) return null;
const moved = arrayMove(menuItems(g), from, to);
let index = 0;
for (const item of moved) {
if (item.kind !== 'subpage') continue;
if (item.index === open) return index;
index++;
}
return open;
}
export function isPageInMenu(g: GalleryState, slug: string): boolean {
return (g.pageRefs ?? []).some((r) => r.slug === slug);
}
/**
* Put a page in the menu or take it out. `at` is a menu row index to insert
* before; without one the page goes to the end of the menu.
*/
export function setPageInMenu(
g: GalleryState,
slug: string,
inMenu: boolean,
at?: number,
): GalleryState {
const without = (g.pageRefs ?? []).filter((r) => r.slug !== slug);
if (!inMenu) {
return isPageInMenu(g, slug) ? { ...g, pageRefs: without } : g;
}
if (isPageInMenu(g, slug)) return g;
const items = menuItems(g);
const index = at === undefined ? items.length : Math.max(0, Math.min(at, items.length));
items.splice(index, 0, { kind: 'page', slug });
return fromMenu(g, items);
}
/** Follow a page rename, keeping its place in the menu. */
export function renamePageRef(g: GalleryState, from: string, to: string): GalleryState {
if (!isPageInMenu(g, from)) return g;
return {
...g,
pageRefs: (g.pageRefs ?? []).map((r) => (r.slug === from ? { ...r, slug: to } : r)),
};
}
// ── Reading ─────────────────────────────────────────────────────
/** The "03 — Collection" number the page renders: counted over enabled subpages only. */

View File

@@ -14,6 +14,22 @@ export interface GalleryState {
hero: string[];
albums: AlbumEntry[];
subpages: Subpage[];
/**
* Content pages in the menu (#722): `- page: <slug>` among the subpages.
* Optional so a draft saved before pages existed still loads.
*/
pageRefs?: PageRef[];
}
/**
* A content page placed in the menu. `position` is the number of subpages in
* front of it, so the subpage list keeps its own indexes — every drawer and
* album address in the builder is one — and the menu order is rebuilt from
* both lists when saving. Refs sharing a position keep their array order.
*/
export interface PageRef {
slug: string;
position: number;
}
/**
@@ -27,8 +43,18 @@ export function parseGalleryYaml(raw: Record<string, unknown>): GalleryState {
);
let subpages: Subpage[] = [];
const pageRefs: PageRef[] = [];
if (Array.isArray(raw.subpages)) {
subpages = (raw.subpages as Array<Record<string, unknown>>).map((sp) => ({
const entries = raw.subpages as Array<Record<string, unknown>>;
const subpageEntries: Array<Record<string, unknown>> = [];
for (const entry of entries) {
if (entry && typeof entry.page === 'string') {
pageRefs.push({ slug: entry.page, position: subpageEntries.length });
} else {
subpageEntries.push(entry);
}
}
subpages = subpageEntries.map((sp) => ({
name: (sp.name as string) || '',
title: sp.title as string | undefined,
subtitle: sp.subtitle as string | undefined,
@@ -77,7 +103,7 @@ export function parseGalleryYaml(raw: Record<string, unknown>): GalleryState {
});
}
return { hero, albums, subpages };
return { hero, albums, subpages, pageRefs };
}
/** The gallery.yaml object for a builder state. Empty groups are left out. */
@@ -90,8 +116,9 @@ export function serializeGallery(gallery: GalleryState): Record<string, unknown>
if (gallery.albums.length > 0) {
yamlData.albums = serializeAlbumEntries(gallery.albums);
}
if (gallery.subpages.length > 0) {
yamlData.subpages = gallery.subpages.map((sp) => {
const pageRefs = gallery.pageRefs ?? [];
if (gallery.subpages.length > 0 || pageRefs.length > 0) {
const subpageEntries = 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;
@@ -124,7 +151,25 @@ export function serializeGallery(gallery: GalleryState): Record<string, unknown>
return entry;
});
yamlData.subpages = interleave(subpageEntries, pageRefs);
}
return yamlData;
}
/** Subpage entries with `{ page }` entries placed at their positions. */
function interleave(
subpageEntries: Record<string, unknown>[],
pageRefs: PageRef[],
): Record<string, unknown>[] {
const out: Record<string, unknown>[] = [];
for (let i = 0; i <= subpageEntries.length; i++) {
// A ref beyond the end (a subpage was removed) lands at the end.
for (const ref of pageRefs) {
const at = Math.min(ref.position, subpageEntries.length);
if (at === i) out.push({ page: ref.slug });
}
if (i < subpageEntries.length) out.push(subpageEntries[i]);
}
return out;
}

View File

@@ -111,7 +111,10 @@ export interface PickerTarget {
export interface HeroPickerTarget {
albumId?: string;
onSelect: (assetId: string) => void;
/** Single pick; the picker closes after it. */
onSelect?: (assetId: string) => void;
/** Multi-select: several picks confirmed at once (#602). */
onSelectMany?: (assetIds: string[]) => void;
currentAssetIds?: string[];
title?: string;
}

View File

@@ -1,10 +1,12 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import * as Icons from '../Icons';
import type { SaveStatus } from '../SaveBar';
import { useDraft, readDraft } from '../useDraft';
import { useContentRestored } from '../contentRestored';
import { useVersionedSave } from '../useVersionedSave';
import { useLatest } from '../useLatest';
interface AboutMeta {
portrait?: string;
@@ -54,6 +56,8 @@ export function useAboutEditor(active: boolean) {
const [aboutStatus, setAboutStatus] = useState<SaveStatus>(null);
const [aboutGearText, setAboutGearText] = useState('');
const [serverAbout, setServerAbout] = useState<AboutDraft | null>(null);
/** The fields as last rendered: tell a finished save whether editing went on meanwhile. */
const latest = useLatest({ meta: aboutMeta, body: aboutBody, gearText: aboutGearText });
// Unsaved About edits survive a tab switch or Reload, like settings (#592).
const draft = useDraft<AboutDraft>(
@@ -62,6 +66,9 @@ export function useAboutEditor(active: boolean) {
aboutDirty,
);
const loadDraft = draft.load;
/** about.md as loaded, sent back on save so a change elsewhere is caught (#601). */
const versionRef = useRef<string | null>(null);
const versionedSave = useVersionedSave();
const applyAbout = (value: AboutDraft) => {
setAboutMeta(value.meta);
@@ -82,6 +89,7 @@ export function useAboutEditor(active: boolean) {
);
}
const data = await res.json();
versionRef.current = typeof data.version === 'string' ? data.version : null;
const loaded: AboutDraft = {
meta: data.meta || {},
body: data.body || '',
@@ -128,22 +136,37 @@ export function useAboutEditor(active: boolean) {
}
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 result = await versionedSave(
'/api/admin/about',
{ meta: cleanedMeta, body: aboutBody },
versionRef,
'The About page',
);
if (result.kind === 'reload') {
draft.discard();
await loadAboutContent();
} else if (result.kind === 'keep') {
setAboutStatus({ kind: 'error', message: 'Not saved — the About page changed elsewhere.' });
} else if (result.kind === 'saved') {
const data = result.data;
const saved: AboutDraft = { meta: cleanedMeta, body: aboutBody, gearText: aboutGearText };
// Edited while the request was out: those edits are not saved yet.
const now = latest.current;
const editedMeanwhile =
now.meta !== aboutMeta || now.body !== aboutBody || now.gearText !== aboutGearText;
setServerAbout(saved);
draft.saved(aboutFingerprint(saved));
setAboutDirty(false);
setAboutStatus({ kind: 'success', message: data.message || 'Saved!' });
draft.saved(aboutFingerprint(saved), editedMeanwhile);
if (!editedMeanwhile) setAboutDirty(false);
setAboutStatus({
kind: 'success',
message: typeof data.message === 'string' ? data.message : 'Saved!',
});
setTimeout(() => setAboutStatus(null), 4000);
} else {
const err = await res.json();
setAboutStatus({ kind: 'error', message: `Error: ${err.error}` });
setAboutStatus({
kind: 'error',
message: `Error: ${result.data?.error ?? `HTTP ${result.res.status}`}`,
});
}
} catch {
setAboutStatus({ kind: 'error', message: 'Error: Failed to save' });

View File

@@ -137,6 +137,23 @@ export default function GeneralSection({ settings, update, updateMany }: Section
onToggle={() => update('proofing.enabled', settings.proofing?.enabled === false)}
/>
</div>
{/* Shown even while proofing is off globally: a subpage can switch it
on for itself (`proofing: true`), so the address still has to be
settable from here. */}
<div className="admin-field">
<label htmlFor="proofing-email">Proofing email</label>
<input
id="proofing-email"
type="email"
value={settings.proofing?.email || ''}
onChange={(e) => update('proofing.email', e.target.value)}
placeholder="Defaults to the footer contact email"
/>
<p className="admin-field-hint">
Where &ldquo;Email to photographer&rdquo; sends a selection. Falls back to the footer
contact email; when neither is set, the button is hidden.
</p>
</div>
</FeatureGroup>
<FeatureGroup

View File

@@ -7,6 +7,7 @@ import {
PHOTO_GRID_COLUMNS_MIN,
PHOTO_GRID_GAP_MAX,
} from '@/lib/config/schema';
import { FieldError } from './fields';
import type { SectionProps } from './types';
const LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow', 'justified'];
@@ -131,7 +132,7 @@ function AspectRatioPreview({ value }: { value: string }) {
);
}
export default function GridSection({ settings, update }: SectionProps) {
export default function GridSection({ settings, update, fieldErrors }: SectionProps) {
return (
<div className="settings-panel">
<div className="settings-section-header">
@@ -185,7 +186,10 @@ export default function GridSection({ settings, update }: SectionProps) {
max={PHOTO_GRID_COLUMNS_MAX}
value={settings.grid?.columns ?? 3}
onChange={(e) => update('grid.columns', parseInt(e.target.value) || 3)}
aria-invalid={fieldErrors?.['grid.columns'] ? true : undefined}
aria-describedby={fieldErrors?.['grid.columns'] ? 'grid-columns-error' : undefined}
/>
<FieldError id="grid-columns-error" message={fieldErrors?.['grid.columns']} />
</div>
<div className="admin-field">
<label htmlFor="grid-gap-spacing">Gap Spacing (px)</label>
@@ -196,7 +200,10 @@ export default function GridSection({ settings, update }: SectionProps) {
max={PHOTO_GRID_GAP_MAX}
value={settings.grid?.gap ?? 12}
onChange={(e) => update('grid.gap', parseInt(e.target.value) || 0)}
aria-invalid={fieldErrors?.['grid.gap'] ? true : undefined}
aria-describedby={fieldErrors?.['grid.gap'] ? 'grid-gap-error' : undefined}
/>
<FieldError id="grid-gap-error" message={fieldErrors?.['grid.gap']} />
</div>
</div>
</div>

View File

@@ -3,10 +3,12 @@
import * as Icons from '../Icons';
import ToggleCard from '../fields/ToggleCard';
import PrivacyEditor from '../PrivacyEditor';
import EnvLockNote from '../fields/EnvLockNote';
import { CONTACT_RETENTION_DEFAULT, CONTACT_RETENTION_MAX, isHttpUrl } from '@/lib/config/schema';
import type { SectionProps } from './types';
export default function LegalSection({ settings, update }: SectionProps) {
export default function LegalSection({ settings, update, envLocks }: SectionProps) {
const notifyLock = envLocks?.['contact.notifyUrl'];
// 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();
@@ -206,16 +208,19 @@ export default function LegalSection({ settings, update }: SectionProps) {
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"
aria-invalid={(!notifyLock && notifyUrlInvalid) || undefined}
aria-describedby={notifyLock ? 'contact-notify-url-lock' : 'contact-notify-url-hint'}
disabled={!!notifyLock}
/>
{notifyLock && <EnvLockNote id="contact-notify-url-lock" variable={notifyLock} />}
<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.'}
: 'Gets a fixed "new message" push, with nothing about the sender. Pick a topic name nobody can guess.' +
(notifyLock ? '' : ' CONTACT_NOTIFY_URL overrides this.')}
</p>
</div>
<div className="admin-field">

View File

@@ -3,10 +3,12 @@
import * as Icons from '../Icons';
import ToggleCard from '../fields/ToggleCard';
import PasswordField from '../fields/PasswordField';
import EnvLockNote from '../fields/EnvLockNote';
import { resolveWatermarkOpacity } from '@/lib/config/schema';
import type { SectionProps } from './types';
export default function SecuritySection({ settings, update }: SectionProps) {
export default function SecuritySection({ settings, update, envLocks }: SectionProps) {
const passwordLock = envLocks?.sitePassword;
return (
<div className="settings-panel">
<div className="settings-section-header">
@@ -27,11 +29,18 @@ export default function SecuritySection({ settings, update }: SectionProps) {
onChange={(password) => update('sitePassword', password ?? '')}
placeholder="Leave empty for a public site"
label="Site password"
disabled={!!passwordLock}
/>
{passwordLock && <EnvLockNote variable={passwordLock} />}
<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.
Stored as a <code>scrypt:…</code> hash in <code>settings.yaml</code>, never as typed.
{!passwordLock && (
<>
{' '}
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>

View File

@@ -2,11 +2,13 @@
import * as Icons from '../Icons';
import ToggleCard from '../fields/ToggleCard';
import { FieldError } from './fields';
import type { SectionProps } from './types';
export default function SeoSection({
settings,
update,
fieldErrors,
siteUrlInfo,
}: SectionProps & {
/** Resolved site URL and its origin, so the panel can name SITE_URL (#472). */
@@ -52,7 +54,10 @@ export default function SeoSection({
value={settings.url || ''}
onChange={(e) => update('url', e.target.value)}
placeholder="https://folio.example"
aria-invalid={fieldErrors?.url ? true : undefined}
aria-describedby={fieldErrors?.url ? 'seo-site-url-error' : undefined}
/>
<FieldError id="seo-site-url-error" message={fieldErrors?.url} />
<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

View File

@@ -4,7 +4,7 @@ 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 { effectiveTheme, FeatureRow, FieldError } from './fields';
import type { SectionProps } from './types';
const PRESETS = ['studio-modern', 'studio', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
@@ -264,7 +264,7 @@ function PresetSpecs({ value }: { value: string }) {
);
}
export default function ThemeSection({ settings, update }: SectionProps) {
export default function ThemeSection({ settings, update, fieldErrors }: 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'>(() =>
@@ -417,8 +417,11 @@ export default function ThemeSection({ settings, update }: SectionProps) {
value={settings.theme?.accent || ''}
onChange={(e) => update('theme.accent', e.target.value)}
placeholder={theme.accent}
aria-invalid={fieldErrors?.['theme.accent'] ? true : undefined}
aria-describedby={fieldErrors?.['theme.accent'] ? 'theme-accent-error' : undefined}
/>
</div>
<FieldError id="theme-accent-error" message={fieldErrors?.['theme.accent']} />
</div>
</div>

View File

@@ -9,6 +9,20 @@ import { resolveTheme } from '@/lib/config/theme';
import type { SettingsYaml } from '@/lib/config/schema';
import type { Settings } from './types';
/**
* The message under an input whose value the site would ignore (`fieldErrors`).
* Not a live region: it changes as you type, and announcing every keystroke
* would drown the field itself. The input points at it with aria-describedby.
*/
export function FieldError({ id, message }: { id: string; message?: string }) {
if (!message) return null;
return (
<p id={id} className="admin-field-hint admin-field-hint--error">
{message}
</p>
);
}
/**
* One labelled block of related switches.
*

View File

@@ -1,3 +1,5 @@
import type { EnvLocks } from '@/lib/admin/envLocks';
/**
* Shared shape of the settings form (#554). The sections edit one object
* that mirrors settings.yaml; SettingsEditor owns it and hands each section
@@ -76,6 +78,7 @@ export interface Settings {
proofing?: {
enabled?: boolean;
allowMailto?: boolean;
email?: string;
};
watermark?: {
enabled?: boolean;
@@ -92,4 +95,12 @@ export interface SectionProps {
update: (path: string, value: unknown) => void;
/** Set several paths in one state update. */
updateMany: (entries: Record<string, unknown>) => void;
/** Fields an environment variable overrides; rendered locked (#605). */
envLocks?: EnvLocks;
/**
* Dotted path → message, for values the site would ignore (the save route
* rejects them). Checked as the form changes, so the message stands next to
* the input before a save is tried.
*/
fieldErrors?: Record<string, string>;
}

View File

@@ -7,8 +7,8 @@ import type { DoctorLevel } from '@/lib/admin/doctor';
*/
export const DOCTOR_LEVEL_EVENT = 'folio:doctor-level';
/** The three tones `status-badge-btn` is styled for. */
export type BadgeTone = 'connected' | 'disconnected' | 'unknown';
/** The tones `status-badge-btn` is styled for. */
export type BadgeTone = 'connected' | 'warning' | 'disconnected' | 'unknown';
export interface SystemHealthInput {
statusLoading: boolean;
@@ -49,7 +49,9 @@ export function systemHealth({
if (setupIncomplete) return { tone: 'unknown', label: 'Setup Incomplete' };
if (immich === 'error') return { tone: 'disconnected', label: 'System Degraded' };
if (doctorLevel === 'error') return { tone: 'disconnected', label: 'Needs Attention' };
if (doctorLevel === 'warn') return { tone: 'unknown', label: 'Check Diagnostics' };
// Amber, like the warnings on the diagnostics page it points to. Grey read
// as "no answer", which is what `unknown` means.
if (doctorLevel === 'warn') return { tone: 'warning', label: 'Check Diagnostics' };
if (immich === 'ok') return { tone: 'connected', label: 'System OK' };
return { tone: 'unknown', label: 'Status Unknown' };
}

View File

@@ -108,6 +108,11 @@ export function useDraft<T>(key: string, value: T, dirty: boolean) {
*/
const holdRef = useRef(false);
const [status, setStatus] = useState<DraftStatus>('none');
/** The value as last rendered, for `saved()` when edits arrived during the save. */
const valueRef = useRef(value);
useEffect(() => {
valueRef.current = value;
}, [value]);
// 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
@@ -142,12 +147,19 @@ export function useDraft<T>(key: string, value: T, dirty: boolean) {
[key],
);
/** Call after a successful save, with the fingerprint of what was saved. */
/**
* Call after a successful save, with the fingerprint of what was saved.
* `stillDirty` when the editor was changed while the save was in flight: those
* edits stay a draft, now on top of the version just written. Clearing it
* would leave them unkept until the next keystroke, since the mirror effect
* above only runs when the value or the dirty flag changes.
*/
const saved = useCallback(
(base: string) => {
(base: string, stillDirty = false) => {
baseRef.current = base;
holdRef.current = false;
clearDraft(key);
if (stillDirty) writeDraft(key, { base, value: valueRef.current });
else clearDraft(key);
setStatus('none');
},
[key],

View File

@@ -0,0 +1,20 @@
'use client';
import { useEffect, useRef } from 'react';
/**
* A ref that always holds the value of the latest render.
*
* For code that awaits a request and then has to know whether the editor moved
* on meanwhile: a save handler closes over the state it started with, so after
* the `await` it cannot tell "nothing changed" from "the operator kept typing".
* Comparing what was sent with `ref.current` can (every edit replaces the state
* value, so identity is enough).
*/
export function useLatest<T>(value: T) {
const ref = useRef(value);
useEffect(() => {
ref.current = value;
}, [value]);
return ref;
}

View File

@@ -0,0 +1,97 @@
'use client';
/**
* Saving a content file without clobbering a change made elsewhere (#601).
*
* Each editor keeps the `version` its GET returned in a ref and saves through
* this hook, which sends it as `If-Match`. When the server answers 409 with
* `conflict: true` — the file was saved from another tab or device, edited by
* hand, or restored from a backup since it was loaded — the operator picks:
*
* - Reload: throw away this editor's edits and load the file as it is now.
* - Overwrite anyway: save again against the new version, replacing that change.
* - Keep editing (cancel): nothing happens; the edits and their draft stay.
*
* Other 409s (a rename onto a taken slug) carry no `conflict` flag and are
* returned like any other failure.
*/
import { useCallback, type MutableRefObject } from 'react';
import { useChoice } from './ConfirmDialog';
/** The If-Match header for a version, or none before the first load. */
export function ifMatch(version: string | null): Record<string, string> {
return version ? { 'If-Match': `"${version}"` } : {};
}
/** The body of any JSON answer; routes return objects. */
export type SaveData = Record<string, unknown> & { error?: string };
export type VersionedSaveResult =
| { kind: 'saved'; res: Response; data: SaveData }
| { kind: 'failed'; res: Response; data: SaveData | null }
| { kind: 'reload' }
| { kind: 'keep' };
export function isEditConflict(res: Response, data: unknown): data is { version: string } {
return (
res.status === 409 &&
!!data &&
typeof data === 'object' &&
(data as { conflict?: unknown }).conflict === true &&
typeof (data as { version?: unknown }).version === 'string'
);
}
type ConflictChoice = 'overwrite' | 'reload';
/**
* `what` names the file in the dialog ("The settings", "This entry").
* A network failure throws, as a plain fetch would.
*/
export function useVersionedSave() {
const choose = useChoice();
return useCallback(
async (
url: string,
body: unknown,
versionRef: MutableRefObject<string | null>,
what: string,
): Promise<VersionedSaveResult> => {
let version = versionRef.current;
for (;;) {
const res = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...ifMatch(version) },
body: JSON.stringify(body),
});
const data = (await res.json().catch(() => null)) as SaveData | null;
if (isEditConflict(res, data)) {
const choice = await choose<ConflictChoice>({
title: 'Changed elsewhere',
message: `${what} was changed since you opened it — in another tab, on another device, by hand or by a backup restore. Saving now would replace that change.`,
actions: [
{ id: 'overwrite', label: 'Overwrite anyway' },
{ id: 'reload', label: 'Reload (discard mine)' },
],
cancelLabel: 'Keep editing',
});
if (choice === 'overwrite') {
version = data.version;
continue;
}
return { kind: choice === 'reload' ? 'reload' : 'keep' };
}
if (res.ok && data) {
if (typeof data.version === 'string') versionRef.current = data.version;
return { kind: 'saved', res, data };
}
return { kind: 'failed', res, data };
}
},
[choose],
);
}

View File

@@ -16,9 +16,15 @@ export const metadata: Metadata = {
*/
export const dynamic = 'force-dynamic';
/**
* `lang="en"`: `<html lang>` follows the site language (settings.yaml `lang`),
* but the admin is deliberately untranslated. Without this a screen reader
* read the English interface with a German voice on a `lang: de` site.
* The Listbox popup is portalled to <body> and still inherits the root value.
*/
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return (
<div className="admin-layout">
<div className="admin-layout" lang="en">
<AdminShell>{children}</AdminShell>
</div>
);

View File

@@ -0,0 +1,11 @@
import { PageContentEditor } from '../../../components/page-builder/PageContentEditor';
/** The block editor for one content page (#722), reached from Pages. */
export default async function AdminPageContentPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
return <PageContentEditor slug={slug} />;
}

View File

@@ -7,7 +7,7 @@ export default function AdminPagesPage() {
<PageHeader
kicker="Content"
title="Pages"
description="The home page hero, standalone albums and every subpage. Drag to reorder; changes go live when saved."
description="The home page hero, standalone albums, subpages and content pages. Drag to reorder; changes go live when saved."
/>
<PageBuilder />
</>

View File

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

View File

@@ -74,16 +74,32 @@
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.6rem 0.75rem;
border: none;
border-radius: 8px;
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
transition: background 0.1s;
}
.picker-item:hover:not(.used) {
.picker-item:hover:not(.used),
.picker-item:focus-visible {
background: var(--admin-bg-elevated);
}
.picker-item:focus-visible {
outline: var(--admin-focus-ring);
outline-offset: -2px;
}
.picker-item:focus-visible .picker-select-badge {
opacity: 1;
}
.picker-item.used {
opacity: 0.5;
cursor: not-allowed;

View File

@@ -106,25 +106,32 @@
align-items: flex-end;
background: var(--admin-bg-elevated);
border-radius: 6px;
overflow: hidden;
}
/* --admin-primary never existed, so every bar fell back to a fixed blue;
the accent is what the top-pages bars below use. */
.bar-fill {
width: 100%;
background: var(--admin-primary, #3b82f6);
border-radius: 6px 6px 0 0;
background: var(--admin-accent);
border-radius: 6px;
transition: height 0.3s ease;
position: relative;
display: flex;
align-items: flex-start;
justify-content: center;
}
/* Above the bar, not inside it: inside, a short bar cut its number in half
and an empty day had nowhere to put its 0. The chart's padding-top leaves
room above the tallest bar. */
.bar-val {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 3px;
font-size: 0.65rem;
font-weight: 600;
color: #fff;
margin-top: 4px;
line-height: 1;
color: var(--admin-text-secondary);
white-space: nowrap;
}
.bar-label {

View File

@@ -20,15 +20,16 @@
padding: 0.75rem;
}
/* A warning, so --admin-warning: the accent is red under studio-modern. */
.order-editor-notice {
margin: 0 0 0.75rem;
padding: 0.5rem 0.7rem;
border-radius: 6px;
font-size: 0.78rem;
line-height: 1.4;
color: var(--admin-accent);
border: 1px solid rgba(245, 158, 11, 0.3);
background: rgba(245, 158, 11, 0.05);
color: var(--admin-warning);
border: 1px solid color-mix(in srgb, var(--admin-warning) 30%, transparent);
background: color-mix(in srgb, var(--admin-warning) 5%, transparent);
}
.order-editor-section + .order-editor-section {
@@ -91,15 +92,27 @@
cursor: grabbing;
}
/* A button, so the tail can be pinned from the keyboard. */
.order-tile-unpinned {
display: block;
width: 100%;
padding: 0;
font: inherit;
color: inherit;
opacity: 0.55;
}
.order-tile-unpinned:hover {
.order-tile-unpinned:hover,
.order-tile-unpinned:focus-visible {
opacity: 1;
border-color: var(--admin-border);
}
.order-tile:focus-visible {
outline: var(--admin-focus-ring);
outline-offset: 2px;
}
.order-tile-index {
position: absolute;
bottom: 4px;
@@ -110,7 +123,7 @@
font-size: 0.68rem;
font-weight: 700;
text-align: center;
color: #000;
color: var(--admin-on-accent);
background: var(--admin-accent);
}

View File

@@ -113,7 +113,7 @@
height: 22px;
border-radius: 50%;
background: var(--admin-accent);
color: #000;
color: var(--admin-on-accent);
font-size: 0.7rem;
display: flex;
align-items: center;
@@ -167,3 +167,85 @@
margin-top: 0.3rem;
display: block;
}
/* ── Search and multi-select (#602) ────────────────────────── */
.asset-picker-search {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1rem;
border-bottom: 1px solid var(--admin-border);
color: var(--admin-text-muted);
}
.asset-picker-search-input {
flex: 1;
padding: 0.45rem 0.7rem;
background: var(--admin-bg-input);
border: 1px solid var(--admin-border);
border-radius: 6px;
color: var(--admin-text);
font-size: 0.85rem;
}
.asset-picker-search-input:focus-visible {
outline: var(--admin-focus-ring);
outline-offset: 1px;
border-color: var(--admin-border-focus);
}
/* Tiles are buttons now; reset what the browser gives a button. */
button.asset-picker-tile {
display: block;
width: 100%;
padding: 0;
font: inherit;
color: inherit;
}
.asset-picker-tile:focus-visible {
outline: var(--admin-focus-ring);
outline-offset: 2px;
}
.asset-picker-tile:disabled:not(.used) {
opacity: 0.55;
cursor: not-allowed;
}
.asset-picker-tile.selected {
border-color: var(--admin-accent);
box-shadow: 0 0 0 2px var(--admin-accent);
}
.asset-picker-order-badge {
position: absolute;
top: 4px;
right: 4px;
min-width: 22px;
height: 22px;
padding: 0 6px;
border-radius: 11px;
background: var(--admin-accent);
color: var(--admin-on-accent);
font-size: 0.72rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.asset-picker-selection {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1rem;
border-top: 1px solid var(--admin-border);
font-size: 0.85rem;
color: var(--admin-text-secondary);
}
.asset-picker-selection > span {
flex: 1;
}

View File

@@ -90,7 +90,7 @@ h3 .svg-icon {
.album-tile-btn:hover {
background: var(--admin-accent);
color: #000;
color: var(--admin-on-accent);
border-color: var(--admin-accent);
}

View File

@@ -12,7 +12,7 @@
padding: 0.1rem 0.5rem;
border-radius: 999px;
background: var(--admin-accent);
color: #fff;
color: var(--admin-on-accent);
font-size: 0.7rem;
font-weight: 600;
}

View File

@@ -0,0 +1,92 @@
/* The client proofing view (Admin → Proofing). Builds on the analytics panels. */
.proofing-create-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0 1rem;
}
.proofing-card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
flex-wrap: wrap;
}
.proofing-card-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.proofing-card-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.proofing-card-body {
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.proofing-badge {
font-size: 0.7rem;
font-weight: 500;
padding: 0.1rem 0.5rem;
border-radius: 999px;
border: 1px solid var(--admin-border);
color: var(--admin-text-secondary);
}
.proofing-badge--submitted {
border-color: var(--admin-success);
color: var(--admin-success);
}
.proofing-badge--expired {
border-color: var(--admin-warning);
color: var(--admin-warning);
}
.proofing-card--expired {
opacity: 0.75;
}
.proofing-picks {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.75rem;
}
.proofing-pick {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.proofing-pick img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 4px;
background: var(--admin-bg-input);
}
.proofing-pick-name {
font-size: 0.7rem;
color: var(--admin-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -183,7 +183,7 @@
padding: 0.05rem 0.4rem;
border-radius: 999px;
background: var(--admin-accent);
color: #fff;
color: var(--admin-on-accent);
font-family: var(--admin-font-label);
font-size: 0.68rem;
text-align: center;
@@ -214,17 +214,34 @@
justify-content: flex-start;
}
/* In the sidebar foot the dropdown opens upwards and to the right. */
/* In the sidebar foot the dropdown opens upwards, as wide as the foot. The
sidebar scrolls (overflow-y: auto), which clips sideways too: a fixed 280px
panel lost its values past the 232px column. */
.admin-sidebar .status-dropdown {
top: auto;
bottom: 100%;
left: 0;
right: auto;
right: 0;
margin: 0 0 0.5rem;
width: 280px;
width: auto;
border-radius: var(--admin-radius);
}
.admin-sidebar .status-item {
gap: 0.75rem;
}
.admin-sidebar .status-val {
text-align: right;
}
/* The collapsed sidebar does not clip (overflow: visible below), so the panel
keeps its width and opens out of the 64px column. */
.admin-app.is-collapsed .admin-sidebar .status-dropdown {
right: auto;
width: 280px;
}
.admin-sidebar-actions {
display: flex;
flex-direction: column;
@@ -656,6 +673,7 @@
.confirm-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1.4rem;
@@ -836,6 +854,82 @@
text-transform: uppercase;
}
/* Content pages in the structure list (#722): a document icon and a PAGE tag
tell them apart from subpages at a glance. */
.pb-row-tag {
padding: 0 0.35rem;
border-radius: 2px;
background: var(--admin-bg-elevated);
font-family: var(--admin-font-label);
font-size: 0.58rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--admin-text-secondary);
}
.pb-offmenu {
min-height: 2.25rem;
border-radius: var(--admin-radius);
}
.pb-offmenu.is-over {
outline: 1px dashed var(--admin-border-hover);
}
.pb-offmenu .empty-hint {
margin: 0;
padding: 0.45rem 0.6rem;
font-size: 0.75rem;
}
.page-panel {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 720px;
}
.page-panel .builder-section-header {
margin-bottom: 0;
}
.page-panel textarea {
width: 100%;
resize: vertical;
}
.page-panel-switches {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.page-panel-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.new-page-card {
width: min(440px, 100%);
}
.new-page-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.pb-add-row {
display: flex;
gap: 0.35rem;
}
.pb-add-row .pb-add {
flex: 1;
}
.pb-add {
display: flex;
align-items: center;
@@ -868,6 +962,11 @@
.subpage-drawer-container--inline {
max-width: none;
max-height: none;
/* The overlay sheet is 100dvh tall on a phone (subpage-sheet.css). In place,
that height clipped everything past the first screen — album list, grid
settings, Delete — and the footer slid over the password field. The
sheet grows with its content and the page scrolls. */
height: auto;
border-radius: var(--admin-radius);
box-shadow: none;
animation: none;
@@ -891,6 +990,11 @@
position: static;
max-height: none;
}
/* Picking a row scrolls the stacked panel up (PageBuilder revealPanel). */
.pb-panel {
scroll-margin-top: 1rem;
}
}
/* A custom album title is marked by weight, not by the accent: red italics
@@ -1324,3 +1428,34 @@
min-height: 44px;
}
}
/* ── Env-locked fields (#605) ──────────────────────────────── */
/* A field an environment variable overrides: the input is disabled and this
note names the variable. Neutral pill, not the warning or accent colour —
the lock is a fact about the deployment, not a problem. */
.admin-env-lock {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 8px;
margin-top: 0.4rem;
font-size: 0.75rem;
line-height: 1.4;
color: var(--admin-text-muted);
}
.admin-env-lock-pill {
display: inline-flex;
align-items: center;
gap: 5px;
height: 20px;
padding: 0 8px;
border: 1px solid var(--admin-border-hover);
border-radius: 999px;
font-family: var(--admin-font-label);
font-size: 0.62rem;
letter-spacing: 0.1em;
text-transform: uppercase;
flex-shrink: 0;
}

View File

@@ -27,8 +27,10 @@
animation: save-bar-in 0.2s ease-out;
}
/* Unsaved changes are a warning, never the accent: studio-modern makes the
accent red, and the bar read as an error. */
.floating-save-bar.dirty {
border-color: var(--admin-accent);
border-color: var(--admin-warning);
}
/* Keyframes must carry the translateX(-50%) or the bar jumps horizontally. */
@@ -47,7 +49,7 @@
width: 8px;
height: 8px;
border-radius: 50%;
background: #ff9800;
background: var(--admin-warning);
display: inline-block;
margin-right: 6px;
animation: pulse 1.5s infinite;
@@ -56,15 +58,15 @@
@keyframes pulse {
0% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba(255, 152, 0, 0.7);
box-shadow: 0 0 0 0 color-mix(in srgb, var(--admin-warning) 70%, transparent);
}
70% {
transform: scale(1);
box-shadow: 0 0 0 8px rgba(255, 152, 0, 0);
box-shadow: 0 0 0 8px transparent;
}
100% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba(255, 152, 0, 0);
box-shadow: 0 0 0 0 transparent;
}
}

View File

@@ -269,6 +269,11 @@
animation: pulse-red 1.5s infinite;
}
/* The doctor found something worth a look, nothing broken: amber, still. */
.status-badge-btn.warning .status-dot {
background-color: var(--admin-warning);
}
/* The diagnostics request itself did not run — deliberately not red, and not
animated: an unanswered check is not a fault report. */
.status-badge-btn.unknown .status-dot {
@@ -640,6 +645,11 @@
color: var(--admin-error);
}
/* A consequence to know about, not an error: --admin-accent may be red (#722). */
.admin-field-hint--warn {
color: var(--admin-warning);
}
/* 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 {

View File

@@ -98,7 +98,7 @@
display: inline-flex;
align-items: center;
font-size: 0.75rem;
color: var(--admin-accent);
color: var(--admin-warning);
font-weight: 500;
}

View File

@@ -37,7 +37,7 @@
.hover-action-btn {
font-size: 0.78rem;
font-weight: 600;
color: #ffffff;
color: var(--admin-on-accent);
background: var(--admin-accent, #e60012);
padding: 0.4rem 0.85rem;
border-radius: 20px;

View File

@@ -18,8 +18,13 @@
--admin-border-focus: #777777;
--admin-text: #f0f0f0;
--admin-text-secondary: #a0a0a0;
--admin-text-muted: #707070;
--admin-accent: #f59e0b;
/* ≥4.6:1 on every admin surface (#0c0c0c–#222222); #707070 fell to 3.2:1. */
--admin-text-muted: #8a8a8a;
/* Not amber: that is --admin-warning, and with both the same every active
marker and focus ring read as a warning. 7.7:1 on the background. */
--admin-accent: #60a5fa;
/* Text on an accent fill (counts, badges). */
--admin-on-accent: #000000;
/* Focus used to show only as a border colour change from #2e2e2e to
#666666, a contrast of 2.37:1 where WCAG 1.4.11 asks for 3:1 against
adjacent colours, so keyboard users could barely see where they were.
@@ -53,8 +58,10 @@
--admin-border-focus: #7f7f7f;
--admin-text: #1a1a1a;
--admin-text-secondary: #555555;
--admin-text-muted: #888888;
--admin-accent: #d97706;
/* ≥4.6:1 on white and on #efefee; #888888 was 3.1–3.5:1. */
--admin-text-muted: #6b6b6b;
--admin-accent: #2563eb;
--admin-on-accent: #ffffff;
--admin-success: #16a34a;
--admin-warning: #b45309;
--admin-error: #dc2626;
@@ -66,6 +73,20 @@
--admin-overlay: rgba(0, 0, 0, 0.4);
}
/* studio-modern restyles the admin in its preset file with a red accent and
warm greys. Two of its values are corrected here, one step more specific
(html) so the order the sheets load in does not matter: its muted text
(#6b6b66 dark, #8a8a84 light) sat at 3.0–3.6:1, and text on its red reads
better white (4.8:1) than black (4.4:1). */
html[data-preset='studio-modern'] .admin-layout {
--admin-text-muted: #8c8c86;
--admin-on-accent: #ffffff;
}
html[data-preset='studio-modern'][data-theme='light'] .admin-layout {
--admin-text-muted: #6b6b66;
}
.admin-layout {
min-height: 100vh;
background: var(--admin-bg);

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