release: v0.18.0 (#725)
Release v0.18.0 — merges `dev` into `main`. ## Thanks - [@lancetm714](https://github.com/lancetm714) for the accent-contrast fix (#680 → #724). - [@RichKidsDev](https://github.com/RichKidsDev) for reporting the outdated Impressum (#697). ### Security - **Journal drafts and password-protected entries no longer render publicly as a subpage essay** ([#684](https://github.com/ralksta/immich-folio/pull/684), [GHSA-987w-cpwm-c9g9](https://github.com/ralksta/immich-folio/security/advisories/GHSA-987w-cpwm-c9g9), medium). A subpage's `essayFile` read the journal entry straight from disk, skipping the draft and password rules `/journal/<slug>` applies. It now resolves through the same rules: a draft reads as missing for visitors, and a protected entry shows its password gate, sharing the `lb_auth_journal_<slug>` cookie. - **Immich descriptions are escaped in generated subpage essays** ([#684](https://github.com/ralksta/immich-folio/pull/684), [GHSA-8ppx-39jv-q5xm](https://github.com/ralksta/immich-folio/security/advisories/GHSA-8ppx-39jv-q5xm), low). The essay built from a subpage's albums (`layout: essay` without markdown) put each asset's Immich description into the page as raw HTML. Captions are now HTML-escaped, and they go through `assetCaption()`, so `exif.caption: false` is respected there as well. Tracked in [#689](https://github.com/ralksta/immich-folio/issues/689). - **Admin drafts no longer keep a typed password, and the contact form's email check runs in linear time** ([#727](https://github.com/ralksta/immich-folio/pull/727)). The admin's session drafts wrote a password as typed to `sessionStorage`; a draft holding a plaintext password is now not kept (the unsaved-changes guard still warns). The email pattern could backtrack polynomially on a crafted domain; its labels now exclude the dot and the length is checked first. Both found by CodeQL before release. ### Added - **A built-in contact form with an inbox in the admin** ([#703](https://github.com/ralksta/immich-folio/pull/703), closes [#702](https://github.com/ralksta/immich-folio/issues/702)). `/contact` needs neither SMTP nor a form service: each message is one file in `content/messages/` (mode `0600`), read under the new _Messages_ tab, where _Reply by email_ opens the owner's mail client with the message quoted. Messages older than `contact.retentionDays` (default 90) are deleted. An optional ntfy push goes to `contact.notifyUrl` or `CONTACT_NOTIFY_URL`, and since [#709](https://github.com/ralksta/immich-folio/pull/709) it carries a fixed "new message" line and nothing about the sender — no name, no address, no text. Spam is kept out without a captcha: an off-screen honeypot, a minimum fill time, three submissions a minute per IP and a cap of 500 stored messages. With the Impressum on and no `legal.contactUrl`, the Impressum links the form as its second contact channel. A new doctor check warns when the form is on but nobody is notified. - **A privacy policy page, next to a list of what the site processes** ([#708](https://github.com/ralksta/immich-folio/pull/708)). `/privacy` renders `content/privacy.md`, edited under Settings → Legal, and the footer links it once the file has text; `privacy.enabled: false` hides it. Folio writes no legal text. Beside the editor it lists what _this_ installation does, read off the configuration — the map, statistics, contact form, password cookies, CDN — and marks every entry that involves a third party. _Insert headings_ adds the matching section headings and nothing else. The doctor warns while the Impressum is on and there is no policy. - **French, Spanish, Italian and Dutch interface translations** ([#681](https://github.com/ralksta/immich-folio/pull/681)), selected with `settings.yaml: lang` as before. The admin panel stays English. - **CDN mode for photos and videos** ([#681](https://github.com/ralksta/immich-folio/pull/681)). With `CDN_URL` set, image and video URLs point at a pull CDN whose origin is this server, so repeat views reach neither the server nor Immich. A site password switches the mode off, since a CDN would hand out cached photos without checking the unlock cookie. The CDN origin is added to the CSP, and a doctor check covers the password and `TRUSTED_PROXY_HOPS` cases. See `docs/deployment.md#cdn-mode`. - **A reworked admin panel** ([#712](https://github.com/ralksta/immich-folio/pull/712), [#713](https://github.com/ralksta/immich-folio/pull/713), [#714](https://github.com/ralksta/immich-folio/pull/714), [#716](https://github.com/ralksta/immich-folio/pull/716), [#717](https://github.com/ralksta/immich-folio/pull/717), [#718](https://github.com/ralksta/immich-folio/pull/718), [#719](https://github.com/ralksta/immich-folio/pull/719), [#721](https://github.com/ralksta/immich-folio/pull/721)): - **A sidebar instead of the header row**, grouped by task, with an unread count on Messages and a dot on Diagnostics while Immich is down. It collapses to an icon rail, with names shown on hover and focus, and remembers that per browser. - **`/admin` is an overview**: what needs attention (unread messages, doctor warnings), published pages, journal entries and recent views. - **The site's own visual language** — the preset's fonts, hairline borders, the square marker — and one page header on every screen. The accent is kept for the current place and the primary action; switches are now pills reading ON or OFF, in green rather than the accent, so Studio Modern's red no longer makes an enabled setting look like an error. - **The page builder is a structure list and a panel.** Hero, subpages and standalone albums sit in one list on the left, subpages drag-sorted in place; the selected entry is edited on the right instead of in an overlay. - **Dialogs only where a change is permanent.** Deleting a message or a journal entry, and resetting the favicon (which used to delete the file without asking), go through one confirm dialog in the admin's own look. Removals in the page builder, which only touch unsaved state, happen at once and offer _Undo_. No `window.confirm()` is left. - **Feature switches are grouped by meaning** as one-line rows under Settings → General; page transitions and scroll-to-top moved to Theme → Look & motion. No setting keys changed. ### Changed - **Fonts and the Leaflet stylesheet are served from the site itself** ([#700](https://github.com/ralksta/immich-folio/pull/700)). Every page used to link `fonts.googleapis.com`, which sends each visitor's IP address to Google. The server now fetches a theme's Google fonts once, rewrites the stylesheet and caches the files in `content/.fonts/`; custom fonts in `theme.fonts` keep working. If Google cannot be reached before a font is cached, the page falls back to the theme's system fonts. `leaflet.css` comes from the npm package instead of unpkg. The CSP drops `fonts.googleapis.com`, `fonts.gstatic.com` and `unpkg.com`. - **Email addresses are kept out of the served HTML** ([#705](https://github.com/ralksta/immich-folio/pull/705)). The Impressum and footer render the address only after hydration, from an encoded form, so harvesters reading raw HTML find nothing. Without JavaScript the Impressum shows `name [at] example [dot] de`. This defeats bulk harvesting, not a person with a browser. - **The Impressum cites § 5 DDG and links its contacts** ([#698](https://github.com/ralksta/immich-folio/pull/698), reported in [#697](https://github.com/ralksta/immich-folio/issues/697) by [@RichKidsDev](https://github.com/RichKidsDev)). The TMG was replaced by the DDG in May 2024; `legal.heading` replaces the line for other laws (e.g. § 5 ECG in Austria). `legal.contactUrl` and `legal.contactLabel` add a contact form as the second channel, so a phone number stays optional. Email, phone and contact URL are now links, and the "Created with the Impressum generator by eRecht24" line, shown on every site whether true or not, is gone. Settings → Legal gains fields for heading, contact URL, VAT ID and tax number, and a doctor check flags missing details. - **Missing pages answer `404`** ([#704](https://github.com/ralksta/immich-folio/pull/704)). Every missing page used to answer `200`, because streaming had started before `notFound()` ran. `/contact`, `/impressum`, `/map` and `/privacy` while switched off, and `/journal/<slug>` without an entry, now return a real 404 with the same not-found page. Album and subpage slugs still depend on Immich and stay soft 404s. - **Passwords set in the admin are stored as scrypt hashes** ([#706](https://github.com/ralksta/immich-folio/pull/706), closes [#690](https://github.com/ralksta/immich-folio/issues/690)). Subpage, album, journal-entry and site passwords were written as typed. The save routes now hash them; a stored hash reads _Protected_ with _Change_ and _Remove_ and is never put into an input. Visitors who unlocked a page stay signed in across unrelated saves. Existing plaintext passwords are hashed the next time their file is saved in the admin. - **One notification surface in the admin** ([#707](https://github.com/ralksta/immich-folio/pull/707)) replaces every `alert()`: errors stay until dismissed, successes clear themselves, and screen readers announce both. ### Fixed - **Map tiles load again** ([#701](https://github.com/ralksta/immich-folio/pull/701)). CARTO started answering every tile with an "API KEY REQUIRED" image, leaving `/map` and journal map blocks as a grey grid. Tiles now come from OpenStreetMap; the dark look is a CSS filter, so the map now follows the colour mode. - **The page builder no longer drops a subpage's `proofing:` flag on save** ([#711](https://github.com/ralksta/immich-folio/pull/711)). A page switched off for proofing by hand reverted after any unrelated edit. - **Unsaved edits in Settings and About survive switching admin tabs, and a backup restore is no longer overwritten by the next save** ([#687](https://github.com/ralksta/immich-folio/pull/687)). Editors that were open during a restore now reload and offer unsaved edits back as a conflict. Also: "Restore my changes" after a conflict found nothing, removing an album left its drawer showing the next album, and the journal saved (rotating a backup) without changes. - **Accent-backed buttons stay readable with a light or malformed accent** ([#724](https://github.com/ralksta/immich-folio/pull/724), supersedes [#680](https://github.com/ralksta/immich-folio/pull/680)) — contributed by [@lancetm714](https://github.com/lancetm714). A white accent made the proofing dialog's _Download selected (.zip)_ button white on white. `theme.accent` is now accepted as hex only and normalised to `#rrggbb`, anything else falls back to the preset, and the proofing dialog and filter pills use the mode-aware `--on-accent` text colour from #688. - **Every preset's accent is visible in both colour modes** ([#688](https://github.com/ralksta/immich-folio/pull/688)). Presets had one accent for both modes; minimal's dark mode drew black buttons on black. Presets now carry a light and a dark accent, and text on accent-filled buttons picks black or white for contrast. A custom accent stays the owner's choice in both modes. - **Visitor-side inconsistencies** ([#685](https://github.com/ralksta/immich-folio/pull/685)): after visiting `/impressum`, every subpage header turned centred and uppercase; back links announced "Back to Back to …"; the hero EXIF chip ignored `exif.location`/`camera`/`settings`; videos in journal entries opened as stills; the setup screen named a non-existent `.env.example`. - **An essay's lightbox walks only the photos the story shows** ([#686](https://github.com/ralksta/immich-folio/pull/686)), not every photo of the subpage's albums, and the proofing bar in essays shows the selection count instead of a filter button that did nothing. - **The page panel scrolls with the mouse wheel** ([#720](https://github.com/ralksta/immich-folio/pull/720)). - **The journal editor's top bar wraps on phones** ([#723](https://github.com/ralksta/immich-folio/pull/723), closes [#675](https://github.com/ralksta/immich-folio/issues/675)), so the title stays editable and Save stays on screen. - **Every admin form field has a name for assistive technology** ([#715](https://github.com/ralksta/immich-folio/pull/715)). Only two inputs had a linked label; 47 are linked now, and clicking a label focuses its field. ### Internal - **`SettingsEditor` and `PageBuilder` are split up** ([#710](https://github.com/ralksta/immich-folio/pull/710), [#711](https://github.com/ralksta/immich-folio/pull/711)). The 2,200-line settings editor is a shell with one file per section; the page builder's YAML handling and edits are pure functions under test. Both were checked side by side against `dev` for identical output. - **`useAdminFetch()`** ([#707](https://github.com/ralksta/immich-folio/pull/707)) gives Analytics, Messages, Backups and the journal list one loading and error pattern. - README brought up to v0.17.0 ([#678](https://github.com/ralksta/immich-folio/pull/678), [#679](https://github.com/ralksta/immich-folio/pull/679)). ### Upgrade notes **`content/` gains two directories the app writes to:** `content/.fonts/` for the cached fonts and `content/messages/` for contact-form messages. Both are created on demand, so a writable `content/` is enough, as before. **The server needs outbound HTTPS to Google once per font** to fill the font cache. Until then pages fall back to the theme's system fonts. Visitors' browsers no longer contact Google. **A custom CSP or reverse-proxy allowlist** must now allow `tile.openstreetmap.org` for images instead of `*.basemaps.cartocdn.com`, and no longer needs `fonts.googleapis.com`, `fonts.gstatic.com` or `unpkg.com`. The built-in CSP is updated. **`theme.accent` must be hex** (`#rgb`, `#rrggbb` or `#rrggbbaa`). Anything else, including a value without `#`, falls back to the preset's accent. **Plaintext passwords in `gallery.yaml`, `settings.yaml` or journal entries** are hashed the next time that file is saved in the admin; one save clears the doctor's warning. **New, optional settings:** `contact.*` (the form is off by default), `privacy.enabled`, `legal.heading`, `legal.contactUrl`, `legal.contactLabel`, and the `CDN_URL` and `CONTACT_NOTIFY_URL` environment variables. See `content/settings.yaml.example` and `.env.local.example`. In the example file, `vatId` and `taxId` had swapped sample values; check yours if you copied them. No `IMAGE_CACHE_VERSION` bump is needed this time. --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ralksta <ralksta@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Ralf <rohlenbo79@gmail.com> Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com> Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com> Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
This commit is contained in:
@@ -34,6 +34,14 @@ STALE_MAX_AGE=86400
|
|||||||
# refetch. Leave it unset in normal operation.
|
# refetch. Leave it unset in normal operation.
|
||||||
# IMAGE_CACHE_VERSION=1
|
# IMAGE_CACHE_VERSION=1
|
||||||
|
|
||||||
|
# CDN mode: serve photos and videos through a pull CDN (default: off).
|
||||||
|
# Point a CDN (Cloudflare, Bunny, CloudFront, ...) at this server as its origin,
|
||||||
|
# cache /api/image/* and /api/video/* including the query string, and put its
|
||||||
|
# URL here. Pages, EXIF, the map and downloads keep coming from this server.
|
||||||
|
# Ignored while a site password is set, so the password keeps covering photos.
|
||||||
|
# Also set TRUSTED_PROXY_HOPS to count the CDN, see docs/deployment.md#cdn-mode.
|
||||||
|
# CDN_URL=https://cdn.example.com
|
||||||
|
|
||||||
# How long to wait for a response from Immich, in milliseconds
|
# How long to wait for a response from Immich, in milliseconds
|
||||||
# (integer, minimum 1000, default: 15000 = 15 seconds).
|
# (integer, minimum 1000, default: 15000 = 15 seconds).
|
||||||
# Bounds the wait for a *response* only — downloading a large photo or a long
|
# Bounds the wait for a *response* only — downloading a large photo or a long
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -68,6 +68,7 @@ docker-compose.override.yml
|
|||||||
content/settings.yaml
|
content/settings.yaml
|
||||||
content/gallery.yaml
|
content/gallery.yaml
|
||||||
content/about.md
|
content/about.md
|
||||||
|
content/privacy.md
|
||||||
# Journal entries reference Immich asset UUIDs, which are specific to one
|
# Journal entries reference Immich asset UUIDs, which are specific to one
|
||||||
# server — committing an entry would publish ids from the author's instance.
|
# server — committing an entry would publish ids from the author's instance.
|
||||||
# The .example alongside them stays tracked as a starting point.
|
# The .example alongside them stays tracked as a starting point.
|
||||||
@@ -86,6 +87,10 @@ content/*.screenshot-bak
|
|||||||
content/install.json
|
content/install.json
|
||||||
# One-time setup token, written on first access and removed once install completes
|
# One-time setup token, written on first access and removed once install completes
|
||||||
content/.setup-token
|
content/.setup-token
|
||||||
|
# Contact form messages (lib/contact.ts), personal data of visitors
|
||||||
|
content/messages/
|
||||||
|
# Google Fonts cached by /api/fonts (lib/fonts.ts)
|
||||||
|
content/.fonts/
|
||||||
|
|
||||||
# ── Internal Dev Docs ────────────────────────────────
|
# ── Internal Dev Docs ────────────────────────────────
|
||||||
docs/dev/
|
docs/dev/
|
||||||
|
|||||||
267
CHANGELOG.md
267
CHANGELOG.md
@@ -7,6 +7,273 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
Releases up to and including v0.9.2 are documented in the
|
Releases up to and including v0.9.2 are documented in the
|
||||||
[GitHub releases](https://github.com/ralksta/immich-folio/releases).
|
[GitHub releases](https://github.com/ralksta/immich-folio/releases).
|
||||||
|
|
||||||
|
## [0.18.0] — 2026-09-27
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **Journal drafts and password-protected entries no longer render publicly
|
||||||
|
as a subpage essay**
|
||||||
|
([#684](https://github.com/ralksta/immich-folio/pull/684),
|
||||||
|
[GHSA-987w-cpwm-c9g9](https://github.com/ralksta/immich-folio/security/advisories/GHSA-987w-cpwm-c9g9),
|
||||||
|
medium). A subpage's `essayFile` read the journal entry straight from disk,
|
||||||
|
skipping the draft and password rules `/journal/<slug>` applies. It now
|
||||||
|
resolves through the same rules: a draft reads as missing for visitors, and
|
||||||
|
a protected entry shows its password gate, sharing the
|
||||||
|
`lb_auth_journal_<slug>` cookie.
|
||||||
|
|
||||||
|
- **Immich descriptions are escaped in generated subpage essays**
|
||||||
|
([#684](https://github.com/ralksta/immich-folio/pull/684),
|
||||||
|
[GHSA-8ppx-39jv-q5xm](https://github.com/ralksta/immich-folio/security/advisories/GHSA-8ppx-39jv-q5xm),
|
||||||
|
low). The essay built from a subpage's albums (`layout: essay` without
|
||||||
|
markdown) put each asset's Immich description into the page as raw HTML.
|
||||||
|
Captions are now HTML-escaped, and they go through `assetCaption()`, so
|
||||||
|
`exif.caption: false` is respected there as well. Tracked in
|
||||||
|
[#689](https://github.com/ralksta/immich-folio/issues/689).
|
||||||
|
|
||||||
|
- **Admin drafts no longer keep a typed password, and the contact form's
|
||||||
|
email check runs in linear time**
|
||||||
|
([#727](https://github.com/ralksta/immich-folio/pull/727)). The admin's
|
||||||
|
session drafts wrote a password as typed to `sessionStorage`; a draft
|
||||||
|
holding a plaintext password is now not kept (the unsaved-changes guard
|
||||||
|
still warns). The email pattern could backtrack polynomially on a crafted
|
||||||
|
domain; its labels now exclude the dot and the length is checked first.
|
||||||
|
Both found by CodeQL before release.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **A built-in contact form with an inbox in the admin**
|
||||||
|
([#703](https://github.com/ralksta/immich-folio/pull/703), closes
|
||||||
|
[#702](https://github.com/ralksta/immich-folio/issues/702)). `/contact`
|
||||||
|
needs neither SMTP nor a form service: each message is one file in
|
||||||
|
`content/messages/` (mode `0600`), read under the new _Messages_ tab, where
|
||||||
|
_Reply by email_ opens the owner's mail client with the message quoted.
|
||||||
|
Messages older than `contact.retentionDays` (default 90) are deleted. An
|
||||||
|
optional ntfy push goes to `contact.notifyUrl` or `CONTACT_NOTIFY_URL`, and
|
||||||
|
since [#709](https://github.com/ralksta/immich-folio/pull/709) it carries a
|
||||||
|
fixed "new message" line and nothing about the sender — no name, no
|
||||||
|
address, no text. Spam is kept out without a captcha: an off-screen
|
||||||
|
honeypot, a minimum fill time, three submissions a minute per IP and a cap
|
||||||
|
of 500 stored messages. With the Impressum on and no `legal.contactUrl`,
|
||||||
|
the Impressum links the form as its second contact channel. A new doctor
|
||||||
|
check warns when the form is on but nobody is notified.
|
||||||
|
|
||||||
|
- **A privacy policy page, next to a list of what the site processes**
|
||||||
|
([#708](https://github.com/ralksta/immich-folio/pull/708)). `/privacy`
|
||||||
|
renders `content/privacy.md`, edited under Settings → Legal, and the footer
|
||||||
|
links it once the file has text; `privacy.enabled: false` hides it. Folio
|
||||||
|
writes no legal text. Beside the editor it lists what _this_ installation
|
||||||
|
does, read off the configuration — the map, statistics, contact form,
|
||||||
|
password cookies, CDN — and marks every entry that involves a third party.
|
||||||
|
_Insert headings_ adds the matching section headings and nothing else. The
|
||||||
|
doctor warns while the Impressum is on and there is no policy.
|
||||||
|
|
||||||
|
- **French, Spanish, Italian and Dutch interface translations**
|
||||||
|
([#681](https://github.com/ralksta/immich-folio/pull/681)), selected with
|
||||||
|
`settings.yaml: lang` as before. The admin panel stays English.
|
||||||
|
|
||||||
|
- **CDN mode for photos and videos**
|
||||||
|
([#681](https://github.com/ralksta/immich-folio/pull/681)). With `CDN_URL`
|
||||||
|
set, image and video URLs point at a pull CDN whose origin is this server,
|
||||||
|
so repeat views reach neither the server nor Immich. A site password
|
||||||
|
switches the mode off, since a CDN would hand out cached photos without
|
||||||
|
checking the unlock cookie. The CDN origin is added to the CSP, and a
|
||||||
|
doctor check covers the password and `TRUSTED_PROXY_HOPS` cases. See
|
||||||
|
`docs/deployment.md#cdn-mode`.
|
||||||
|
|
||||||
|
- **A reworked admin panel**
|
||||||
|
([#712](https://github.com/ralksta/immich-folio/pull/712),
|
||||||
|
[#713](https://github.com/ralksta/immich-folio/pull/713),
|
||||||
|
[#714](https://github.com/ralksta/immich-folio/pull/714),
|
||||||
|
[#716](https://github.com/ralksta/immich-folio/pull/716),
|
||||||
|
[#717](https://github.com/ralksta/immich-folio/pull/717),
|
||||||
|
[#718](https://github.com/ralksta/immich-folio/pull/718),
|
||||||
|
[#719](https://github.com/ralksta/immich-folio/pull/719),
|
||||||
|
[#721](https://github.com/ralksta/immich-folio/pull/721)):
|
||||||
|
- **A sidebar instead of the header row**, grouped by task, with an unread
|
||||||
|
count on Messages and a dot on Diagnostics while Immich is down. It
|
||||||
|
collapses to an icon rail, with names shown on hover and focus, and
|
||||||
|
remembers that per browser.
|
||||||
|
- **`/admin` is an overview**: what needs attention (unread messages,
|
||||||
|
doctor warnings), published pages, journal entries and recent views.
|
||||||
|
- **The site's own visual language** — the preset's fonts, hairline
|
||||||
|
borders, the square marker — and one page header on every screen. The
|
||||||
|
accent is kept for the current place and the primary action; switches
|
||||||
|
are now pills reading ON or OFF, in green rather than the accent, so
|
||||||
|
Studio Modern's red no longer makes an enabled setting look like an
|
||||||
|
error.
|
||||||
|
- **The page builder is a structure list and a panel.** Hero, subpages and
|
||||||
|
standalone albums sit in one list on the left, subpages drag-sorted in
|
||||||
|
place; the selected entry is edited on the right instead of in an
|
||||||
|
overlay.
|
||||||
|
- **Dialogs only where a change is permanent.** Deleting a message or a
|
||||||
|
journal entry, and resetting the favicon (which used to delete the file
|
||||||
|
without asking), go through one confirm dialog in the admin's own look.
|
||||||
|
Removals in the page builder, which only touch unsaved state, happen at
|
||||||
|
once and offer _Undo_. No `window.confirm()` is left.
|
||||||
|
- **Feature switches are grouped by meaning** as one-line rows under
|
||||||
|
Settings → General; page transitions and scroll-to-top moved to Theme →
|
||||||
|
Look & motion. No setting keys changed.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Fonts and the Leaflet stylesheet are served from the site itself**
|
||||||
|
([#700](https://github.com/ralksta/immich-folio/pull/700)). Every page used
|
||||||
|
to link `fonts.googleapis.com`, which sends each visitor's IP address to
|
||||||
|
Google. The server now fetches a theme's Google fonts once, rewrites the
|
||||||
|
stylesheet and caches the files in `content/.fonts/`; custom fonts in
|
||||||
|
`theme.fonts` keep working. If Google cannot be reached before a font is
|
||||||
|
cached, the page falls back to the theme's system fonts. `leaflet.css`
|
||||||
|
comes from the npm package instead of unpkg. The CSP drops
|
||||||
|
`fonts.googleapis.com`, `fonts.gstatic.com` and `unpkg.com`.
|
||||||
|
|
||||||
|
- **Email addresses are kept out of the served HTML**
|
||||||
|
([#705](https://github.com/ralksta/immich-folio/pull/705)). The Impressum
|
||||||
|
and footer render the address only after hydration, from an encoded form,
|
||||||
|
so harvesters reading raw HTML find nothing. Without JavaScript the
|
||||||
|
Impressum shows `name [at] example [dot] de`. This defeats bulk harvesting,
|
||||||
|
not a person with a browser.
|
||||||
|
|
||||||
|
- **The Impressum cites § 5 DDG and links its contacts**
|
||||||
|
([#698](https://github.com/ralksta/immich-folio/pull/698), reported in
|
||||||
|
[#697](https://github.com/ralksta/immich-folio/issues/697) by
|
||||||
|
[@RichKidsDev](https://github.com/RichKidsDev)). The TMG was replaced by the
|
||||||
|
DDG in May 2024; `legal.heading` replaces the line for other laws (e.g.
|
||||||
|
§ 5 ECG in Austria). `legal.contactUrl` and `legal.contactLabel` add a
|
||||||
|
contact form as the second channel, so a phone number stays optional.
|
||||||
|
Email, phone and contact URL are now links, and the "Created with the
|
||||||
|
Impressum generator by eRecht24" line, shown on every site whether true or
|
||||||
|
not, is gone. Settings → Legal gains fields for heading, contact URL, VAT
|
||||||
|
ID and tax number, and a doctor check flags missing details.
|
||||||
|
|
||||||
|
- **Missing pages answer `404`**
|
||||||
|
([#704](https://github.com/ralksta/immich-folio/pull/704)). Every missing
|
||||||
|
page used to answer `200`, because streaming had started before
|
||||||
|
`notFound()` ran. `/contact`, `/impressum`, `/map` and `/privacy` while
|
||||||
|
switched off, and `/journal/<slug>` without an entry, now return a real
|
||||||
|
404 with the same not-found page. Album and subpage slugs still depend on
|
||||||
|
Immich and stay soft 404s.
|
||||||
|
|
||||||
|
- **Passwords set in the admin are stored as scrypt hashes**
|
||||||
|
([#706](https://github.com/ralksta/immich-folio/pull/706), closes
|
||||||
|
[#690](https://github.com/ralksta/immich-folio/issues/690)). Subpage,
|
||||||
|
album, journal-entry and site passwords were written as typed. The save
|
||||||
|
routes now hash them; a stored hash reads _Protected_ with _Change_ and
|
||||||
|
_Remove_ and is never put into an input. Visitors who unlocked a page stay
|
||||||
|
signed in across unrelated saves. Existing plaintext passwords are hashed
|
||||||
|
the next time their file is saved in the admin.
|
||||||
|
|
||||||
|
- **One notification surface in the admin**
|
||||||
|
([#707](https://github.com/ralksta/immich-folio/pull/707)) replaces every
|
||||||
|
`alert()`: errors stay until dismissed, successes clear themselves, and
|
||||||
|
screen readers announce both.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Map tiles load again**
|
||||||
|
([#701](https://github.com/ralksta/immich-folio/pull/701)). CARTO started
|
||||||
|
answering every tile with an "API KEY REQUIRED" image, leaving `/map` and
|
||||||
|
journal map blocks as a grey grid. Tiles now come from OpenStreetMap; the
|
||||||
|
dark look is a CSS filter, so the map now follows the colour mode.
|
||||||
|
|
||||||
|
- **The page builder no longer drops a subpage's `proofing:` flag on save**
|
||||||
|
([#711](https://github.com/ralksta/immich-folio/pull/711)). A page switched
|
||||||
|
off for proofing by hand reverted after any unrelated edit.
|
||||||
|
|
||||||
|
- **Unsaved edits in Settings and About survive switching admin tabs, and a
|
||||||
|
backup restore is no longer overwritten by the next save**
|
||||||
|
([#687](https://github.com/ralksta/immich-folio/pull/687)). Editors that
|
||||||
|
were open during a restore now reload and offer unsaved edits back as a
|
||||||
|
conflict. Also: "Restore my changes" after a conflict found nothing,
|
||||||
|
removing an album left its drawer showing the next album, and the journal
|
||||||
|
saved (rotating a backup) without changes.
|
||||||
|
|
||||||
|
- **Accent-backed buttons stay readable with a light or malformed accent**
|
||||||
|
([#724](https://github.com/ralksta/immich-folio/pull/724), supersedes
|
||||||
|
[#680](https://github.com/ralksta/immich-folio/pull/680)) — contributed by
|
||||||
|
[@lancetm714](https://github.com/lancetm714). A white accent made the
|
||||||
|
proofing dialog's _Download selected (.zip)_ button white on white.
|
||||||
|
`theme.accent` is now accepted as hex only and normalised to `#rrggbb`,
|
||||||
|
anything else falls back to the preset, and the proofing dialog and filter
|
||||||
|
pills use the mode-aware `--on-accent` text colour from #688.
|
||||||
|
|
||||||
|
- **Every preset's accent is visible in both colour modes**
|
||||||
|
([#688](https://github.com/ralksta/immich-folio/pull/688)). Presets had one
|
||||||
|
accent for both modes; minimal's dark mode drew black buttons on black.
|
||||||
|
Presets now carry a light and a dark accent, and text on accent-filled
|
||||||
|
buttons picks black or white for contrast. A custom accent stays the
|
||||||
|
owner's choice in both modes.
|
||||||
|
|
||||||
|
- **Visitor-side inconsistencies**
|
||||||
|
([#685](https://github.com/ralksta/immich-folio/pull/685)): after visiting
|
||||||
|
`/impressum`, every subpage header turned centred and uppercase; back links
|
||||||
|
announced "Back to Back to …"; the hero EXIF chip ignored
|
||||||
|
`exif.location`/`camera`/`settings`; videos in journal entries opened as
|
||||||
|
stills; the setup screen named a non-existent `.env.example`.
|
||||||
|
|
||||||
|
- **An essay's lightbox walks only the photos the story shows**
|
||||||
|
([#686](https://github.com/ralksta/immich-folio/pull/686)), not every photo
|
||||||
|
of the subpage's albums, and the proofing bar in essays shows the selection
|
||||||
|
count instead of a filter button that did nothing.
|
||||||
|
|
||||||
|
- **The page panel scrolls with the mouse wheel**
|
||||||
|
([#720](https://github.com/ralksta/immich-folio/pull/720)).
|
||||||
|
|
||||||
|
- **The journal editor's top bar wraps on phones**
|
||||||
|
([#723](https://github.com/ralksta/immich-folio/pull/723), closes
|
||||||
|
[#675](https://github.com/ralksta/immich-folio/issues/675)), so the title
|
||||||
|
stays editable and Save stays on screen.
|
||||||
|
|
||||||
|
- **Every admin form field has a name for assistive technology**
|
||||||
|
([#715](https://github.com/ralksta/immich-folio/pull/715)). Only two inputs
|
||||||
|
had a linked label; 47 are linked now, and clicking a label focuses its
|
||||||
|
field.
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
|
||||||
|
- **`SettingsEditor` and `PageBuilder` are split up**
|
||||||
|
([#710](https://github.com/ralksta/immich-folio/pull/710),
|
||||||
|
[#711](https://github.com/ralksta/immich-folio/pull/711)). The 2,200-line
|
||||||
|
settings editor is a shell with one file per section; the page builder's
|
||||||
|
YAML handling and edits are pure functions under test. Both were checked
|
||||||
|
side by side against `dev` for identical output.
|
||||||
|
- **`useAdminFetch()`** ([#707](https://github.com/ralksta/immich-folio/pull/707))
|
||||||
|
gives Analytics, Messages, Backups and the journal list one loading and
|
||||||
|
error pattern.
|
||||||
|
- README brought up to v0.17.0
|
||||||
|
([#678](https://github.com/ralksta/immich-folio/pull/678),
|
||||||
|
[#679](https://github.com/ralksta/immich-folio/pull/679)).
|
||||||
|
|
||||||
|
### Upgrade notes
|
||||||
|
|
||||||
|
**`content/` gains two directories the app writes to:** `content/.fonts/`
|
||||||
|
for the cached fonts and `content/messages/` for contact-form messages. Both
|
||||||
|
are created on demand, so a writable `content/` is enough, as before.
|
||||||
|
|
||||||
|
**The server needs outbound HTTPS to Google once per font** to fill the font
|
||||||
|
cache. Until then pages fall back to the theme's system fonts. Visitors'
|
||||||
|
browsers no longer contact Google.
|
||||||
|
|
||||||
|
**A custom CSP or reverse-proxy allowlist** must now allow
|
||||||
|
`tile.openstreetmap.org` for images instead of `*.basemaps.cartocdn.com`,
|
||||||
|
and no longer needs `fonts.googleapis.com`, `fonts.gstatic.com` or
|
||||||
|
`unpkg.com`. The built-in CSP is updated.
|
||||||
|
|
||||||
|
**`theme.accent` must be hex** (`#rgb`, `#rrggbb` or `#rrggbbaa`). Anything
|
||||||
|
else, including a value without `#`, falls back to the preset's accent.
|
||||||
|
|
||||||
|
**Plaintext passwords in `gallery.yaml`, `settings.yaml` or journal entries**
|
||||||
|
are hashed the next time that file is saved in the admin; one save clears the
|
||||||
|
doctor's warning.
|
||||||
|
|
||||||
|
**New, optional settings:** `contact.*` (the form is off by default),
|
||||||
|
`privacy.enabled`, `legal.heading`, `legal.contactUrl`, `legal.contactLabel`,
|
||||||
|
and the `CDN_URL` and `CONTACT_NOTIFY_URL` environment variables. See
|
||||||
|
`content/settings.yaml.example` and `.env.local.example`. In the example file,
|
||||||
|
`vatId` and `taxId` had swapped sample values; check yours if you copied
|
||||||
|
them.
|
||||||
|
|
||||||
|
No `IMAGE_CACHE_VERSION` bump is needed this time.
|
||||||
|
|
||||||
## [0.17.0] — 2026-09-23
|
## [0.17.0] — 2026-09-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
29
README.md
29
README.md
@@ -13,6 +13,8 @@
|
|||||||
|
|
||||||
A self-hosted portfolio powered by [Immich](https://immich.app). It acts as a **secure reverse proxy** between your visitors and your private Immich instance: your Immich server stays on your local network, completely invisible to the outside world, while your albums are published as a gallery you control.
|
A self-hosted portfolio powered by [Immich](https://immich.app). It acts as a **secure reverse proxy** between your visitors and your private Immich instance: your Immich server stays on your local network, completely invisible to the outside world, while your albums are published as a gallery you control.
|
||||||
|
|
||||||
|
**Latest: v0.18.0** — built-in contact form, privacy page and a reworked admin panel. → [What's New](#whats-new)
|
||||||
|
|
||||||
## Contents
|
## Contents
|
||||||
|
|
||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
@@ -67,6 +69,7 @@ A self-hosted portfolio powered by [Immich](https://immich.app). It acts as a **
|
|||||||
- **Client proofing** — clients favorite photos and export the selection; picks are encoded in the URL, nothing is stored server-side
|
- **Client proofing** — clients favorite photos and export the selection; picks are encoded in the URL, nothing is stored server-side
|
||||||
- **Originals delivery** — per-album opt-in lets visitors download a whole album or their proofing selection as a ZIP of the originals
|
- **Originals delivery** — per-album opt-in lets visitors download a whole album or their proofing selection as a ZIP of the originals
|
||||||
- **Lightbox watermark** — configurable overlay on fullscreen images
|
- **Lightbox watermark** — configurable overlay on fullscreen images
|
||||||
|
- **Six interface languages** — English, German, French, Spanish, Italian and Dutch for everything visitors see
|
||||||
- **Privacy-friendly analytics** — cookieless view counts, no third parties, can be switched off
|
- **Privacy-friendly analytics** — cookieless view counts, no third parties, can be switched off
|
||||||
- **Dynamic OG images** — auto-generated social preview images per album
|
- **Dynamic OG images** — auto-generated social preview images per album
|
||||||
|
|
||||||
@@ -114,6 +117,7 @@ A self-hosted portfolio powered by [Immich](https://immich.app). It acts as a **
|
|||||||
|
|
||||||
- Health check endpoint at `GET /api/health`
|
- Health check endpoint at `GET /api/health`
|
||||||
- In-memory caching with configurable TTL
|
- In-memory caching with configurable TTL
|
||||||
|
- Optional CDN mode — photos and videos served through a pull CDN, so repeat views never reach your server ([Deployment Guide](docs/deployment.md#cdn-mode))
|
||||||
- Standalone Docker image — multi-stage, non-root, ~150 MB
|
- Standalone Docker image — multi-stage, non-root, ~150 MB
|
||||||
- Dependencies kept current via Dependabot (npm + GitHub Actions, weekly)
|
- Dependencies kept current via Dependabot (npm + GitHub Actions, weekly)
|
||||||
|
|
||||||
@@ -219,6 +223,8 @@ AUTH_SECRET=long-random-string # required in production
|
|||||||
TRUSTED_PROXY_HOPS=1 # reverse proxies in front, default: 0
|
TRUSTED_PROXY_HOPS=1 # reverse proxies in front, default: 0
|
||||||
ADMIN_PASSWORD=your-secure-password # enables /admin panel
|
ADMIN_PASSWORD=your-secure-password # enables /admin panel
|
||||||
WEBHOOK_SECRET=long-random-string # enables POST /api/webhook cache invalidation
|
WEBHOOK_SECRET=long-random-string # enables POST /api/webhook cache invalidation
|
||||||
|
CDN_URL=https://cdn.example.com # serve photos and videos through a pull CDN
|
||||||
|
CONTACT_NOTIFY_URL=https://ntfy.sh/x # push for new contact form messages, overrides settings.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
> Login and setup endpoints have their own, much lower limits that `RATE_LIMIT_RPM` does not raise — see [Rate Limiting](docs/gallery-config.md#rate-limiting).
|
> Login and setup endpoints have their own, much lower limits that `RATE_LIMIT_RPM` does not raise — see [Rate Limiting](docs/gallery-config.md#rate-limiting).
|
||||||
@@ -336,15 +342,15 @@ own password, separate from any album passwords, and writes straight to
|
|||||||
|
|
||||||
## What's New
|
## What's New
|
||||||
|
|
||||||
v0.17.0 — originals as a ZIP, and a richer journal:
|
v0.18.0 — a contact form, a privacy page and a reworked admin:
|
||||||
|
|
||||||
- **Download originals as a ZIP** — a whole album, or just the photos a client picked in proofing, as one archive of the originals. Opt-in per album; contributed by [@lancetm714](https://github.com/lancetm714)
|
- **Built-in contact form** — no SMTP or form service: messages land in a new admin inbox, with an optional ntfy push that carries nothing about the sender
|
||||||
- **Journal templates** — start a new entry from eight structures, from a wedding to a hiking trip
|
- **Privacy page and fewer third parties** — `/privacy` with a list of what your installation actually processes; fonts and map styles are served from your own server, and email addresses stay out of the page source
|
||||||
- **New journal blocks** — photo grids, facts lists, a map with the pins you choose, and a slice of an album; reorder blocks by dragging → [Journal guide](docs/journal.md)
|
- **A reworked admin panel** — a collapsible sidebar, an overview page, the page builder as a structure list with Undo, and ON/OFF switches grouped by meaning
|
||||||
- **Unsaved admin edits survive navigation** — switching tabs or reloading no longer throws your changes away
|
- **Four more languages and a CDN mode** — French, Spanish, Italian and Dutch, and `CDN_URL` to serve photos through a pull CDN
|
||||||
- **Plain-HTTP installs** — admin login and gallery passwords now work at `http://host:7211`
|
- **Two security fixes** — draft and password-protected journal entries used as subpage essays, and unescaped Immich descriptions ([advisories](https://github.com/ralksta/immich-folio/security/advisories))
|
||||||
|
|
||||||
**Upgrade note:** visitors' proofing favourites from before v0.17.0 are not carried over. Nothing else to migrate.
|
**Upgrade note:** a custom CSP or proxy allowlist must now allow `tile.openstreetmap.org` for map tiles, and `theme.accent` must be a hex colour. Nothing else to migrate.
|
||||||
|
|
||||||
Security fixes ship in normal releases, so **running the latest release is the
|
Security fixes ship in normal releases, so **running the latest release is the
|
||||||
recommended baseline**.
|
recommended baseline**.
|
||||||
@@ -373,7 +379,14 @@ easier to live with.
|
|||||||
readable in light mode. Also brought links to journal quote attributions. In
|
readable in light mode. Also brought links to journal quote attributions. In
|
||||||
v0.17.0, built the ZIP download: a whole album, or just the photos a client
|
v0.17.0, built the ZIP download: a whole album, or just the photos a client
|
||||||
picked, as one archive of the originals — streamed, so even a large shoot
|
picked, as one archive of the originals — streamed, so even a large shoot
|
||||||
arrives complete.
|
arrives complete. In v0.18.0, kept accent-coloured buttons readable when the
|
||||||
|
accent is light, and made a mistyped accent fall back to the theme instead of
|
||||||
|
turning every button invisible.
|
||||||
|
- **[@RichKidsDev](https://github.com/RichKidsDev)** — noticed that the
|
||||||
|
Impressum still cited the TMG, which the DDG replaced in 2024. That report
|
||||||
|
turned into a proper overhaul in v0.18.0: the right law by default, a heading
|
||||||
|
you can change for other countries, clickable contacts and a contact form as
|
||||||
|
a second channel.
|
||||||
- **[@ImScheinox](https://github.com/ImScheinox)** — found and fixed portrait
|
- **[@ImScheinox](https://github.com/ImScheinox)** — found and fixed portrait
|
||||||
photos rendering as landscape tiles, because the grid ignored the camera's
|
photos rendering as landscape tiles, because the grid ignored the camera's
|
||||||
EXIF orientation flag. That brought the masonry layout's stagger back for
|
EXIF orientation flag. That brought the masonry layout's stagger back for
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { FadeIn } from '@/components/FadeIn';
|
|||||||
import { LeafletMap, escapeHtml } from '@/components/LeafletMap';
|
import { LeafletMap, escapeHtml } from '@/components/LeafletMap';
|
||||||
import type { ParsedEssay, EssayBlock } from '@/lib/essay';
|
import type { ParsedEssay, EssayBlock } from '@/lib/essay';
|
||||||
import { renderInlineMarkdown } from '@/lib/essay';
|
import { renderInlineMarkdown } from '@/lib/essay';
|
||||||
|
import { essayPhotoSequence } from '@/lib/essaySequence';
|
||||||
import type { PhotoItem } from './PhotoGrid';
|
import type { PhotoItem } from './PhotoGrid';
|
||||||
import './essay.css';
|
import './essay.css';
|
||||||
import { useDictionary } from '@/components/I18nProvider';
|
import { useDictionary } from '@/components/I18nProvider';
|
||||||
@@ -60,19 +61,35 @@ function EssayViewContent({
|
|||||||
? assetMap.get(essay.frontmatter.coverAssetId)
|
? assetMap.get(essay.frontmatter.coverAssetId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// The lightbox walks only the photos this essay shows, in reading order —
|
||||||
|
// not every photo of the subpage's albums (`assets`).
|
||||||
|
const sequence = useMemo(
|
||||||
|
() =>
|
||||||
|
essayPhotoSequence(
|
||||||
|
essay.blocks,
|
||||||
|
essay.frontmatter.coverAssetId,
|
||||||
|
(key) => assetMap.get(key)?.item,
|
||||||
|
),
|
||||||
|
[essay.blocks, essay.frontmatter.coverAssetId, assetMap],
|
||||||
|
);
|
||||||
|
const openPhoto = (item: PhotoItem) => {
|
||||||
|
const at = sequence.findIndex((p) => p.id === item.id);
|
||||||
|
if (at !== -1) setLightboxIndex(at);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One photo tile of a pair or a grid: sized by its real ratio (see
|
* One photo tile of a pair or a grid: sized by its real ratio (see
|
||||||
* .essay-pair-grid), opens the lightbox, carries the proofing heart. The pair
|
* .essay-pair-grid), opens the lightbox, carries the proofing heart. The pair
|
||||||
* used to spell this out a second time next to the photo case; a third copy
|
* used to spell this out a second time next to the photo case; a third copy
|
||||||
* for the grid is where that stops.
|
* for the grid is where that stops.
|
||||||
*/
|
*/
|
||||||
const renderTile = ({ item, index }: { item: PhotoItem; index: number }, key: number) => {
|
const renderTile = ({ item }: { item: PhotoItem }, key: number) => {
|
||||||
const isFav = proofing ? proofing.isFavorite(item.id) : false;
|
const isFav = proofing ? proofing.isFavorite(item.id) : false;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
className="essay-image-wrapper photo-grid__item"
|
className="essay-image-wrapper photo-grid__item"
|
||||||
onClick={() => setLightboxIndex(index)}
|
onClick={() => openPhoto(item)}
|
||||||
style={{
|
style={{
|
||||||
...(item.dominantColor ? { backgroundColor: item.dominantColor } : {}),
|
...(item.dominantColor ? { backgroundColor: item.dominantColor } : {}),
|
||||||
flexGrow: item.aspectRatio ?? 1.5,
|
flexGrow: item.aspectRatio ?? 1.5,
|
||||||
@@ -166,7 +183,7 @@ function EssayViewContent({
|
|||||||
case 'photo': {
|
case 'photo': {
|
||||||
const resolved = assetMap.get(block.assetId);
|
const resolved = assetMap.get(block.assetId);
|
||||||
if (!resolved) return null;
|
if (!resolved) return null;
|
||||||
const { item, index } = resolved;
|
const { item } = resolved;
|
||||||
const isFav = proofing ? proofing.isFavorite(item.id) : false;
|
const isFav = proofing ? proofing.isFavorite(item.id) : false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -174,7 +191,7 @@ function EssayViewContent({
|
|||||||
<figure className={`essay-figure essay-figure--${block.layout}`}>
|
<figure className={`essay-figure essay-figure--${block.layout}`}>
|
||||||
<div
|
<div
|
||||||
className="essay-image-wrapper photo-grid__item"
|
className="essay-image-wrapper photo-grid__item"
|
||||||
onClick={() => setLightboxIndex(index)}
|
onClick={() => openPhoto(item)}
|
||||||
style={{
|
style={{
|
||||||
...(item.dominantColor ? { backgroundColor: item.dominantColor } : {}),
|
...(item.dominantColor ? { backgroundColor: item.dominantColor } : {}),
|
||||||
...(item.aspectRatio ? { aspectRatio: `${item.aspectRatio}` } : {}),
|
...(item.aspectRatio ? { aspectRatio: `${item.aspectRatio}` } : {}),
|
||||||
@@ -388,7 +405,7 @@ function EssayViewContent({
|
|||||||
<figure className="essay-figure essay-figure--wide" style={{ marginBottom: '4rem' }}>
|
<figure className="essay-figure essay-figure--wide" style={{ marginBottom: '4rem' }}>
|
||||||
<div
|
<div
|
||||||
className="essay-image-wrapper photo-grid__item"
|
className="essay-image-wrapper photo-grid__item"
|
||||||
onClick={() => setLightboxIndex(coverAsset.index)}
|
onClick={() => openPhoto(coverAsset.item)}
|
||||||
style={{
|
style={{
|
||||||
...(coverAsset.item.dominantColor
|
...(coverAsset.item.dominantColor
|
||||||
? { backgroundColor: coverAsset.item.dominantColor }
|
? { backgroundColor: coverAsset.item.dominantColor }
|
||||||
@@ -434,26 +451,11 @@ function EssayViewContent({
|
|||||||
backdropFilter: 'blur(8px)',
|
backdropFilter: 'blur(8px)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
{/* A count, not the album grid's filter toggle: a story's layout is
|
||||||
type="button"
|
authored, so there is nothing for "show favourites" to narrow. */}
|
||||||
onClick={() => proofing.setIsFilterActive((prev) => !prev)}
|
<span style={{ fontSize: '0.85rem', fontWeight: 500, padding: '6px 4px' }}>
|
||||||
style={{
|
{t.proofing.selected(proofing.favorites.size)}
|
||||||
background: proofing.isFilterActive
|
</span>
|
||||||
? 'var(--accent, #e60012)'
|
|
||||||
: 'var(--bg-card-hover)',
|
|
||||||
color: proofing.isFilterActive ? '#fff' : 'var(--text-primary)',
|
|
||||||
border: 'none',
|
|
||||||
padding: '6px 14px',
|
|
||||||
borderRadius: '20px',
|
|
||||||
fontSize: '0.85rem',
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{proofing.isFilterActive
|
|
||||||
? t.proofing.showAll
|
|
||||||
: t.proofing.selected(proofing.favorites.size)}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => proofing.setIsModalOpen(true)}
|
onClick={() => proofing.setIsModalOpen(true)}
|
||||||
@@ -476,15 +478,15 @@ function EssayViewContent({
|
|||||||
|
|
||||||
{lightboxIndex !== null && (
|
{lightboxIndex !== null && (
|
||||||
<Lightbox
|
<Lightbox
|
||||||
assets={assets}
|
assets={sequence}
|
||||||
currentIndex={lightboxIndex}
|
currentIndex={lightboxIndex}
|
||||||
onClose={() => setLightboxIndex(null)}
|
onClose={() => setLightboxIndex(null)}
|
||||||
onNext={() =>
|
onNext={() =>
|
||||||
setLightboxIndex((prev) => (prev !== null ? (prev + 1) % assets.length : null))
|
setLightboxIndex((prev) => (prev !== null ? (prev + 1) % sequence.length : null))
|
||||||
}
|
}
|
||||||
onPrev={() =>
|
onPrev={() =>
|
||||||
setLightboxIndex((prev) =>
|
setLightboxIndex((prev) =>
|
||||||
prev !== null ? (prev - 1 + assets.length) % assets.length : null,
|
prev !== null ? (prev - 1 + sequence.length) % sequence.length : null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
watermark={watermark}
|
watermark={watermark}
|
||||||
|
|||||||
@@ -318,7 +318,7 @@ function PhotoGridInner({
|
|||||||
background: proofing.isFilterActive
|
background: proofing.isFilterActive
|
||||||
? 'var(--accent, #e60012)'
|
? 'var(--accent, #e60012)'
|
||||||
: 'var(--bg-card-hover)',
|
: 'var(--bg-card-hover)',
|
||||||
color: proofing.isFilterActive ? '#fff' : 'var(--text-primary)',
|
color: proofing.isFilterActive ? 'var(--on-accent, #fff)' : 'var(--text-primary)',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
padding: '6px 14px',
|
padding: '6px 14px',
|
||||||
borderRadius: '20px',
|
borderRadius: '20px',
|
||||||
|
|||||||
@@ -45,12 +45,12 @@ import { albumStructuredData } from '@/lib/structuredData';
|
|||||||
import { absoluteUrl } from '@/lib/siteUrl';
|
import { absoluteUrl } from '@/lib/siteUrl';
|
||||||
import { SubpageGridView } from './SubpageGridView';
|
import { SubpageGridView } from './SubpageGridView';
|
||||||
import { EssayView } from './EssayView';
|
import { EssayView } from './EssayView';
|
||||||
import { parseEssayMarkdown, type EssayBlock } from '@/lib/essay';
|
import { parseEssayMarkdown, type EssayBlock, type ParsedEssay } from '@/lib/essay';
|
||||||
import { pinsForEntry } from '@/lib/journalMap';
|
import { pinsForEntry } from '@/lib/journalMap';
|
||||||
import { expandAlbumBlocks } from '@/lib/journalAlbum';
|
import { expandAlbumBlocks } from '@/lib/journalAlbum';
|
||||||
import { mapBlockAssetIds } from '@/lib/journal';
|
import { mapBlockAssetIds } from '@/lib/journal';
|
||||||
import { strictestPrecision, type LocationPrecision } from '@/lib/mapPrecision';
|
import { strictestPrecision, type LocationPrecision } from '@/lib/mapPrecision';
|
||||||
import { loadEssayFromFile } from '@/lib/admin/journal-service';
|
import { resolveEssayFile, generatedEssayCaption } from '@/lib/essaySource';
|
||||||
import { getServerDictionary } from '@/lib/i18n/server';
|
import { getServerDictionary } from '@/lib/i18n/server';
|
||||||
|
|
||||||
// Render at request time — requires live Immich connection
|
// Render at request time — requires live Immich connection
|
||||||
@@ -424,16 +424,25 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
|||||||
!!result.subpage.essayText;
|
!!result.subpage.essayText;
|
||||||
|
|
||||||
if (isEssay) {
|
if (isEssay) {
|
||||||
let essayParsed = result.subpage.essayText
|
const cookieStore = await cookies();
|
||||||
? parseEssayMarkdown(result.subpage.essayText)
|
let essayParsed: ParsedEssay | null = null;
|
||||||
: result.subpage.essayFile
|
if (result.subpage.essayText) {
|
||||||
? loadEssayFromFile(result.subpage.essayFile)
|
essayParsed = parseEssayMarkdown(result.subpage.essayText);
|
||||||
: null;
|
} else if (result.subpage.essayFile) {
|
||||||
|
// A borrowed journal entry keeps its own draft flag and password.
|
||||||
|
const file = resolveEssayFile(result.subpage.essayFile, {
|
||||||
|
isAdmin: await isAdminAuthenticated(),
|
||||||
|
getCookie: (name) => cookieStore.get(name)?.value,
|
||||||
|
});
|
||||||
|
if (file.status === 'locked') {
|
||||||
|
return <PasswordGate slug={result.subpage.essayFile} title={file.title} type="journal" />;
|
||||||
|
}
|
||||||
|
if (file.status === 'open') essayParsed = file.parsed;
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch assets from the subpage's albums. An essay has no per-album
|
// Fetch assets from the subpage's albums. An essay has no per-album
|
||||||
// gate to pass through, so an album with its own password stays out
|
// gate to pass through, so an album with its own password stays out
|
||||||
// until it has been unlocked.
|
// until it has been unlocked.
|
||||||
const cookieStore = await cookies();
|
|
||||||
const openAlbums = withoutLockedAlbums(albums, (name) => cookieStore.get(name)?.value);
|
const openAlbums = withoutLockedAlbums(albums, (name) => cookieStore.get(name)?.value);
|
||||||
const allAlbums = await Promise.all(
|
const allAlbums = await Promise.all(
|
||||||
openAlbums.map((a) => immich.getAlbumBySlug(a.slug, slug, forceFresh)),
|
openAlbums.map((a) => immich.getAlbumBySlug(a.slug, slug, forceFresh)),
|
||||||
@@ -494,7 +503,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
|||||||
...a.assets.map((asset) => ({
|
...a.assets.map((asset) => ({
|
||||||
type: 'photo' as const,
|
type: 'photo' as const,
|
||||||
assetId: encodeAssetId(asset.id),
|
assetId: encodeAssetId(asset.id),
|
||||||
caption: asset.exifInfo?.description || undefined,
|
caption: generatedEssayCaption(asset, config.exif.caption),
|
||||||
layout: 'contained' as const,
|
layout: 'contained' as const,
|
||||||
})),
|
})),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { useState, useEffect, type ReactNode } from 'react';
|
|||||||
import AdminLogin from './components/AdminLogin';
|
import AdminLogin from './components/AdminLogin';
|
||||||
import AdminDashboard from './components/AdminDashboard';
|
import AdminDashboard from './components/AdminDashboard';
|
||||||
import { SESSION_EXPIRED_EVENT } from './components/sessionExpiry';
|
import { SESSION_EXPIRED_EVENT } from './components/sessionExpiry';
|
||||||
|
import { NotificationProvider } from './components/Notifications';
|
||||||
import './admin.css';
|
import './admin.css';
|
||||||
|
import { ConfirmProvider } from './components/ConfirmDialog';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auth gate and panel chrome for every /admin route. Lives in the admin layout
|
* Auth gate and panel chrome for every /admin route. Lives in the admin layout
|
||||||
@@ -79,5 +81,11 @@ export default function AdminShell({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <AdminDashboard onLogout={() => setAuthenticated(false)}>{children}</AdminDashboard>;
|
return (
|
||||||
|
<NotificationProvider>
|
||||||
|
<ConfirmProvider>
|
||||||
|
<AdminDashboard onLogout={() => setAuthenticated(false)}>{children}</AdminDashboard>
|
||||||
|
</ConfirmProvider>
|
||||||
|
</NotificationProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
62
app/admin/__tests__/ConfirmDialog.test.tsx
Normal file
62
app/admin/__tests__/ConfirmDialog.test.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { ConfirmProvider, useConfirm } from '../components/ConfirmDialog';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
/** UX stage 3 (#694 §3): what replaced window.confirm in the admin. */
|
||||||
|
function Trigger({ onResult }: { onResult: (ok: boolean) => void }) {
|
||||||
|
const confirm = useConfirm();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={async () =>
|
||||||
|
onResult(
|
||||||
|
await confirm({
|
||||||
|
title: 'Delete it?',
|
||||||
|
message: 'Gone for good.',
|
||||||
|
confirmLabel: 'Delete',
|
||||||
|
danger: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
go
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const results: boolean[] = [];
|
||||||
|
render(
|
||||||
|
<ConfirmProvider>
|
||||||
|
<Trigger onResult={(ok) => results.push(ok)} />
|
||||||
|
</ConfirmProvider>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText('go'));
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ConfirmDialog', () => {
|
||||||
|
it('asks in an alertdialog named by its title', () => {
|
||||||
|
setup();
|
||||||
|
const dialog = screen.getByRole('alertdialog', { name: 'Delete it?' });
|
||||||
|
expect(dialog.textContent).toContain('Gone for good.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves true on confirm and closes', async () => {
|
||||||
|
const results = setup();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
|
||||||
|
await waitFor(() => expect(results).toEqual([true]));
|
||||||
|
expect(screen.queryByRole('alertdialog')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves false on cancel, and on Escape', async () => {
|
||||||
|
const results = setup();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||||
|
await waitFor(() => expect(results).toEqual([false]));
|
||||||
|
fireEvent.click(screen.getByText('go'));
|
||||||
|
fireEvent.keyDown(document, { key: 'Escape' });
|
||||||
|
await waitFor(() => expect(results).toEqual([false, false]));
|
||||||
|
});
|
||||||
|
});
|
||||||
74
app/admin/__tests__/Notifications.test.tsx
Normal file
74
app/admin/__tests__/Notifications.test.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||||
|
import { render, screen, cleanup, fireEvent, act } from '@testing-library/react';
|
||||||
|
import { NotificationProvider, useNotify } from '../components/Notifications';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
function Trigger({ kind, message }: { kind: 'success' | 'error'; message: string }) {
|
||||||
|
const notify = useNotify();
|
||||||
|
return <button onClick={() => notify(kind, message)}>go</button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** #600: what replaced alert() in the admin. */
|
||||||
|
describe('admin notifications', () => {
|
||||||
|
it('announces an error assertively and keeps it until dismissed', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
render(
|
||||||
|
<NotificationProvider>
|
||||||
|
<Trigger kind="error" message="Save failed" />
|
||||||
|
</NotificationProvider>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText('go'));
|
||||||
|
|
||||||
|
expect(screen.getByRole('alert').textContent).toContain('Save failed');
|
||||||
|
act(() => vi.advanceTimersByTime(60_000));
|
||||||
|
expect(screen.queryByText('Save failed')).not.toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
|
||||||
|
expect(screen.queryByText('Save failed')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('announces a success politely and lets it go on its own', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
render(
|
||||||
|
<NotificationProvider>
|
||||||
|
<Trigger kind="success" message="Saved" />
|
||||||
|
</NotificationProvider>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText('go'));
|
||||||
|
|
||||||
|
expect(screen.getByRole('status').textContent).toContain('Saved');
|
||||||
|
act(() => vi.advanceTimersByTime(5000));
|
||||||
|
expect(screen.queryByText('Saved')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing outside the provider instead of throwing', () => {
|
||||||
|
render(<Trigger kind="error" message="x" />);
|
||||||
|
expect(() => fireEvent.click(screen.getByText('go'))).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs an action once and closes the notification', () => {
|
||||||
|
let runs = 0;
|
||||||
|
function WithAction() {
|
||||||
|
const notify = useNotify();
|
||||||
|
return (
|
||||||
|
<button onClick={() => notify('success', 'Removed', { label: 'Undo', run: () => runs++ })}>
|
||||||
|
go
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
render(
|
||||||
|
<NotificationProvider>
|
||||||
|
<WithAction />
|
||||||
|
</NotificationProvider>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText('go'));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
|
||||||
|
expect(runs).toBe(1);
|
||||||
|
expect(screen.queryByText('Removed')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
|
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
|
||||||
import SaveBar from '../components/SaveBar';
|
import SaveBar, { type SaveStatus } from '../components/SaveBar';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The first component test in the project, and deliberately a small one: it is
|
* The first component test in the project, and deliberately a small one: it is
|
||||||
* here to show the shape (`// @vitest-environment jsdom` on the first line,
|
* here to show the shape (`// @vitest-environment jsdom` on the first line,
|
||||||
* render, assert, cleanup) as much as to cover SaveBar.
|
* render, assert, cleanup) as much as to cover SaveBar.
|
||||||
*
|
*
|
||||||
* SaveBar earns it though. It decides whether an outcome reads as success or
|
* SaveBar earns it though. It used to decide whether an outcome read as
|
||||||
* failure by testing whether the message happens to start with "Error" — #600
|
* success or failure by testing whether the message happened to start with
|
||||||
* replaces that with a typed state, and this pins the behaviour so the swap can
|
* "Error"; #600 replaced that with a typed state, pinned below.
|
||||||
* be verified rather than eyeballed.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
@@ -19,7 +18,7 @@ afterEach(cleanup);
|
|||||||
const props = {
|
const props = {
|
||||||
dirty: false,
|
dirty: false,
|
||||||
saving: false,
|
saving: false,
|
||||||
saveMessage: '',
|
status: null as SaveStatus,
|
||||||
onSave: () => {},
|
onSave: () => {},
|
||||||
label: 'Save',
|
label: 'Save',
|
||||||
};
|
};
|
||||||
@@ -40,7 +39,9 @@ describe('SaveBar', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('only offers saving when there is something to save', () => {
|
it('only offers saving when there is something to save', () => {
|
||||||
const { rerender } = render(<SaveBar {...props} saveMessage="Saved!" />);
|
const { rerender } = render(
|
||||||
|
<SaveBar {...props} status={{ kind: 'success', message: 'Saved!' }} />,
|
||||||
|
);
|
||||||
expect(screen.getByRole('button', { name: 'Save' })).toHaveProperty('disabled', true);
|
expect(screen.getByRole('button', { name: 'Save' })).toHaveProperty('disabled', true);
|
||||||
|
|
||||||
rerender(<SaveBar {...props} dirty />);
|
rerender(<SaveBar {...props} dirty />);
|
||||||
@@ -63,18 +64,21 @@ describe('SaveBar', () => {
|
|||||||
expect(onSave).toHaveBeenCalledOnce();
|
expect(onSave).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tells a failure from a success by the message prefix', () => {
|
it('styles the outcome by its kind, not by its wording', () => {
|
||||||
// Pinned as it is, not as it should be: the prefix check is the thing #600
|
// #600: the bar used to call a message an error when it started with
|
||||||
// replaces. A message that fails without starting with "Error" is styled as
|
// "Error", so this third message was shown as a success.
|
||||||
// a success today, which is precisely the argument for a typed state.
|
const { rerender } = render(
|
||||||
const { rerender } = render(<SaveBar {...props} saveMessage="Error: Failed to save" />);
|
<SaveBar {...props} status={{ kind: 'error', message: 'Error: Failed to save' }} />,
|
||||||
|
);
|
||||||
expect(screen.getByText('Error: Failed to save').className).toContain('error');
|
expect(screen.getByText('Error: Failed to save').className).toContain('error');
|
||||||
|
|
||||||
rerender(<SaveBar {...props} saveMessage="Saved!" />);
|
rerender(<SaveBar {...props} status={{ kind: 'success', message: 'Saved!' }} />);
|
||||||
expect(screen.getByText('Saved!').className).toContain('success');
|
expect(screen.getByText('Saved!').className).toContain('success');
|
||||||
|
|
||||||
rerender(<SaveBar {...props} saveMessage="Could not reach the server" />);
|
rerender(
|
||||||
expect(screen.getByText('Could not reach the server').className).toContain('success');
|
<SaveBar {...props} status={{ kind: 'error', message: 'Could not reach the server' }} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Could not reach the server').className).toContain('error');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('offers the preview link only where it belongs', () => {
|
it('offers the preview link only where it belongs', () => {
|
||||||
|
|||||||
35
app/admin/__tests__/contentRestored.test.tsx
Normal file
35
app/admin/__tests__/contentRestored.test.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { renderHook, act, cleanup } from '@testing-library/react';
|
||||||
|
import { reportContentRestored, useContentRestored } from '../components/contentRestored';
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
describe('useContentRestored', () => {
|
||||||
|
it('hands each restore to the mounted editors', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
renderHook(() => useContentRestored(handler));
|
||||||
|
act(() => reportContentRestored({ target: 'journal', slug: 'iceland' }));
|
||||||
|
expect(handler).toHaveBeenCalledWith({ target: 'journal', slug: 'iceland' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls the latest handler, not the one from the first render', () => {
|
||||||
|
const first = vi.fn();
|
||||||
|
const second = vi.fn();
|
||||||
|
const { rerender } = renderHook(({ fn }) => useContentRestored(fn), {
|
||||||
|
initialProps: { fn: first },
|
||||||
|
});
|
||||||
|
rerender({ fn: second });
|
||||||
|
act(() => reportContentRestored({ target: 'gallery' }));
|
||||||
|
expect(first).not.toHaveBeenCalled();
|
||||||
|
expect(second).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops listening once the editor unmounts', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const { unmount } = renderHook(() => useContentRestored(handler));
|
||||||
|
unmount();
|
||||||
|
act(() => reportContentRestored({ target: 'settings' }));
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
112
app/admin/__tests__/gallery-ops.test.ts
Normal file
112
app/admin/__tests__/gallery-ops.test.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import * as ops from '../components/page-builder/galleryOps';
|
||||||
|
import type { GalleryState } from '../components/page-builder/galleryYaml';
|
||||||
|
|
||||||
|
/** #608: the page builder's edits, out of the component and under test. */
|
||||||
|
|
||||||
|
const state = (): GalleryState => ({
|
||||||
|
hero: ['h1', 'h2', 'h3'],
|
||||||
|
albums: [{ id: 'a1' }, { id: 'a2' }],
|
||||||
|
subpages: [
|
||||||
|
{ name: 'Japan', albums: [{ id: 'j1' }, { id: 'j2' }] },
|
||||||
|
{
|
||||||
|
name: 'Trips',
|
||||||
|
enabled: false,
|
||||||
|
albums: [],
|
||||||
|
sections: [{ title: 'North', albums: [{ id: 'n1' }] }],
|
||||||
|
},
|
||||||
|
{ name: 'Korea', albums: [{ id: 'k1' }] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('album lists by address', () => {
|
||||||
|
it('adds to the standalone list, a subpage and a section', () => {
|
||||||
|
let g = ops.addAlbum(state(), { type: 'standalone' }, { id: 'x' });
|
||||||
|
expect(g.albums.map((a) => a.id)).toEqual(['a1', 'a2', 'x']);
|
||||||
|
g = ops.addAlbum(g, { type: 'subpage', subpageIndex: 0 }, { id: 'y' });
|
||||||
|
expect(g.subpages[0].albums.map((a) => a.id)).toEqual(['j1', 'j2', 'y']);
|
||||||
|
g = ops.addAlbum(g, { type: 'section', subpageIndex: 1, sectionIndex: 0 }, { id: 'z' });
|
||||||
|
expect(g.subpages[1].sections![0].albums.map((a) => a.id)).toEqual(['n1', 'z']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates, removes and moves one entry and leaves its neighbours alone', () => {
|
||||||
|
const at = { type: 'subpage' as const, subpageIndex: 0 };
|
||||||
|
let g = ops.updateAlbum(state(), at, 1, { title: 'Tokyo', sort: 'manual' });
|
||||||
|
expect(g.subpages[0].albums).toEqual([
|
||||||
|
{ id: 'j1' },
|
||||||
|
{ id: 'j2', title: 'Tokyo', sort: 'manual' },
|
||||||
|
]);
|
||||||
|
g = ops.moveAlbum(g, at, 1, 0);
|
||||||
|
expect(g.subpages[0].albums.map((a) => a.id)).toEqual(['j2', 'j1']);
|
||||||
|
g = ops.removeAlbum(g, at, 0);
|
||||||
|
expect(g.subpages[0].albums.map((a) => a.id)).toEqual(['j1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never mutates the state it was given', () => {
|
||||||
|
const before = state();
|
||||||
|
const copy = JSON.parse(JSON.stringify(before));
|
||||||
|
ops.removeAlbum(before, { type: 'section', subpageIndex: 1, sectionIndex: 0 }, 0);
|
||||||
|
ops.updateSubpage(before, 0, { title: 'x' });
|
||||||
|
ops.moveHero(before, 0, 2);
|
||||||
|
expect(before).toEqual(copy);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the state as it is for an address that no longer exists', () => {
|
||||||
|
const g = state();
|
||||||
|
expect(ops.addAlbum(g, { type: 'subpage', subpageIndex: 9 }, { id: 'x' })).toBe(g);
|
||||||
|
expect(ops.removeAlbum(g, { type: 'section', subpageIndex: 0, sectionIndex: 0 }, 0)).toBe(g);
|
||||||
|
expect(ops.albumsAt(g, { type: 'section', subpageIndex: 1, sectionIndex: 5 })).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hero, subpages and sections', () => {
|
||||||
|
it('adds, moves and removes hero photos', () => {
|
||||||
|
let g = ops.addHero(state(), 'h4');
|
||||||
|
g = ops.moveHero(g, 3, 0);
|
||||||
|
expect(ops.removeHero(g, 1).hero).toEqual(['h4', 'h2', 'h3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names a new subpage after its position and starts it empty', () => {
|
||||||
|
const g = ops.addSubpage(state());
|
||||||
|
expect(g.subpages[3]).toEqual({ name: 'New Page 4', albums: [], sections: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds, updates and removes sections', () => {
|
||||||
|
let g = ops.addSection(state(), 0);
|
||||||
|
expect(g.subpages[0].sections).toEqual([{ title: 'New Section', albums: [] }]);
|
||||||
|
g = ops.updateSection(g, 0, 0, { title: 'Tokyo', description: 'Night' });
|
||||||
|
expect(g.subpages[0].sections![0]).toEqual({
|
||||||
|
title: 'Tokyo',
|
||||||
|
description: 'Night',
|
||||||
|
albums: [],
|
||||||
|
});
|
||||||
|
expect(ops.removeSection(g, 0, 0).subpages[0].sections).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reading', () => {
|
||||||
|
it('numbers subpages over the enabled ones only', () => {
|
||||||
|
const g = state();
|
||||||
|
expect(ops.enabledPosition(g, 0)).toBe(1);
|
||||||
|
expect(ops.enabledPosition(g, 1)).toBeUndefined();
|
||||||
|
expect(ops.enabledPosition(g, 2)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collects every album in use, sections included', () => {
|
||||||
|
expect([...ops.usedAlbumIds(state())].sort()).toEqual(['a1', 'a2', 'j1', 'j2', 'k1', 'n1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The open subpage sheet follows its page through a drag: the page itself,
|
||||||
|
* or a neighbour moving across it, shifts its index.
|
||||||
|
*/
|
||||||
|
it.each([
|
||||||
|
[null, 0, 2, null],
|
||||||
|
[1, 1, 2, 2],
|
||||||
|
[1, 0, 2, 0],
|
||||||
|
[1, 2, 0, 2],
|
||||||
|
[0, 1, 2, 0],
|
||||||
|
])('keeps sheet %s on its page when %s moves to %s', (open, from, to, expected) => {
|
||||||
|
expect(ops.followMovedIndex(open, from, to)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
162
app/admin/__tests__/gallery-yaml.test.ts
Normal file
162
app/admin/__tests__/gallery-yaml.test.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parseGalleryYaml, serializeGallery } from '../components/page-builder/galleryYaml';
|
||||||
|
import type { GalleryYaml } from '@/lib/config/schema';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #608: gallery.yaml ⇄ the page builder's model. This is what decides what the
|
||||||
|
* builder writes on save, and until it left PageBuilder.tsx nothing tested it.
|
||||||
|
* Album entries themselves are covered in album-entries-round-trip.test.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const A = '11111111-1111-1111-1111-111111111111';
|
||||||
|
const B = '22222222-2222-2222-2222-222222222222';
|
||||||
|
|
||||||
|
/** Load and save once, the way the builder does without any edit in between. */
|
||||||
|
const roundTrip = (yaml: Record<string, unknown>) => serializeGallery(parseGalleryYaml(yaml));
|
||||||
|
|
||||||
|
describe('gallery.yaml round trip', () => {
|
||||||
|
it('keeps hero photos, standalone albums and a plain subpage', () => {
|
||||||
|
const yaml = {
|
||||||
|
hero: ['h1', 'h2'],
|
||||||
|
albums: [A],
|
||||||
|
subpages: [{ name: 'Japan', albums: [B] }],
|
||||||
|
};
|
||||||
|
expect(roundTrip(yaml)).toEqual(yaml);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns a single hero string into a list', () => {
|
||||||
|
expect(roundTrip({ hero: 'h1' })).toEqual({ hero: ['h1'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves empty groups out instead of writing empty lists', () => {
|
||||||
|
expect(roundTrip({ hero: [], albums: [], subpages: [] })).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps every subpage field the builder knows', () => {
|
||||||
|
const sp = {
|
||||||
|
name: 'Clients',
|
||||||
|
title: 'For clients',
|
||||||
|
subtitle: 'Handover',
|
||||||
|
password: 'scrypt:aa:bb',
|
||||||
|
enabled: false,
|
||||||
|
hidden: true,
|
||||||
|
essayText: 'Text',
|
||||||
|
essayFile: 'story',
|
||||||
|
grid: { columns: 4, layout: 'justified' },
|
||||||
|
coverGrid: { columns: 2, gap: 8 },
|
||||||
|
location: 'city',
|
||||||
|
albums: [A],
|
||||||
|
};
|
||||||
|
expect(roundTrip({ subpages: [sp] })).toEqual({ subpages: [sp] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes enabled and hidden only when they differ from the default', () => {
|
||||||
|
const out = roundTrip({ subpages: [{ name: 'P', enabled: true, hidden: false, albums: [A] }] });
|
||||||
|
expect(out).toEqual({ subpages: [{ name: 'P', albums: [A] }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps sections, and page-level albums next to them only when there are any', () => {
|
||||||
|
const withSections = {
|
||||||
|
subpages: [
|
||||||
|
{
|
||||||
|
name: 'Trips',
|
||||||
|
sections: [
|
||||||
|
{ title: 'North', description: 'Cold', albums: [A] },
|
||||||
|
{ title: 'South', albums: [B] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(roundTrip(withSections)).toEqual(withSections);
|
||||||
|
|
||||||
|
const both = {
|
||||||
|
subpages: [{ name: 'Trips', albums: [B], sections: [{ title: 'North', albums: [A] }] }],
|
||||||
|
};
|
||||||
|
expect(roundTrip(both)).toEqual({
|
||||||
|
subpages: [{ name: 'Trips', sections: [{ title: 'North', albums: [A] }], albums: [B] }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts the older name → albums map into the list the builder writes', () => {
|
||||||
|
expect(
|
||||||
|
roundTrip({ subpages: { Japan: [A], Korea: { title: 'South Korea', albums: [B] } } }),
|
||||||
|
).toEqual({
|
||||||
|
subpages: [
|
||||||
|
{ name: 'Japan', albums: [A] },
|
||||||
|
{ name: 'Korea', title: 'South Korea', albums: [B] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* #523 split `grid` into photo grid and cover grid. A pre-#523 page keeps
|
||||||
|
* its cover columns and gap by being seeded from `grid` on load, so the
|
||||||
|
* first save writes the split out explicitly.
|
||||||
|
*/
|
||||||
|
it('seeds the cover grid from a pre-#523 grid', () => {
|
||||||
|
const out = roundTrip({
|
||||||
|
subpages: [{ name: 'Old', grid: { columns: 3, gap: 10, layout: 'masonry' }, albums: [A] }],
|
||||||
|
});
|
||||||
|
expect(out).toEqual({
|
||||||
|
subpages: [
|
||||||
|
{
|
||||||
|
name: 'Old',
|
||||||
|
grid: { columns: 3, gap: 10, layout: 'masonry' },
|
||||||
|
coverGrid: { columns: 3, gap: 10 },
|
||||||
|
albums: [A],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is stable: a second round trip changes nothing', () => {
|
||||||
|
const once = roundTrip({
|
||||||
|
hero: 'h1',
|
||||||
|
subpages: { Japan: { grid: { columns: 2 }, albums: [A] } },
|
||||||
|
});
|
||||||
|
expect(roundTrip(once)).toEqual(once);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The builder rewrites gallery.yaml wholesale on every save, so a subpage key
|
||||||
|
* it does not carry is deleted, not ignored. `proofing` was lost this way: a
|
||||||
|
* page switched off for client proofing by hand came back on after any
|
||||||
|
* unrelated save in the admin. Same lesson as #614 for album entries.
|
||||||
|
*
|
||||||
|
* The type annotation is the enforcement: every key the schema allows on a
|
||||||
|
* subpage has to be listed here, so the next one added and forgotten in
|
||||||
|
* galleryYaml.ts fails this test instead of vanishing from someone's file.
|
||||||
|
*/
|
||||||
|
type SubpageYaml = Extract<NonNullable<GalleryYaml['subpages']>, unknown[]>[number];
|
||||||
|
|
||||||
|
const everySubpageKey: Required<SubpageYaml> = {
|
||||||
|
name: 'Clients',
|
||||||
|
title: 'For clients',
|
||||||
|
subtitle: 'Handover',
|
||||||
|
albums: [A],
|
||||||
|
sections: [{ title: 'North', description: 'Cold', albums: [B] }],
|
||||||
|
password: 'scrypt:aa:bb',
|
||||||
|
proofing: false,
|
||||||
|
essayFile: 'story',
|
||||||
|
essayText: 'Text',
|
||||||
|
enabled: false,
|
||||||
|
hidden: true,
|
||||||
|
location: 'city',
|
||||||
|
grid: { columns: 4, gap: 6, aspectRatio: '3/2', layout: 'justified' },
|
||||||
|
coverGrid: { columns: 2, gap: 8, aspectRatio: '1', layout: 'masonry' },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('subpage keys', () => {
|
||||||
|
it('survives a round trip with every key the schema allows', () => {
|
||||||
|
expect(roundTrip({ subpages: [everySubpageKey] })).toEqual({ subpages: [everySubpageKey] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps proofing in either direction, and writes nothing when unset', () => {
|
||||||
|
const on = { name: 'P', proofing: true, albums: [A] };
|
||||||
|
expect(roundTrip({ subpages: [on] })).toEqual({ subpages: [on] });
|
||||||
|
expect(roundTrip({ subpages: [{ name: 'P', albums: [A] }] })).toEqual({
|
||||||
|
subpages: [{ name: 'P', albums: [A] }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
80
app/admin/__tests__/useAdminFetch.test.tsx
Normal file
80
app/admin/__tests__/useAdminFetch.test.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { renderHook, waitFor, act, cleanup } from '@testing-library/react';
|
||||||
|
import { useAdminFetch } from '../components/useAdminFetch';
|
||||||
|
import { SESSION_EXPIRED_EVENT } from '../components/sessionExpiry';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
const respond = (status: number, body: unknown) =>
|
||||||
|
vi.fn(async () => new Response(JSON.stringify(body), { status }));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #609: every admin screen used to hand-roll this, with three error
|
||||||
|
* conventions between them, one of them a silent catch. These pin the one
|
||||||
|
* convention that replaced them.
|
||||||
|
*/
|
||||||
|
describe('useAdminFetch', () => {
|
||||||
|
it('loads data and clears loading', async () => {
|
||||||
|
vi.stubGlobal('fetch', respond(200, { n: 1 }));
|
||||||
|
const { result } = renderHook(() => useAdminFetch<{ n: number }>('/api/admin/x'));
|
||||||
|
expect(result.current.loading).toBe(true);
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(result.current.data).toEqual({ n: 1 });
|
||||||
|
expect(result.current.error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns an HTTP failure into a message, with the server error when there is one', async () => {
|
||||||
|
vi.stubGlobal('fetch', respond(500, { error: 'disk full' }));
|
||||||
|
const { result } = renderHook(() => useAdminFetch('/api/admin/x'));
|
||||||
|
await waitFor(() => expect(result.current.error).toBe('Could not load (HTTP 500): disk full.'));
|
||||||
|
expect(result.current.data).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a network failure instead of swallowing it', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async () => {
|
||||||
|
throw new TypeError('network');
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => useAdminFetch('/api/admin/x'));
|
||||||
|
await waitFor(() => expect(result.current.error).toBe('Could not reach the server.'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('raises the session-expired event on a 401', async () => {
|
||||||
|
vi.stubGlobal('fetch', respond(401, {}));
|
||||||
|
const expired = vi.fn();
|
||||||
|
window.addEventListener(SESSION_EXPIRED_EVENT, expired);
|
||||||
|
const { result } = renderHook(() => useAdminFetch('/api/admin/x'));
|
||||||
|
await waitFor(() => expect(result.current.error).toContain('session expired'));
|
||||||
|
expect(expired).toHaveBeenCalledOnce();
|
||||||
|
window.removeEventListener(SESSION_EXPIRED_EVENT, expired);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches nothing for a null url, and again on reload', async () => {
|
||||||
|
const fetchMock = respond(200, { n: 1 });
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
const { result, rerender } = renderHook(({ url }) => useAdminFetch(url), {
|
||||||
|
initialProps: { url: null as string | null },
|
||||||
|
});
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
rerender({ url: '/api/admin/x' });
|
||||||
|
await waitFor(() => expect(result.current.data).toEqual({ n: 1 }));
|
||||||
|
act(() => result.current.reload());
|
||||||
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies a local edit without a round trip', async () => {
|
||||||
|
vi.stubGlobal('fetch', respond(200, { items: [1, 2] }));
|
||||||
|
const { result } = renderHook(() => useAdminFetch<{ items: number[] }>('/api/admin/x'));
|
||||||
|
await waitFor(() => expect(result.current.data).not.toBeNull());
|
||||||
|
act(() => result.current.mutate((d) => ({ items: d.items.filter((i) => i !== 1) })));
|
||||||
|
expect(result.current.data).toEqual({ items: [2] });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||||
import { renderHook, act, render, screen, fireEvent, cleanup } from '@testing-library/react';
|
import { renderHook, act, render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||||
import { useDraft, readDraft } from '../components/useDraft';
|
import { useDraft, readDraft, writeDraft, holdsPlaintextPassword } from '../components/useDraft';
|
||||||
import DraftNotice from '../components/DraftNotice';
|
import DraftNotice from '../components/DraftNotice';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,6 +70,25 @@ describe('useDraft', () => {
|
|||||||
expect(second.result.current.status).toBe('restored');
|
expect(second.result.current.status).toBe('restored');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps a held-back draft while the editor shows the server state', () => {
|
||||||
|
const first = mount();
|
||||||
|
act(() => void first.result.current.load('server-v1'));
|
||||||
|
first.rerender({ value: 'my edit', dirty: true });
|
||||||
|
first.unmount();
|
||||||
|
|
||||||
|
// The editor applies the server state after a conflict and is not dirty.
|
||||||
|
// That render used to clear the draft "Restore my changes" then reads.
|
||||||
|
const second = mount();
|
||||||
|
act(() => void second.result.current.load('server-v2'));
|
||||||
|
second.rerender({ value: 'server v2', dirty: false });
|
||||||
|
|
||||||
|
let taken: string | null = null;
|
||||||
|
act(() => {
|
||||||
|
taken = second.result.current.takeConflicting();
|
||||||
|
});
|
||||||
|
expect(taken).toBe('my edit');
|
||||||
|
});
|
||||||
|
|
||||||
it('forgets the draft once saved, and bases new edits on the saved state', () => {
|
it('forgets the draft once saved, and bases new edits on the saved state', () => {
|
||||||
const first = mount();
|
const first = mount();
|
||||||
act(() => void first.result.current.load('server-v1'));
|
act(() => void first.result.current.load('server-v1'));
|
||||||
@@ -155,3 +174,26 @@ describe('DraftNotice', () => {
|
|||||||
expect(h.onDiscard).toHaveBeenCalled();
|
expect(h.onDiscard).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('drafts never keep a typed password', () => {
|
||||||
|
it('detects plaintext passwords in settings, gallery and journal drafts', () => {
|
||||||
|
expect(holdsPlaintextPassword({ sitePassword: 'hunter2' })).toBe(true);
|
||||||
|
expect(holdsPlaintextPassword({ subpages: [{ slug: 'x', password: 'pw' }] })).toBe(true);
|
||||||
|
expect(holdsPlaintextPassword('---\ntitle: A\npassword: pw\n---\nBody')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets hashes and empty values through', () => {
|
||||||
|
expect(holdsPlaintextPassword({ sitePassword: 'scrypt:aa:bb' })).toBe(false);
|
||||||
|
expect(holdsPlaintextPassword({ sitePassword: '' })).toBe(false);
|
||||||
|
expect(holdsPlaintextPassword('---\npassword: scrypt:aa:bb\n---\n')).toBe(false);
|
||||||
|
expect(holdsPlaintextPassword('No frontmatter, password: pw')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the draft instead of writing the password to sessionStorage', () => {
|
||||||
|
writeDraft('settings', { base: 'b', value: { title: 'x' } });
|
||||||
|
expect(readDraft('settings')).not.toBeNull();
|
||||||
|
writeDraft('settings', { base: 'b', value: { title: 'x', sitePassword: 'hunter2' } });
|
||||||
|
expect(readDraft('settings')).toBeNull();
|
||||||
|
expect(JSON.stringify(window.sessionStorage)).not.toContain('hunter2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -32,3 +32,6 @@
|
|||||||
@import './styles/asset-order.css';
|
@import './styles/asset-order.css';
|
||||||
@import './styles/favicon.css';
|
@import './styles/favicon.css';
|
||||||
@import './styles/diagnostics.css';
|
@import './styles/diagnostics.css';
|
||||||
|
@import './styles/messages.css';
|
||||||
|
@import './styles/notifications.css';
|
||||||
|
@import './styles/redesign.css';
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import type { DoctorLevel } from '@/lib/admin/doctor';
|
|||||||
import { DOCTOR_LEVEL_EVENT, systemHealth } from './systemHealth';
|
import { DOCTOR_LEVEL_EVENT, systemHealth } from './systemHealth';
|
||||||
import { reportIfSessionExpired } from './sessionExpiry';
|
import { reportIfSessionExpired } from './sessionExpiry';
|
||||||
import * as Icons from './Icons';
|
import * as Icons from './Icons';
|
||||||
|
import { useNotify } from './Notifications';
|
||||||
|
import { adminGet } from './useAdminFetch';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
@@ -15,16 +17,100 @@ interface Props {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TABS = [
|
/**
|
||||||
{ label: 'Pages', href: '/admin/pages', match: /^\/admin\/pages$/ },
|
* The sidebar, grouped by what the owner is doing (docs/admin-ux-concept.md).
|
||||||
{ label: 'Journal', href: '/admin/journal', match: /^\/admin\/journal/ },
|
* `match` decides the active entry; Overview only matches /admin itself.
|
||||||
{ label: 'Settings', href: '/admin/settings', match: /^\/admin\/settings/ },
|
*/
|
||||||
{ label: 'Analytics', href: '/admin/analytics', match: /^\/admin\/analytics/ },
|
const NAV: {
|
||||||
{ label: 'Diagnostics', href: '/admin/diagnostics', match: /^\/admin\/diagnostics/ },
|
group?: string;
|
||||||
{ label: 'Help', href: '/admin/help', match: /^\/admin\/help/ },
|
items: {
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
match: RegExp;
|
||||||
|
icon: ReactNode;
|
||||||
|
badge?: 'messages' | 'health';
|
||||||
|
}[];
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Overview',
|
||||||
|
href: '/admin',
|
||||||
|
match: /^\/admin\/?$/,
|
||||||
|
icon: <Icons.IconHome size={16} />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'Content',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Pages',
|
||||||
|
href: '/admin/pages',
|
||||||
|
match: /^\/admin\/pages$/,
|
||||||
|
icon: <Icons.IconGrid size={16} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Journal',
|
||||||
|
href: '/admin/journal',
|
||||||
|
match: /^\/admin\/journal/,
|
||||||
|
icon: <Icons.IconBook size={16} />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'Visitors',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Messages',
|
||||||
|
href: '/admin/messages',
|
||||||
|
match: /^\/admin\/messages/,
|
||||||
|
icon: <Icons.IconFileText size={16} />,
|
||||||
|
badge: 'messages',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Analytics',
|
||||||
|
href: '/admin/analytics',
|
||||||
|
match: /^\/admin\/analytics/,
|
||||||
|
icon: <Icons.IconBarChart size={16} />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'Site',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Settings',
|
||||||
|
href: '/admin/settings',
|
||||||
|
match: /^\/admin\/settings/,
|
||||||
|
icon: <Icons.IconGear size={16} />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'System',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Diagnostics',
|
||||||
|
href: '/admin/diagnostics',
|
||||||
|
match: /^\/admin\/diagnostics/,
|
||||||
|
icon: <Icons.IconShieldCheck size={16} />,
|
||||||
|
badge: 'health',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Help',
|
||||||
|
href: '/admin/help',
|
||||||
|
match: /^\/admin\/help/,
|
||||||
|
icon: <Icons.IconQuote size={16} />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const SIDEBAR_KEY = 'folio_admin_sidebar';
|
||||||
|
|
||||||
export default function AdminDashboard({ onLogout, children }: Props) {
|
export default function AdminDashboard({ onLogout, children }: Props) {
|
||||||
|
const notify = useNotify();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
@@ -40,6 +126,29 @@ export default function AdminDashboard({ onLogout, children }: Props) {
|
|||||||
// an expired session or a 500 rendered exactly like a real outage — every
|
// an expired session or a 500 rendered exactly like a real outage — every
|
||||||
// indicator flipped to its alarming value at once (#341).
|
// indicator flipped to its alarming value at once (#341).
|
||||||
const [statusError, setStatusError] = useState(false);
|
const [statusError, setStatusError] = useState(false);
|
||||||
|
/**
|
||||||
|
* Icons-only sidebar, remembered per browser. The dashboard only renders
|
||||||
|
* after the client-side session check, so reading storage in the initial
|
||||||
|
* state cannot mismatch a server render.
|
||||||
|
*/
|
||||||
|
const [collapsed, setCollapsed] = useState<boolean>(() => {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(SIDEBAR_KEY) === 'collapsed';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const toggleCollapsed = () =>
|
||||||
|
setCollapsed((c) => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(SIDEBAR_KEY, c ? 'expanded' : 'collapsed');
|
||||||
|
} catch {
|
||||||
|
// Private mode or blocked storage: the toggle still works for this visit.
|
||||||
|
}
|
||||||
|
return !c;
|
||||||
|
});
|
||||||
|
/** Unread contact messages, for the count next to Messages. */
|
||||||
|
const [unread, setUnread] = useState(0);
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await fetch('/api/admin/auth', { method: 'DELETE' });
|
await fetch('/api/admin/auth', { method: 'DELETE' });
|
||||||
@@ -54,7 +163,7 @@ export default function AdminDashboard({ onLogout, children }: Props) {
|
|||||||
// result was never checked at all (#596).
|
// result was never checked at all (#596).
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (!reportIfSessionExpired(res)) {
|
if (!reportIfSessionExpired(res)) {
|
||||||
alert(`Reload failed (HTTP ${res.status}).`);
|
notify('error', `Reload failed (HTTP ${res.status}).`);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -112,6 +221,18 @@ export default function AdminDashboard({ onLogout, children }: Props) {
|
|||||||
fetchDoctor();
|
fetchDoctor();
|
||||||
}, [fetchDoctor]);
|
}, [fetchDoctor]);
|
||||||
|
|
||||||
|
// Re-counted on every navigation: reading a message in the inbox and moving
|
||||||
|
// on should clear the count without a reload.
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
adminGet<{ messages: { read: boolean }[] }>('/api/admin/messages').then((r) => {
|
||||||
|
if (live && r.data) setUnread(r.data.messages.filter((m) => !m.read).length);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
// The diagnostics page runs the doctor itself; take its answer rather than
|
// The diagnostics page runs the doctor itself; take its answer rather than
|
||||||
// asking Immich the same three questions again.
|
// asking Immich the same three questions again.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -146,36 +267,81 @@ export default function AdminDashboard({ onLogout, children }: Props) {
|
|||||||
const missingCredentials = status?.setup?.credentials === 'missing';
|
const missingCredentials = status?.setup?.credentials === 'missing';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-dashboard">
|
<div className={`admin-dashboard admin-app${collapsed ? ' is-collapsed' : ''}`}>
|
||||||
<header className="admin-header">
|
<aside className="admin-sidebar" aria-label="Admin">
|
||||||
<div className="admin-header-left">
|
<button
|
||||||
<h1>Immich Folio</h1>
|
type="button"
|
||||||
<nav className="admin-tabs">
|
className="admin-sidebar-toggle"
|
||||||
{TABS.map((t) => {
|
onClick={toggleCollapsed}
|
||||||
const active = t.match.test(pathname);
|
aria-expanded={!collapsed}
|
||||||
return (
|
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
<Link
|
data-tip={collapsed ? 'Expand' : undefined}
|
||||||
key={t.href}
|
>
|
||||||
href={t.href}
|
<svg
|
||||||
className={`admin-tab ${active ? 'active' : ''}`}
|
width="14"
|
||||||
aria-current={active ? 'page' : undefined}
|
height="14"
|
||||||
>
|
viewBox="0 0 24 24"
|
||||||
{t.label}
|
fill="none"
|
||||||
</Link>
|
stroke="currentColor"
|
||||||
);
|
strokeWidth="2"
|
||||||
})}
|
strokeLinecap="round"
|
||||||
</nav>
|
strokeLinejoin="round"
|
||||||
</div>
|
aria-hidden="true"
|
||||||
<div className="admin-header-right">
|
>
|
||||||
|
<polyline points={collapsed ? '9 6 15 12 9 18' : '15 6 9 12 15 18'} />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<Link href="/admin" className="admin-brand" data-tip="Immich Folio">
|
||||||
|
<span className="admin-brand-mark" aria-hidden="true" />
|
||||||
|
<span className="admin-brand-text">
|
||||||
|
<span className="admin-brand-name">Immich Folio</span>
|
||||||
|
<span className="admin-brand-sub">Admin</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav className="admin-nav">
|
||||||
|
{NAV.map((section, i) => (
|
||||||
|
<div key={section.group ?? i} className="admin-nav-group">
|
||||||
|
{section.group && <span className="admin-nav-label">{section.group}</span>}
|
||||||
|
{section.items.map((item) => {
|
||||||
|
const active = item.match.test(pathname);
|
||||||
|
const count = item.badge === 'messages' ? unread : 0;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={`admin-nav-item ${active ? 'active' : ''}`}
|
||||||
|
data-tip={item.label}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
<span className="admin-nav-icon">{item.icon}</span>
|
||||||
|
<span className="admin-nav-text">{item.label}</span>
|
||||||
|
{count > 0 && (
|
||||||
|
<span className="admin-nav-count" aria-label={`${count} unread`}>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.badge === 'health' && health.tone === 'disconnected' && (
|
||||||
|
<span className={`admin-nav-dot ${health.tone}`} aria-label={health.label} />
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="admin-sidebar-foot">
|
||||||
{/* Diagnostics Badge */}
|
{/* Diagnostics Badge */}
|
||||||
<div className="status-indicator-container">
|
<div className="status-indicator-container">
|
||||||
<button
|
<button
|
||||||
className={`status-badge-btn ${health.tone}`}
|
className={`status-badge-btn sidebar-status ${health.tone}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowStatus(!showStatus);
|
setShowStatus(!showStatus);
|
||||||
if (!showStatus) fetchStatus();
|
if (!showStatus) fetchStatus();
|
||||||
}}
|
}}
|
||||||
title="Show system status"
|
title="Show system status"
|
||||||
|
data-tip={health.label}
|
||||||
>
|
>
|
||||||
<span className="status-dot"></span>
|
<span className="status-dot"></span>
|
||||||
<span className="status-text">{health.label}</span>
|
<span className="status-text">{health.label}</span>
|
||||||
@@ -266,39 +432,46 @@ export default function AdminDashboard({ onLogout, children }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="admin-header-divider" aria-hidden="true" />
|
<div className="admin-sidebar-actions">
|
||||||
|
<a
|
||||||
<a
|
href="/?fresh=1"
|
||||||
href="/?fresh=1"
|
target="_blank"
|
||||||
target="_blank"
|
rel="noopener noreferrer"
|
||||||
rel="noopener noreferrer"
|
className="admin-side-action"
|
||||||
className="admin-btn admin-btn-ghost"
|
data-tip="View site"
|
||||||
title="Open site in new tab (bypassing cache)"
|
title="Open site in new tab (bypassing cache)"
|
||||||
>
|
>
|
||||||
<Icons.IconLink size={14} /> Site
|
<Icons.IconLink size={14} />
|
||||||
</a>
|
<span className="admin-side-label">View site</span>
|
||||||
<button
|
</a>
|
||||||
className="admin-btn admin-btn-ghost"
|
<button
|
||||||
onClick={() => setShowBackupModal(true)}
|
className="admin-side-action"
|
||||||
title="Manage config backups & restore"
|
data-tip="Backups"
|
||||||
>
|
onClick={() => setShowBackupModal(true)}
|
||||||
<Icons.IconArchive size={14} /> Backups
|
title="Manage config backups & restore"
|
||||||
</button>
|
>
|
||||||
<button
|
<Icons.IconArchive size={14} />
|
||||||
className="admin-btn admin-btn-ghost"
|
<span className="admin-side-label">Backups</span>
|
||||||
onClick={handleReload}
|
</button>
|
||||||
disabled={saving}
|
<button
|
||||||
title="Reload config & clear cache"
|
className="admin-side-action"
|
||||||
>
|
data-tip="Reload"
|
||||||
<Icons.IconRefresh size={14} /> Reload
|
onClick={handleReload}
|
||||||
</button>
|
disabled={saving}
|
||||||
<button className="admin-btn admin-btn-ghost" onClick={handleLogout}>
|
title="Reload config & clear cache"
|
||||||
Logout
|
>
|
||||||
</button>
|
<Icons.IconRefresh size={14} />
|
||||||
|
<span className="admin-side-label">Reload</span>
|
||||||
|
</button>
|
||||||
|
<button className="admin-side-action" data-tip="Sign out" onClick={handleLogout}>
|
||||||
|
<Icons.IconX size={14} />
|
||||||
|
<span className="admin-side-label">Sign out</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</aside>
|
||||||
|
|
||||||
<main className="admin-main">{children}</main>
|
<main className="admin-main admin-workspace">{children}</main>
|
||||||
|
|
||||||
<BackupManagerModal
|
<BackupManagerModal
|
||||||
isOpen={showBackupModal}
|
isOpen={showBackupModal}
|
||||||
|
|||||||
42
app/admin/components/AdminLoadState.tsx
Normal file
42
app/admin/components/AdminLoadState.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as Icons from './Icons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one spinner and the one error-with-retry for admin screens (#609), so a
|
||||||
|
* failed load looks the same everywhere and always offers a way out.
|
||||||
|
*
|
||||||
|
* Renders nothing once there is data and no error; the caller renders the
|
||||||
|
* content then.
|
||||||
|
*/
|
||||||
|
export default function AdminLoadState({
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
hasData,
|
||||||
|
}: {
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
onRetry: () => void;
|
||||||
|
/** With data already on screen, a reload shows no spinner over it. */
|
||||||
|
hasData?: boolean;
|
||||||
|
}) {
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="admin-load-error" role="alert">
|
||||||
|
<p>{error}</p>
|
||||||
|
<button type="button" className="admin-btn admin-btn-primary" onClick={onRetry}>
|
||||||
|
<Icons.IconRefresh size={14} /> Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (loading && !hasData) {
|
||||||
|
return (
|
||||||
|
<div className="admin-loading-container" role="status" aria-label="Loading">
|
||||||
|
<div className="admin-spinner" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
229
app/admin/components/AdminOverview.tsx
Normal file
229
app/admin/components/AdminOverview.tsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /admin: the first screen after signing in (docs/admin-ux-concept.md).
|
||||||
|
*
|
||||||
|
* Says what needs attention before it is asked for (unread messages, doctor
|
||||||
|
* findings), then the site at a glance. Everything on it links to the screen
|
||||||
|
* where it is acted on. It only reads the existing admin endpoints; a failed
|
||||||
|
* one leaves its part out rather than failing the page.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import * as Icons from './Icons';
|
||||||
|
import { adminGet } from './useAdminFetch';
|
||||||
|
import type { DoctorFinding, DoctorLevel } from '@/lib/admin/doctor';
|
||||||
|
import { DOCTOR_LEVEL_EVENT } from './systemHealth';
|
||||||
|
|
||||||
|
interface OverviewData {
|
||||||
|
gallery: { hero?: unknown; subpages?: Array<{ enabled?: boolean }> } | null;
|
||||||
|
journal: Array<{ frontmatter: { draft?: boolean } }> | null;
|
||||||
|
messages: Array<{ read: boolean; name: string }> | null;
|
||||||
|
analytics: { days?: Record<string, { pageviews?: number }> } | null;
|
||||||
|
doctor: { level: DoctorLevel; findings: DoctorFinding[] } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a doctor finding is fixed; mirrors FIXES in DiagnosticsView. */
|
||||||
|
const FIX_ROUTE: Record<string, string> = {
|
||||||
|
legal: '/admin/settings/legal',
|
||||||
|
contact: '/admin/settings/legal',
|
||||||
|
privacy: '/admin/settings/legal',
|
||||||
|
passwords: '/admin/settings/security',
|
||||||
|
'album-ids': '/admin/pages',
|
||||||
|
'albums-shared': '/admin/pages',
|
||||||
|
};
|
||||||
|
|
||||||
|
function viewsLastDays(
|
||||||
|
days: Record<string, { pageviews?: number }> | undefined,
|
||||||
|
n: number,
|
||||||
|
): number {
|
||||||
|
if (!days) return 0;
|
||||||
|
const cutoff = new Date();
|
||||||
|
cutoff.setDate(cutoff.getDate() - (n - 1));
|
||||||
|
const from = cutoff.toISOString().slice(0, 10);
|
||||||
|
return Object.entries(days)
|
||||||
|
.filter(([date]) => date >= from)
|
||||||
|
.reduce((sum, [, d]) => sum + (d.pageviews || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function greeting(): string {
|
||||||
|
const h = new Date().getHours();
|
||||||
|
if (h < 5) return 'Good night';
|
||||||
|
if (h < 12) return 'Good morning';
|
||||||
|
if (h < 18) return 'Good afternoon';
|
||||||
|
return 'Good evening';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminOverview() {
|
||||||
|
const [data, setData] = useState<OverviewData | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
Promise.all([
|
||||||
|
adminGet<{ gallery: OverviewData['gallery'] }>('/api/admin/gallery'),
|
||||||
|
adminGet<{ entries: NonNullable<OverviewData['journal']> }>('/api/admin/journal'),
|
||||||
|
adminGet<{ messages: NonNullable<OverviewData['messages']> }>('/api/admin/messages'),
|
||||||
|
adminGet<NonNullable<OverviewData['analytics']>>('/api/admin/analytics'),
|
||||||
|
adminGet<NonNullable<OverviewData['doctor']>>('/api/admin/doctor'),
|
||||||
|
]).then(([gallery, journal, messages, analytics, doctor]) => {
|
||||||
|
if (!live) return;
|
||||||
|
setData({
|
||||||
|
gallery: gallery.data?.gallery ?? null,
|
||||||
|
journal: journal.data?.entries ?? null,
|
||||||
|
messages: messages.data?.messages ?? null,
|
||||||
|
analytics: analytics.data ?? null,
|
||||||
|
doctor: doctor.data ?? null,
|
||||||
|
});
|
||||||
|
// The sidebar badge takes the level from here instead of asking again.
|
||||||
|
if (doctor.data?.level) {
|
||||||
|
window.dispatchEvent(new CustomEvent(DOCTOR_LEVEL_EVENT, { detail: doctor.data.level }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const subpages = data?.gallery?.subpages ?? [];
|
||||||
|
const published = subpages.filter((sp) => sp.enabled !== false).length;
|
||||||
|
const heroRaw = data?.gallery?.hero;
|
||||||
|
const heroCount = Array.isArray(heroRaw) ? heroRaw.length : heroRaw ? 1 : 0;
|
||||||
|
const entries = data?.journal ?? [];
|
||||||
|
const drafts = entries.filter((e) => e.frontmatter.draft).length;
|
||||||
|
const unread = (data?.messages ?? []).filter((m) => !m.read);
|
||||||
|
const views7 = viewsLastDays(data?.analytics?.days, 7);
|
||||||
|
const findings = (data?.doctor?.findings ?? []).filter((f) => f.level !== 'ok');
|
||||||
|
|
||||||
|
const attention: Array<{
|
||||||
|
tone: 'accent' | 'warn' | 'error';
|
||||||
|
title: string;
|
||||||
|
detail?: string;
|
||||||
|
href: string;
|
||||||
|
}> = [];
|
||||||
|
if (unread.length) {
|
||||||
|
attention.push({
|
||||||
|
tone: 'accent',
|
||||||
|
title: `${unread.length} unread message${unread.length === 1 ? '' : 's'}`,
|
||||||
|
detail: `From ${[...new Set(unread.map((m) => m.name))].slice(0, 3).join(', ')}`,
|
||||||
|
href: '/admin/messages',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const f of findings) {
|
||||||
|
attention.push({
|
||||||
|
tone: f.level === 'error' ? 'error' : 'warn',
|
||||||
|
title: f.title,
|
||||||
|
detail: f.detail,
|
||||||
|
href: FIX_ROUTE[f.id] ?? '/admin/diagnostics',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-overview">
|
||||||
|
<header className="overview-head">
|
||||||
|
<div>
|
||||||
|
<p className="overview-kicker">Overview</p>
|
||||||
|
<h1 className="overview-title">{greeting()}.</h1>
|
||||||
|
<p className="overview-sub">
|
||||||
|
{data
|
||||||
|
? attention.length
|
||||||
|
? `${attention.length} thing${attention.length === 1 ? '' : 's'} could use a look.`
|
||||||
|
: 'Everything is in order.'
|
||||||
|
: 'Checking the site…'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="overview-actions">
|
||||||
|
<a
|
||||||
|
className="admin-btn admin-btn-ghost"
|
||||||
|
href="/?fresh=1"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Icons.IconLink size={14} /> View site
|
||||||
|
</a>
|
||||||
|
<Link className="admin-btn admin-btn-primary" href="/admin/pages">
|
||||||
|
<Icons.IconPencil size={14} /> Edit pages
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section aria-labelledby="overview-attention">
|
||||||
|
<h2 id="overview-attention" className="overview-section-label">
|
||||||
|
Needs attention
|
||||||
|
</h2>
|
||||||
|
{!data ? (
|
||||||
|
<div className="admin-spinner" />
|
||||||
|
) : attention.length ? (
|
||||||
|
<div className="overview-attention">
|
||||||
|
{attention.map((a, i) => (
|
||||||
|
<Link key={i} href={a.href} className="overview-attention-item">
|
||||||
|
<span className={`overview-attention-marker ${a.tone}`} aria-hidden="true" />
|
||||||
|
<span className="overview-attention-text">
|
||||||
|
{a.title}
|
||||||
|
{a.detail && <span className="overview-attention-detail">{a.detail}</span>}
|
||||||
|
</span>
|
||||||
|
<span className="overview-attention-go">Open</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="overview-calm">Nothing waiting. New messages and problems show up here.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="overview-glance">
|
||||||
|
<h2 id="overview-glance" className="overview-section-label">
|
||||||
|
At a glance
|
||||||
|
</h2>
|
||||||
|
<div className="overview-stats">
|
||||||
|
<Link href="/admin/pages" className="overview-stat">
|
||||||
|
<span className="overview-stat-label">Pages</span>
|
||||||
|
<span className="overview-stat-value">{data ? published : '–'}</span>
|
||||||
|
<span className="overview-stat-note">
|
||||||
|
{subpages.length - published > 0
|
||||||
|
? `${subpages.length - published} switched off`
|
||||||
|
: 'all published'}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/pages" className="overview-stat">
|
||||||
|
<span className="overview-stat-label">Hero photos</span>
|
||||||
|
<span className="overview-stat-value">{data ? heroCount : '–'}</span>
|
||||||
|
<span className="overview-stat-note">on the home page</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/journal" className="overview-stat">
|
||||||
|
<span className="overview-stat-label">Journal</span>
|
||||||
|
<span className="overview-stat-value">{data ? entries.length : '–'}</span>
|
||||||
|
<span className="overview-stat-note">
|
||||||
|
{drafts ? `${drafts} draft${drafts === 1 ? '' : 's'}` : 'no drafts'}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/analytics" className="overview-stat">
|
||||||
|
<span className="overview-stat-label">Views · 7 days</span>
|
||||||
|
<span className="overview-stat-value">{data ? views7.toLocaleString() : '–'}</span>
|
||||||
|
<span className="overview-stat-note">cookieless count</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="overview-next">
|
||||||
|
<h2 id="overview-next" className="overview-section-label">
|
||||||
|
Quick actions
|
||||||
|
</h2>
|
||||||
|
<div className="overview-actions">
|
||||||
|
<Link className="admin-btn admin-btn-ghost" href="/admin/journal">
|
||||||
|
<Icons.IconPlus size={14} /> New journal entry
|
||||||
|
</Link>
|
||||||
|
<Link className="admin-btn admin-btn-ghost" href="/admin/messages">
|
||||||
|
<Icons.IconFileText size={14} /> Messages
|
||||||
|
</Link>
|
||||||
|
<Link className="admin-btn admin-btn-ghost" href="/admin/settings/theme">
|
||||||
|
<Icons.IconPalette size={14} /> Theme
|
||||||
|
</Link>
|
||||||
|
<Link className="admin-btn admin-btn-ghost" href="/admin/diagnostics">
|
||||||
|
<Icons.IconShieldCheck size={14} /> Diagnostics
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import * as Icons from './Icons';
|
import * as Icons from './Icons';
|
||||||
|
import { useAdminFetch } from './useAdminFetch';
|
||||||
|
import AdminLoadState from './AdminLoadState';
|
||||||
|
import PageHeader from './PageHeader';
|
||||||
|
|
||||||
interface AnalyticsData {
|
interface AnalyticsData {
|
||||||
trackingEnabled?: boolean;
|
trackingEnabled?: boolean;
|
||||||
@@ -22,47 +24,17 @@ interface AnalyticsData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AnalyticsView() {
|
export default function AnalyticsView() {
|
||||||
const [data, setData] = useState<AnalyticsData | null>(null);
|
const analytics = useAdminFetch<AnalyticsData>('/api/admin/analytics');
|
||||||
const [loading, setLoading] = useState(true);
|
const data = analytics.data;
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const fetchAnalytics = useCallback(async () => {
|
if (!data || analytics.error) {
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/admin/analytics');
|
|
||||||
if (!res.ok) throw new Error('Failed to fetch analytics');
|
|
||||||
const json = await res.json();
|
|
||||||
setData(json);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message || 'Error loading analytics');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchAnalytics();
|
|
||||||
}, [fetchAnalytics]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-loading-container">
|
<AdminLoadState
|
||||||
<div className="admin-spinner" />
|
loading={analytics.loading}
|
||||||
</div>
|
error={analytics.error}
|
||||||
);
|
onRetry={analytics.reload}
|
||||||
}
|
hasData={!!data}
|
||||||
|
/>
|
||||||
if (error || !data) {
|
|
||||||
return (
|
|
||||||
<div className="admin-panel" style={{ padding: '2rem', textAlign: 'center' }}>
|
|
||||||
<p style={{ color: 'var(--admin-text-muted)', marginBottom: '1rem' }}>
|
|
||||||
{error || 'No analytics data'}
|
|
||||||
</p>
|
|
||||||
<button className="admin-btn admin-btn-primary" onClick={fetchAnalytics}>
|
|
||||||
<Icons.IconRefresh size={14} /> Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,20 +52,16 @@ export default function AnalyticsView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="analytics-view">
|
<div className="analytics-view">
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div className="analytics-header">
|
kicker="Visitors"
|
||||||
<div>
|
title="Analytics"
|
||||||
<h2>
|
description="Cookieless page counts, kept on this server. No personal data is collected."
|
||||||
<Icons.IconSparkles size={20} /> Visitor Insights & Analytics
|
actions={
|
||||||
</h2>
|
<button className="admin-btn admin-btn-ghost admin-btn-sm" onClick={analytics.reload}>
|
||||||
<p className="analytics-subtitle">
|
<Icons.IconRefresh size={14} /> Refresh
|
||||||
Privacy-first aggregate traffic stats. No personal data collected.
|
</button>
|
||||||
</p>
|
}
|
||||||
</div>
|
/>
|
||||||
<button className="admin-btn admin-btn-ghost admin-btn-sm" onClick={fetchAnalytics}>
|
|
||||||
<Icons.IconRefresh size={14} /> Refresh
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{data.trackingEnabled === false && (
|
{data.trackingEnabled === false && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import type { BackupItem, BackupTarget } from '@/app/api/admin/backups/route';
|
import type { BackupItem, BackupTarget } from '@/app/api/admin/backups/route';
|
||||||
import * as Icons from './Icons';
|
import * as Icons from './Icons';
|
||||||
import { useScrollLock } from './useScrollLock';
|
import { useScrollLock } from './useScrollLock';
|
||||||
import { useModalDialog } from '@/hooks/useModalDialog';
|
import { useModalDialog } from '@/hooks/useModalDialog';
|
||||||
|
import { reportContentRestored } from './contentRestored';
|
||||||
|
import { useAdminFetch } from './useAdminFetch';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -16,48 +18,47 @@ const TABS: { target: BackupTarget; label: string; file: string }[] = [
|
|||||||
{ target: 'gallery', label: 'Gallery', file: 'gallery.yaml' },
|
{ target: 'gallery', label: 'Gallery', file: 'gallery.yaml' },
|
||||||
{ target: 'settings', label: 'Settings', file: 'settings.yaml' },
|
{ target: 'settings', label: 'Settings', file: 'settings.yaml' },
|
||||||
{ target: 'about', label: 'About', file: 'about.md' },
|
{ target: 'about', label: 'About', file: 'about.md' },
|
||||||
|
{ target: 'privacy', label: 'Privacy', file: 'privacy.md' },
|
||||||
{ target: 'journal', label: 'Journal', file: 'journal entries' },
|
{ target: 'journal', label: 'Journal', file: 'journal entries' },
|
||||||
];
|
];
|
||||||
|
|
||||||
type BackupLists = Record<BackupTarget, BackupItem[]>;
|
type BackupLists = Record<BackupTarget, BackupItem[]>;
|
||||||
|
|
||||||
const EMPTY_LISTS: BackupLists = { gallery: [], settings: [], about: [], journal: [] };
|
const EMPTY_LISTS: BackupLists = {
|
||||||
|
gallery: [],
|
||||||
|
settings: [],
|
||||||
|
about: [],
|
||||||
|
privacy: [],
|
||||||
|
journal: [],
|
||||||
|
};
|
||||||
|
|
||||||
export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }: Props) {
|
export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }: Props) {
|
||||||
const [activeTab, setActiveTab] = useState<BackupTarget>('gallery');
|
const [activeTab, setActiveTab] = useState<BackupTarget>('gallery');
|
||||||
const [showAllBackups, setShowAllBackups] = useState(false);
|
const [showAllBackups, setShowAllBackups] = useState(false);
|
||||||
const [backups, setBackups] = useState<BackupLists>(EMPTY_LISTS);
|
const list = useAdminFetch<{ backups: Partial<BackupLists> }>(
|
||||||
const [loading, setLoading] = useState(false);
|
isOpen ? '/api/admin/backups' : null,
|
||||||
const [error, setError] = useState<string | null>(null);
|
);
|
||||||
|
const backups: BackupLists = { ...EMPTY_LISTS, ...list.data?.backups };
|
||||||
|
/** A failed restore; a failed load is list.error. */
|
||||||
|
const [restoreError, setRestoreError] = useState<string | null>(null);
|
||||||
|
const error = list.error ?? restoreError;
|
||||||
|
const loading = list.loading;
|
||||||
|
const reloadList = list.reload;
|
||||||
const [restoringFilename, setRestoringFilename] = useState<string | null>(null);
|
const [restoringFilename, setRestoringFilename] = useState<string | null>(null);
|
||||||
const [confirmItem, setConfirmItem] = useState<BackupItem | null>(null);
|
const [confirmItem, setConfirmItem] = useState<BackupItem | null>(null);
|
||||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchBackups = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/admin/backups');
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error('Failed to fetch backup history');
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
|
||||||
setBackups({ ...EMPTY_LISTS, ...data.backups });
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message || 'Error loading backups');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
fetchBackups();
|
// Each opening shows the backups as they are now, not as they were
|
||||||
|
// the last time the modal was open.
|
||||||
|
reloadList();
|
||||||
|
setRestoreError(null);
|
||||||
setConfirmItem(null);
|
setConfirmItem(null);
|
||||||
setSuccessMsg(null);
|
setSuccessMsg(null);
|
||||||
setShowAllBackups(false);
|
setShowAllBackups(false);
|
||||||
}
|
}
|
||||||
}, [isOpen, fetchBackups]);
|
}, [isOpen, reloadList]);
|
||||||
|
|
||||||
useScrollLock(isOpen);
|
useScrollLock(isOpen);
|
||||||
/* Before the early `return null`: hooks must not run conditionally. */
|
/* Before the early `return null`: hooks must not run conditionally. */
|
||||||
@@ -68,7 +69,7 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
|
|||||||
async function handleRestore(item: BackupItem) {
|
async function handleRestore(item: BackupItem) {
|
||||||
const filename = item.filename;
|
const filename = item.filename;
|
||||||
setRestoringFilename(filename);
|
setRestoringFilename(filename);
|
||||||
setError(null);
|
setRestoreError(null);
|
||||||
setSuccessMsg(null);
|
setSuccessMsg(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -90,10 +91,12 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
|
|||||||
: `Backup restored successfully! (${filename})`,
|
: `Backup restored successfully! (${filename})`,
|
||||||
);
|
);
|
||||||
setConfirmItem(null);
|
setConfirmItem(null);
|
||||||
await fetchBackups();
|
// Open editors still hold the pre-restore state; they reload on this.
|
||||||
|
reportContentRestored({ target: item.target, slug: item.slug });
|
||||||
|
reloadList();
|
||||||
onRestoreSuccess();
|
onRestoreSuccess();
|
||||||
} catch (err: any) {
|
} catch (err) {
|
||||||
setError(err.message || 'Error restoring backup');
|
setRestoreError(err instanceof Error ? err.message : 'Error restoring backup');
|
||||||
} finally {
|
} finally {
|
||||||
setRestoringFilename(null);
|
setRestoringFilename(null);
|
||||||
}
|
}
|
||||||
|
|||||||
115
app/admin/components/ConfirmDialog.tsx
Normal file
115
app/admin/components/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One confirmation for the admin (UX stage 3, #694 §3), replacing the
|
||||||
|
* browser's `confirm()`, which cannot be styled, blocks the tab and reads the
|
||||||
|
* same for "discard a draft" and "delete a file".
|
||||||
|
*
|
||||||
|
* Policy: ask only before something that is permanent or takes effect at
|
||||||
|
* once (deleting a message, a journal entry, the uploaded favicon). Removals
|
||||||
|
* of unsaved state are undone by not saving and do not need a dialog.
|
||||||
|
*
|
||||||
|
* const confirm = useConfirm();
|
||||||
|
* if (!(await confirm({ title: 'Delete message?', confirmLabel: 'Delete', danger: true }))) return;
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react';
|
||||||
|
import { useModalDialog } from '@/hooks/useModalDialog';
|
||||||
|
|
||||||
|
export interface ConfirmOptions {
|
||||||
|
title: string;
|
||||||
|
/** What happens, in one or two sentences. */
|
||||||
|
message?: ReactNode;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
/** Destructive: the confirm button uses the error colour. */
|
||||||
|
danger?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Confirm = (options: ConfirmOptions) => Promise<boolean>;
|
||||||
|
|
||||||
|
/** Outside the provider (a component tested on its own) it falls back to window.confirm. */
|
||||||
|
const ConfirmContext = createContext<Confirm>(async (o) =>
|
||||||
|
typeof window === 'undefined' ? false : window.confirm(o.title),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function useConfirm(): Confirm {
|
||||||
|
return useContext(ConfirmContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [pending, setPending] = useState<ConfirmOptions | null>(null);
|
||||||
|
const resolver = useRef<((ok: boolean) => void) | null>(null);
|
||||||
|
|
||||||
|
const confirm = useCallback<Confirm>(
|
||||||
|
(options) =>
|
||||||
|
new Promise<boolean>((resolve) => {
|
||||||
|
resolver.current?.(false);
|
||||||
|
resolver.current = resolve;
|
||||||
|
setPending(options);
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const settle = useCallback((ok: boolean) => {
|
||||||
|
resolver.current?.(ok);
|
||||||
|
resolver.current = null;
|
||||||
|
setPending(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmContext.Provider value={confirm}>
|
||||||
|
{children}
|
||||||
|
{pending && <Dialog options={pending} onSettle={settle} />}
|
||||||
|
</ConfirmContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
options,
|
||||||
|
onSettle,
|
||||||
|
}: {
|
||||||
|
options: ConfirmOptions;
|
||||||
|
onSettle: (ok: boolean) => void;
|
||||||
|
}) {
|
||||||
|
// Esc cancels, focus is trapped and returned (the same hook the modals use).
|
||||||
|
const cardRef = useModalDialog(() => onSettle(false));
|
||||||
|
return (
|
||||||
|
<div className="confirm-backdrop" onClick={() => onSettle(false)}>
|
||||||
|
<div
|
||||||
|
ref={cardRef}
|
||||||
|
className="confirm-card"
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="confirm-title"
|
||||||
|
aria-describedby={options.message ? 'confirm-message' : undefined}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 id="confirm-title" className="confirm-title">
|
||||||
|
{options.title}
|
||||||
|
</h2>
|
||||||
|
{options.message && (
|
||||||
|
<p id="confirm-message" className="confirm-message">
|
||||||
|
{options.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="confirm-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-btn admin-btn-ghost"
|
||||||
|
onClick={() => onSettle(false)}
|
||||||
|
>
|
||||||
|
{options.cancelLabel ?? 'Cancel'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`admin-btn ${options.danger ? 'confirm-danger' : 'admin-btn-primary'}`}
|
||||||
|
onClick={() => onSettle(true)}
|
||||||
|
>
|
||||||
|
{options.confirmLabel ?? 'Confirm'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import * as Icons from './Icons';
|
|||||||
import { DOCTOR_LEVEL_EVENT } from './systemHealth';
|
import { DOCTOR_LEVEL_EVENT } from './systemHealth';
|
||||||
import type { DoctorFinding, DoctorLevel } from '@/lib/admin/doctor';
|
import type { DoctorFinding, DoctorLevel } from '@/lib/admin/doctor';
|
||||||
import type { AltTextReport } from '@/lib/admin/alt-text';
|
import type { AltTextReport } from '@/lib/admin/alt-text';
|
||||||
|
import PageHeader from './PageHeader';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Diagnostics as a page (/admin/diagnostics).
|
* Diagnostics as a page (/admin/diagnostics).
|
||||||
@@ -40,8 +41,12 @@ type AltTextData = AltTextReport & {
|
|||||||
/** Which group a doctor check belongs to. An unknown id lands in Content. */
|
/** Which group a doctor check belongs to. An unknown id lands in Content. */
|
||||||
const GROUPS: { id: string; title: string; checks: string[] }[] = [
|
const GROUPS: { id: string; title: string; checks: string[] }[] = [
|
||||||
{ id: 'connection', title: 'Connection', checks: ['immich-api', 'album-ids'] },
|
{ id: 'connection', title: 'Connection', checks: ['immich-api', 'album-ids'] },
|
||||||
{ id: 'security', title: 'Security', checks: ['auth-secret', 'passwords', 'proxy-hops'] },
|
{ id: 'security', title: 'Security', checks: ['auth-secret', 'passwords', 'proxy-hops', 'cdn'] },
|
||||||
{ id: 'content', title: 'Content', checks: ['albums-shared', 'content-writable', 'alt-text'] },
|
{
|
||||||
|
id: 'content',
|
||||||
|
title: 'Content',
|
||||||
|
checks: ['albums-shared', 'content-writable', 'alt-text', 'legal', 'contact', 'privacy'],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const LEVEL_LABEL: Record<DoctorLevel, string> = { ok: 'OK', warn: 'Check', error: 'Problem' };
|
const LEVEL_LABEL: Record<DoctorLevel, string> = { ok: 'OK', warn: 'Check', error: 'Problem' };
|
||||||
@@ -56,9 +61,13 @@ const FIXES: Record<string, { label: string; href: string }> = {
|
|||||||
href: `${DOCS}/README.md#environment-variables-envlocal`,
|
href: `${DOCS}/README.md#environment-variables-envlocal`,
|
||||||
},
|
},
|
||||||
'proxy-hops': { label: 'How to set it', href: `${DOCS}/docs/gallery-config.md#trusted-proxies` },
|
'proxy-hops': { label: 'How to set it', href: `${DOCS}/docs/gallery-config.md#trusted-proxies` },
|
||||||
|
cdn: { label: 'CDN setup', href: `${DOCS}/docs/deployment.md#cdn-mode` },
|
||||||
passwords: { label: 'Open settings', href: '/admin/settings/security' },
|
passwords: { label: 'Open settings', href: '/admin/settings/security' },
|
||||||
'album-ids': { label: 'Open pages', href: '/admin/pages' },
|
'album-ids': { label: 'Open pages', href: '/admin/pages' },
|
||||||
'albums-shared': { label: 'Open pages', href: '/admin/pages' },
|
'albums-shared': { label: 'Open pages', href: '/admin/pages' },
|
||||||
|
legal: { label: 'Open settings', href: '/admin/settings/legal' },
|
||||||
|
contact: { label: 'Open settings', href: '/admin/settings/legal' },
|
||||||
|
privacy: { label: 'Open settings', href: '/admin/settings/legal' },
|
||||||
'content-writable': { label: 'How to fix', href: `${DOCS}/docs/admin-panel.md#docker-usage` },
|
'content-writable': { label: 'How to fix', href: `${DOCS}/docs/admin-panel.md#docker-usage` },
|
||||||
'immich-api': {
|
'immich-api': {
|
||||||
label: 'API key permissions',
|
label: 'API key permissions',
|
||||||
@@ -371,27 +380,26 @@ export default function DiagnosticsView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="diag-page">
|
<div className="diag-page">
|
||||||
<div className="diag-head">
|
<PageHeader
|
||||||
<div>
|
kicker="System"
|
||||||
<h2>{headline}</h2>
|
title={headline}
|
||||||
<p className="diag-sub">
|
description={ranAt ? `Last run ${ranAt.toLocaleString()}` : 'Running the checks…'}
|
||||||
{ranAt ? `Last run ${ranAt.toLocaleString()}` : 'Running the checks…'}
|
actions={
|
||||||
</p>
|
<>
|
||||||
</div>
|
<button className="admin-btn admin-btn-sm" onClick={run} disabled={loading}>
|
||||||
<div className="diag-actions">
|
<Icons.IconRefresh size={14} /> {loading ? 'Checking…' : 'Run again'}
|
||||||
<button className="admin-btn admin-btn-sm" onClick={run} disabled={loading}>
|
</button>
|
||||||
<Icons.IconRefresh size={14} /> {loading ? 'Checking…' : 'Run again'}
|
<button
|
||||||
</button>
|
className="admin-btn admin-btn-sm"
|
||||||
<button
|
onClick={copyReport}
|
||||||
className="admin-btn admin-btn-sm"
|
disabled={loading || !all.length}
|
||||||
onClick={copyReport}
|
title="Markdown, ready to paste into an issue"
|
||||||
disabled={loading || !all.length}
|
>
|
||||||
title="Markdown, ready to paste into an issue"
|
<Icons.IconCopy size={14} /> {copied ? 'Copied' : 'Copy report'}
|
||||||
>
|
</button>
|
||||||
<Icons.IconCopy size={14} /> {copied ? 'Copied' : 'Copy report'}
|
</>
|
||||||
</button>
|
}
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <div className="admin-error">{error}</div>}
|
{error && <div className="admin-error">{error}</div>}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import * as Icons from './Icons';
|
import * as Icons from './Icons';
|
||||||
import { en } from '@/lib/i18n/locales/en';
|
import { en } from '@/lib/i18n/locales/en';
|
||||||
import { LIGHTBOX_SHORTCUTS, shortcutDisplayKeys } from '@/lib/lightboxShortcuts';
|
import { LIGHTBOX_SHORTCUTS, shortcutDisplayKeys } from '@/lib/lightboxShortcuts';
|
||||||
|
import PageHeader from './PageHeader';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The admin help section.
|
* The admin help section.
|
||||||
@@ -19,14 +20,11 @@ import { LIGHTBOX_SHORTCUTS, shortcutDisplayKeys } from '@/lib/lightboxShortcuts
|
|||||||
export default function HelpView() {
|
export default function HelpView() {
|
||||||
return (
|
return (
|
||||||
<div className="settings-panel">
|
<div className="settings-panel">
|
||||||
<div className="settings-section-header">
|
<PageHeader
|
||||||
<h3>
|
kicker="System"
|
||||||
<Icons.IconFileText size={18} /> Help
|
title="Help"
|
||||||
</h3>
|
description="What your portfolio can do that has no button of its own."
|
||||||
<p className="settings-section-sub">
|
/>
|
||||||
What your portfolio can do that has no button of its own.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="settings-group">
|
<div className="settings-group">
|
||||||
<div className="settings-group-head">
|
<div className="settings-group-head">
|
||||||
|
|||||||
192
app/admin/components/MessagesView.tsx
Normal file
192
app/admin/components/MessagesView.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The contact form inbox at /admin/messages (#702). Messages live in
|
||||||
|
* content/messages/ (lib/contact.ts); replying happens in the owner's own
|
||||||
|
* mail client through a mailto: link, since Folio sends no mail.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import * as Icons from './Icons';
|
||||||
|
import { reportIfSessionExpired } from './sessionExpiry';
|
||||||
|
import { useAdminFetch } from './useAdminFetch';
|
||||||
|
import AdminLoadState from './AdminLoadState';
|
||||||
|
import type { ContactMessage } from '@/lib/contact';
|
||||||
|
import { useNotify } from './Notifications';
|
||||||
|
import PageHeader from './PageHeader';
|
||||||
|
import { useConfirm } from './ConfirmDialog';
|
||||||
|
|
||||||
|
interface InboxData {
|
||||||
|
enabled: boolean;
|
||||||
|
notifyConfigured: boolean;
|
||||||
|
retentionDays: number;
|
||||||
|
messages: ContactMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function replyHref(m: ContactMessage): string {
|
||||||
|
const subject = encodeURIComponent('Re: your message');
|
||||||
|
const quoted = m.message
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => `> ${line}`)
|
||||||
|
.join('\n');
|
||||||
|
const body = encodeURIComponent(`\n\n${m.name} wrote:\n${quoted}`);
|
||||||
|
// Encoded so a ? or & in the address cannot start the query, with the @
|
||||||
|
// put back: not every mail client decodes %40 in the address part.
|
||||||
|
const to = encodeURIComponent(m.email).replace(/%40/g, '@');
|
||||||
|
return `mailto:${to}?subject=${subject}&body=${body}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessagesView() {
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const notify = useNotify();
|
||||||
|
const inbox = useAdminFetch<InboxData>('/api/admin/messages');
|
||||||
|
const data = inbox.data;
|
||||||
|
const [open, setOpen] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function markRead(m: ContactMessage, read: boolean) {
|
||||||
|
const res = await fetch('/api/admin/messages', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: m.id, read }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
reportIfSessionExpired(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
inbox.mutate((d) => ({
|
||||||
|
...d,
|
||||||
|
messages: d.messages.map((x) => (x.id === m.id ? { ...x, read } : x)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(m: ContactMessage) {
|
||||||
|
const opening = open !== m.id;
|
||||||
|
setOpen(opening ? m.id : null);
|
||||||
|
if (opening && !m.read) markRead(m, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(m: ContactMessage) {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: `Delete the message from ${m.name}?`,
|
||||||
|
message: 'It is removed from this server and cannot be restored.',
|
||||||
|
confirmLabel: 'Delete',
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
const res = await fetch(`/api/admin/messages?id=${encodeURIComponent(m.id)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
if (!reportIfSessionExpired(res)) notify('error', `Delete failed (HTTP ${res.status}).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
inbox.mutate((d) => ({ ...d, messages: d.messages.filter((x) => x.id !== m.id) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || inbox.error) {
|
||||||
|
return (
|
||||||
|
<AdminLoadState
|
||||||
|
loading={inbox.loading}
|
||||||
|
error={inbox.error}
|
||||||
|
onRetry={inbox.reload}
|
||||||
|
hasData={!!data}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unread = data.messages.filter((m) => !m.read).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="messages-view">
|
||||||
|
<PageHeader
|
||||||
|
kicker="Visitors"
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
Messages
|
||||||
|
{unread > 0 && <span className="messages-count">{unread} unread</span>}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
description={`Sent through the contact form at /contact. Stored on this server and deleted after ${data.retentionDays} days.`}
|
||||||
|
actions={
|
||||||
|
<button className="admin-btn admin-btn-ghost admin-btn-sm" onClick={inbox.reload}>
|
||||||
|
<Icons.IconRefresh size={14} /> Refresh
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!data.enabled && (
|
||||||
|
<div className="messages-notice">
|
||||||
|
The contact form is switched off, so no new messages arrive.{' '}
|
||||||
|
<Link href="/admin/settings/legal">Turn it on in Settings → Legal</Link>.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.enabled && !data.notifyConfigured && (
|
||||||
|
<div className="messages-notice">
|
||||||
|
No notification is set up, so new messages only show up here.{' '}
|
||||||
|
<Link href="/admin/settings/legal">Add an ntfy URL in Settings → Legal</Link>.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.messages.length === 0 ? (
|
||||||
|
<div className="analytics-panel messages-empty">No messages.</div>
|
||||||
|
) : (
|
||||||
|
<ul className="messages-list">
|
||||||
|
{data.messages.map((m) => {
|
||||||
|
const expanded = open === m.id;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={m.id}
|
||||||
|
className={`messages-item${m.read ? '' : ' messages-item--unread'}${expanded ? ' messages-item--open' : ''}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="messages-summary"
|
||||||
|
onClick={() => toggle(m)}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
>
|
||||||
|
<span className="messages-dot" aria-hidden="true" />
|
||||||
|
<span className="messages-from">
|
||||||
|
{m.name}
|
||||||
|
{!m.read && <span className="sr-only"> (unread)</span>}
|
||||||
|
</span>
|
||||||
|
<span className="messages-preview">{m.message.slice(0, 120)}</span>
|
||||||
|
<time className="messages-date" dateTime={m.receivedAt}>
|
||||||
|
{new Date(m.receivedAt).toLocaleString()}
|
||||||
|
</time>
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<div className="messages-body">
|
||||||
|
<p className="messages-meta">
|
||||||
|
{m.name} <{m.email}>
|
||||||
|
</p>
|
||||||
|
<p className="messages-text">{m.message}</p>
|
||||||
|
<div className="messages-actions">
|
||||||
|
<a className="admin-btn admin-btn-primary admin-btn-sm" href={replyHref(m)}>
|
||||||
|
Reply by email
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-btn admin-btn-ghost admin-btn-sm"
|
||||||
|
onClick={() => markRead(m, false)}
|
||||||
|
>
|
||||||
|
Mark unread
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-btn admin-btn-ghost admin-btn-sm"
|
||||||
|
onClick={() => remove(m)}
|
||||||
|
>
|
||||||
|
<Icons.IconTrash size={14} /> Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
app/admin/components/Notifications.tsx
Normal file
108
app/admin/components/Notifications.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The admin's one place to say that something happened (#600).
|
||||||
|
*
|
||||||
|
* Replaces `alert()`, which blocked the page, could not be styled and said
|
||||||
|
* nothing to a screen reader beyond the dialog itself. A notification here has
|
||||||
|
* an explicit kind rather than one guessed from its wording, can be dismissed,
|
||||||
|
* and is announced: errors through an assertive live region, successes through
|
||||||
|
* a polite one. Successes leave on their own after a few seconds; errors stay
|
||||||
|
* until dismissed, because an error nobody saw did not get reported.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react';
|
||||||
|
import { IconX } from './Icons';
|
||||||
|
|
||||||
|
export type NotificationKind = 'success' | 'error';
|
||||||
|
|
||||||
|
/** One button on a notification, e.g. Undo after a removal. */
|
||||||
|
export interface NotificationAction {
|
||||||
|
label: string;
|
||||||
|
run: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Notification {
|
||||||
|
id: number;
|
||||||
|
kind: NotificationKind;
|
||||||
|
message: string;
|
||||||
|
action?: NotificationAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Notify = (kind: NotificationKind, message: string, action?: NotificationAction) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outside the provider (a component rendered on its own in a test) notifying
|
||||||
|
* does nothing rather than throwing.
|
||||||
|
*/
|
||||||
|
const NotifyContext = createContext<Notify>(() => {});
|
||||||
|
|
||||||
|
export function useNotify(): Notify {
|
||||||
|
return useContext(NotifyContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUCCESS_MS = 4000;
|
||||||
|
const ACTION_MS = 8000;
|
||||||
|
|
||||||
|
export function NotificationProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [items, setItems] = useState<Notification[]>([]);
|
||||||
|
const nextId = useRef(1);
|
||||||
|
|
||||||
|
const dismiss = useCallback((id: number) => {
|
||||||
|
setItems((list) => list.filter((n) => n.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const notify = useCallback<Notify>(
|
||||||
|
(kind, message, action) => {
|
||||||
|
const id = nextId.current++;
|
||||||
|
setItems((list) => [...list, { id, kind, message, action }]);
|
||||||
|
// Longer with an action: there has to be time to reach the button.
|
||||||
|
if (kind === 'success') setTimeout(() => dismiss(id), action ? ACTION_MS : SUCCESS_MS);
|
||||||
|
},
|
||||||
|
[dismiss],
|
||||||
|
);
|
||||||
|
|
||||||
|
const render = (kind: NotificationKind) =>
|
||||||
|
items
|
||||||
|
.filter((n) => n.kind === kind)
|
||||||
|
.map((n) => (
|
||||||
|
<div key={n.id} className={`admin-notification admin-notification--${n.kind}`}>
|
||||||
|
<span className="admin-notification__text">{n.message}</span>
|
||||||
|
{n.action && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-notification__action"
|
||||||
|
onClick={() => {
|
||||||
|
n.action!.run();
|
||||||
|
dismiss(n.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{n.action.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-notification__close"
|
||||||
|
onClick={() => dismiss(n.id)}
|
||||||
|
aria-label="Dismiss"
|
||||||
|
>
|
||||||
|
<IconX size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NotifyContext.Provider value={notify}>
|
||||||
|
{children}
|
||||||
|
<div className="admin-notifications">
|
||||||
|
{/* Two regions, because politeness belongs to the region, not the message. */}
|
||||||
|
<div role="alert" aria-live="assertive" className="admin-notifications__group">
|
||||||
|
{render('error')}
|
||||||
|
</div>
|
||||||
|
<div role="status" aria-live="polite" className="admin-notifications__group">
|
||||||
|
{render('success')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NotifyContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
29
app/admin/components/PageHeader.tsx
Normal file
29
app/admin/components/PageHeader.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one screen header (UX stage 2, docs/admin-ux-concept.md): kicker with
|
||||||
|
* the site's square marker, title, one sentence, actions on the right. Every
|
||||||
|
* screen used to build its own, each with a different size and position.
|
||||||
|
*/
|
||||||
|
export default function PageHeader({
|
||||||
|
kicker,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
}: {
|
||||||
|
kicker: string;
|
||||||
|
title: ReactNode;
|
||||||
|
description?: ReactNode;
|
||||||
|
actions?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<header className="page-header">
|
||||||
|
<div className="page-header-text">
|
||||||
|
<p className="page-header-kicker">{kicker}</p>
|
||||||
|
<h1 className="page-header-title">{title}</h1>
|
||||||
|
{description && <p className="page-header-sub">{description}</p>}
|
||||||
|
</div>
|
||||||
|
{actions && <div className="page-header-actions">{actions}</div>}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
app/admin/components/PrivacyEditor.tsx
Normal file
168
app/admin/components/PrivacyEditor.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editor for content/privacy.md (#699), in Settings → Legal.
|
||||||
|
*
|
||||||
|
* Folio writes no legal text. Next to the editor it lists what this
|
||||||
|
* installation processes, read off the configuration, and "Insert headings"
|
||||||
|
* adds the section headings that list implies, nothing more.
|
||||||
|
*
|
||||||
|
* Saves on its own, like the About page: the policy is a Markdown file, not a
|
||||||
|
* key in settings.yaml.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import * as Icons from './Icons';
|
||||||
|
import { reportIfSessionExpired } from './sessionExpiry';
|
||||||
|
import { useUnsavedGuard } from './useUnsavedGuard';
|
||||||
|
import type { ProcessingFact } from '@/lib/privacy';
|
||||||
|
|
||||||
|
interface PrivacyData {
|
||||||
|
body: string;
|
||||||
|
enabled: boolean;
|
||||||
|
facts: ProcessingFact[];
|
||||||
|
starter: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Status = { kind: 'success' | 'error'; message: string } | null;
|
||||||
|
|
||||||
|
export default function PrivacyEditor() {
|
||||||
|
const [data, setData] = useState<PrivacyData | null>(null);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const [body, setBody] = useState('');
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [status, setStatus] = useState<Status>(null);
|
||||||
|
|
||||||
|
useUnsavedGuard(dirty);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
fetch('/api/admin/privacy')
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
reportIfSessionExpired(res);
|
||||||
|
throw new Error(`HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return (await res.json()) as PrivacyData;
|
||||||
|
})
|
||||||
|
.then((d) => {
|
||||||
|
if (!live) return;
|
||||||
|
setData(d);
|
||||||
|
setBody(d.body);
|
||||||
|
})
|
||||||
|
.catch((err: Error) => live && setLoadError(err.message));
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true);
|
||||||
|
setStatus(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/privacy', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ body }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
if (!reportIfSessionExpired(res)) {
|
||||||
|
setStatus({ kind: 'error', message: `Could not save (HTTP ${res.status}).` });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDirty(false);
|
||||||
|
setStatus({ kind: 'success', message: 'Privacy policy saved.' });
|
||||||
|
} catch {
|
||||||
|
setStatus({ kind: 'error', message: 'Could not reach the server.' });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertHeadings() {
|
||||||
|
if (!data) return;
|
||||||
|
setBody((b) => (b.trim() ? `${b.trimEnd()}\n\n${data.starter}\n` : `${data.starter}\n`));
|
||||||
|
setDirty(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadError) {
|
||||||
|
// No editor over an unknown file: saving an empty one would erase the policy.
|
||||||
|
return (
|
||||||
|
<p className="admin-field-hint admin-field-hint--error">
|
||||||
|
The privacy policy could not be loaded ({loadError}). Reload the page to try again.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!data) return <div className="admin-spinner" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="privacy-editor">
|
||||||
|
<div className="privacy-editor__facts">
|
||||||
|
<h4>What this site processes</h4>
|
||||||
|
<p className="admin-field-hint">
|
||||||
|
Read off your current configuration. A checklist for writing the policy, not text for the
|
||||||
|
page.
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
{data.facts.map((f) => (
|
||||||
|
<li key={f.topic}>
|
||||||
|
<strong>{f.topic}</strong>
|
||||||
|
{f.thirdParty && <span className="privacy-editor__badge">third party</span>}
|
||||||
|
<span>{f.detail}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="privacy-body">Privacy policy (Markdown)</label>
|
||||||
|
<textarea
|
||||||
|
id="privacy-body"
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => {
|
||||||
|
setBody(e.target.value);
|
||||||
|
setDirty(true);
|
||||||
|
}}
|
||||||
|
rows={18}
|
||||||
|
placeholder="## Controller Name, address, email…"
|
||||||
|
/>
|
||||||
|
<p className="admin-field-hint">
|
||||||
|
Headings with <code>##</code>, lists with <code>-</code>, <code>**bold**</code> and{' '}
|
||||||
|
<code>[links](https://…)</code>. Shown at /privacy and linked in the footer once there is
|
||||||
|
text. Earlier versions are kept under Backups.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="privacy-editor__actions">
|
||||||
|
<button type="button" className="admin-btn admin-btn-ghost" onClick={insertHeadings}>
|
||||||
|
<Icons.IconPlus size={14} /> Insert headings
|
||||||
|
</button>
|
||||||
|
{body.trim() && data.enabled && (
|
||||||
|
<a
|
||||||
|
className="admin-btn admin-btn-ghost"
|
||||||
|
href="/privacy"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
View /privacy ↗
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-btn admin-btn-primary"
|
||||||
|
onClick={save}
|
||||||
|
disabled={!dirty || saving}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : 'Save privacy policy'}
|
||||||
|
</button>
|
||||||
|
{status && (
|
||||||
|
<span className={`save-message ${status.kind}`} role="status">
|
||||||
|
{status.message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The outcome of the last save. Typed rather than read off the wording: the bar
|
||||||
|
* used to call a message an error when it started with "Error", so a failure
|
||||||
|
* worded any other way was shown as a success (#600).
|
||||||
|
*/
|
||||||
|
export type SaveStatus = { kind: 'success' | 'error'; message: string } | null;
|
||||||
|
|
||||||
interface SaveBarProps {
|
interface SaveBarProps {
|
||||||
dirty: boolean;
|
dirty: boolean;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
saveMessage: string;
|
status: SaveStatus;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
label: string;
|
label: string;
|
||||||
/** Show the cache-bypassing preview link (page builder only). */
|
/** Show the cache-bypassing preview link (page builder only). */
|
||||||
@@ -21,20 +28,18 @@ interface SaveBarProps {
|
|||||||
export default function SaveBar({
|
export default function SaveBar({
|
||||||
dirty,
|
dirty,
|
||||||
saving,
|
saving,
|
||||||
saveMessage,
|
status,
|
||||||
onSave,
|
onSave,
|
||||||
label,
|
label,
|
||||||
showPreview = false,
|
showPreview = false,
|
||||||
}: SaveBarProps) {
|
}: SaveBarProps) {
|
||||||
if (!dirty && !saving && !saveMessage) return null;
|
if (!dirty && !saving && !status) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`floating-save-bar ${dirty ? 'dirty' : ''}`} role="status">
|
<div className={`floating-save-bar ${dirty ? 'dirty' : ''}`} role="status">
|
||||||
<div className="save-bar-left">
|
<div className="save-bar-left">
|
||||||
{saveMessage ? (
|
{status ? (
|
||||||
<span className={`save-message ${saveMessage.startsWith('Error') ? 'error' : 'success'}`}>
|
<span className={`save-message ${status.kind}`}>{status.message}</span>
|
||||||
{saveMessage}
|
|
||||||
</span>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="unsaved-badge">
|
<span className="unsaved-badge">
|
||||||
<span className="badge-pulse-dot" aria-hidden="true" />
|
<span className="badge-pulse-dot" aria-hidden="true" />
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
|
|||||||
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
|
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
|
||||||
import AdminDashboard from '../AdminDashboard';
|
import AdminDashboard from '../AdminDashboard';
|
||||||
import { SESSION_EXPIRED_EVENT } from '../sessionExpiry';
|
import { SESSION_EXPIRED_EVENT } from '../sessionExpiry';
|
||||||
|
import { NotificationProvider } from '../Notifications';
|
||||||
|
|
||||||
vi.mock('next/navigation', () => ({
|
vi.mock('next/navigation', () => ({
|
||||||
usePathname: () => '/admin',
|
usePathname: () => '/admin',
|
||||||
@@ -16,8 +17,6 @@ afterEach(cleanup);
|
|||||||
*/
|
*/
|
||||||
describe('AdminDashboard reload failure reporting', () => {
|
describe('AdminDashboard reload failure reporting', () => {
|
||||||
it('reports a non-ok, non-401 reload failure', async () => {
|
it('reports a non-ok, non-401 reload failure', async () => {
|
||||||
const alertMock = vi.fn();
|
|
||||||
vi.stubGlobal('alert', alertMock);
|
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'fetch',
|
||||||
vi.fn(async (url: string, init?: RequestInit) => {
|
vi.fn(async (url: string, init?: RequestInit) => {
|
||||||
@@ -29,19 +28,22 @@ describe('AdminDashboard reload failure reporting', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<AdminDashboard onLogout={() => {}}>
|
<NotificationProvider>
|
||||||
<div>Panel</div>
|
<AdminDashboard onLogout={() => {}}>
|
||||||
</AdminDashboard>,
|
<div>Panel</div>
|
||||||
|
</AdminDashboard>
|
||||||
|
</NotificationProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByTitle('Reload config & clear cache'));
|
fireEvent.click(screen.getByTitle('Reload config & clear cache'));
|
||||||
|
|
||||||
await waitFor(() => expect(alertMock).toHaveBeenCalledWith('Reload failed (HTTP 500).'));
|
// Reported through the notification surface (#600), not alert().
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole('alert').textContent).toContain('Reload failed (HTTP 500).'),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('raises the session-expired event instead of alerting on a 401', async () => {
|
it('raises the session-expired event instead of reporting an error on a 401', async () => {
|
||||||
const alertMock = vi.fn();
|
|
||||||
vi.stubGlobal('alert', alertMock);
|
|
||||||
const listener = vi.fn();
|
const listener = vi.fn();
|
||||||
window.addEventListener(SESSION_EXPIRED_EVENT, listener);
|
window.addEventListener(SESSION_EXPIRED_EVENT, listener);
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
@@ -55,21 +57,21 @@ describe('AdminDashboard reload failure reporting', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<AdminDashboard onLogout={() => {}}>
|
<NotificationProvider>
|
||||||
<div>Panel</div>
|
<AdminDashboard onLogout={() => {}}>
|
||||||
</AdminDashboard>,
|
<div>Panel</div>
|
||||||
|
</AdminDashboard>
|
||||||
|
</NotificationProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByTitle('Reload config & clear cache'));
|
fireEvent.click(screen.getByTitle('Reload config & clear cache'));
|
||||||
|
|
||||||
await waitFor(() => expect(listener).toHaveBeenCalledOnce());
|
await waitFor(() => expect(listener).toHaveBeenCalledOnce());
|
||||||
expect(alertMock).not.toHaveBeenCalled();
|
expect(screen.getByRole('alert').textContent).toBe('');
|
||||||
window.removeEventListener(SESSION_EXPIRED_EVENT, listener);
|
window.removeEventListener(SESSION_EXPIRED_EVENT, listener);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not alert when the reload succeeds', async () => {
|
it('reports no error when the reload succeeds', async () => {
|
||||||
const alertMock = vi.fn();
|
|
||||||
vi.stubGlobal('alert', alertMock);
|
|
||||||
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
||||||
if (url === '/api/admin/reload' && init?.method === 'POST') {
|
if (url === '/api/admin/reload' && init?.method === 'POST') {
|
||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
@@ -79,9 +81,11 @@ describe('AdminDashboard reload failure reporting', () => {
|
|||||||
vi.stubGlobal('fetch', fetchMock);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<AdminDashboard onLogout={() => {}}>
|
<NotificationProvider>
|
||||||
<div>Panel</div>
|
<AdminDashboard onLogout={() => {}}>
|
||||||
</AdminDashboard>,
|
<div>Panel</div>
|
||||||
|
</AdminDashboard>
|
||||||
|
</NotificationProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByTitle('Reload config & clear cache'));
|
fireEvent.click(screen.getByTitle('Reload config & clear cache'));
|
||||||
@@ -93,6 +97,6 @@ describe('AdminDashboard reload failure reporting', () => {
|
|||||||
),
|
),
|
||||||
).toHaveLength(1),
|
).toHaveLength(1),
|
||||||
);
|
);
|
||||||
expect(alertMock).not.toHaveBeenCalled();
|
expect(screen.getByRole('alert').textContent).toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
40
app/admin/components/contentRestored.ts
Normal file
40
app/admin/components/contentRestored.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import type { BackupTarget } from '@/app/api/admin/backups/route';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A backup restore rewrote a content file behind the editors' backs.
|
||||||
|
*
|
||||||
|
* The page builder, settings and journal editors stay mounted in their
|
||||||
|
* layouts, holding what they loaded before the restore. Left alone, the next
|
||||||
|
* save wrote that older state straight over the restored file. So the backup
|
||||||
|
* manager announces the restore and each editor reloads the file it owns —
|
||||||
|
* through its draft, so unsaved edits come back as a `conflict` the operator
|
||||||
|
* decides on instead of vanishing or winning silently.
|
||||||
|
*
|
||||||
|
* Same pattern as SESSION_EXPIRED_EVENT in sessionExpiry.ts.
|
||||||
|
*/
|
||||||
|
export const CONTENT_RESTORED_EVENT = 'folio:admin-content-restored';
|
||||||
|
|
||||||
|
export interface ContentRestored {
|
||||||
|
target: BackupTarget;
|
||||||
|
/** The journal entry, for `journal`. */
|
||||||
|
slug?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reportContentRestored(detail: ContentRestored): void {
|
||||||
|
window.dispatchEvent(new CustomEvent<ContentRestored>(CONTENT_RESTORED_EVENT, { detail }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useContentRestored(handler: (detail: ContentRestored) => void): void {
|
||||||
|
const handlerRef = useRef(handler);
|
||||||
|
useEffect(() => {
|
||||||
|
handlerRef.current = handler;
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
const onRestored = (e: Event) => handlerRef.current((e as CustomEvent<ContentRestored>).detail);
|
||||||
|
window.addEventListener(CONTENT_RESTORED_EVENT, onRestored);
|
||||||
|
return () => window.removeEventListener(CONTENT_RESTORED_EVENT, onRestored);
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import { useId, type ReactNode } from 'react';
|
||||||
|
|
||||||
type GridOverride = { columns?: number; gap?: number };
|
type GridOverride = { columns?: number; gap?: number };
|
||||||
|
|
||||||
@@ -50,16 +50,20 @@ export default function GridOverrideFields({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const labelId = useId();
|
||||||
return (
|
return (
|
||||||
<div className="admin-field" style={{ marginTop: '1rem' }}>
|
<div className="admin-field" style={{ marginTop: '1rem' }}>
|
||||||
<label>{label}</label>
|
<span className="admin-field-label" id={labelId}>
|
||||||
<div style={{ display: 'flex', gap: '12px' }}>
|
{label}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', gap: '12px' }} role="group" aria-labelledby={labelId}>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={columnsMin}
|
min={columnsMin}
|
||||||
max={columnsMax}
|
max={columnsMax}
|
||||||
value={value?.columns ?? ''}
|
value={value?.columns ?? ''}
|
||||||
placeholder="Columns (site default)"
|
placeholder="Columns (site default)"
|
||||||
|
aria-label="Columns"
|
||||||
onChange={(e) => patch('columns', e.target.value, columnsMin, columnsMax)}
|
onChange={(e) => patch('columns', e.target.value, columnsMin, columnsMax)}
|
||||||
style={NUMBER_INPUT_STYLE}
|
style={NUMBER_INPUT_STYLE}
|
||||||
/>
|
/>
|
||||||
@@ -69,6 +73,7 @@ export default function GridOverrideFields({
|
|||||||
max={gapMax}
|
max={gapMax}
|
||||||
value={value?.gap ?? ''}
|
value={value?.gap ?? ''}
|
||||||
placeholder="Gap in px (theme default)"
|
placeholder="Gap in px (theme default)"
|
||||||
|
aria-label="Gap in px"
|
||||||
onChange={(e) => patch('gap', e.target.value, 0, gapMax)}
|
onChange={(e) => patch('gap', e.target.value, 0, gapMax)}
|
||||||
style={NUMBER_INPUT_STYLE}
|
style={NUMBER_INPUT_STYLE}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { CSSProperties, ReactNode } from 'react';
|
import { useId, type CSSProperties, type ReactNode } from 'react';
|
||||||
|
|
||||||
export type CardOption = { value: string; label: string; desc: string };
|
export type CardOption = { value: string; label: string; desc: string };
|
||||||
|
|
||||||
@@ -55,10 +55,14 @@ export default function OptionGrid({
|
|||||||
renderSpecs?: (option: CardOption) => ReactNode;
|
renderSpecs?: (option: CardOption) => ReactNode;
|
||||||
cardStyle?: (option: CardOption) => CSSProperties | undefined;
|
cardStyle?: (option: CardOption) => CSSProperties | undefined;
|
||||||
}) {
|
}) {
|
||||||
|
// A group of buttons, not one control, so it is named as a group (#694 §4).
|
||||||
|
const labelId = useId();
|
||||||
return (
|
return (
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label>{label}</label>
|
<span className="admin-field-label" id={labelId}>
|
||||||
<div className="preset-card-grid">
|
{label}
|
||||||
|
</span>
|
||||||
|
<div className="preset-card-grid" role="group" aria-labelledby={labelId}>
|
||||||
{options.map((option) => {
|
{options.map((option) => {
|
||||||
const isActive = value === option.value;
|
const isActive = value === option.value;
|
||||||
return (
|
return (
|
||||||
|
|||||||
120
app/admin/components/fields/PasswordField.tsx
Normal file
120
app/admin/components/fields/PasswordField.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useId, useState } from 'react';
|
||||||
|
import { IconEye, IconEyeOff, IconLock } from '../Icons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One password input for every place the admin sets one: subpage, album,
|
||||||
|
* journal entry and site password (#690).
|
||||||
|
*
|
||||||
|
* The save routes store passwords as scrypt hashes (lib/admin/passwordHashing.ts),
|
||||||
|
* so a saved password comes back as `scrypt:…`. That value is never put into
|
||||||
|
* the input: showing it would invite editing a hash, and saving an edited hash
|
||||||
|
* would lock the page with the hash string. Instead the field reads
|
||||||
|
* "Protected", with Change and Remove.
|
||||||
|
*
|
||||||
|
* A plaintext value from a hand-written file is shown in the input as before;
|
||||||
|
* the next save stores it hashed.
|
||||||
|
*/
|
||||||
|
export default function PasswordField({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Leave empty for public access',
|
||||||
|
disabled,
|
||||||
|
label,
|
||||||
|
id: idProp,
|
||||||
|
}: {
|
||||||
|
value: string | undefined;
|
||||||
|
/** `undefined` removes the password. */
|
||||||
|
onChange: (value: string | undefined) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Accessible name when there is no visible <label> for the field. */
|
||||||
|
label?: string;
|
||||||
|
/** For a visible <label htmlFor>; generated otherwise. */
|
||||||
|
id?: string;
|
||||||
|
}) {
|
||||||
|
const generatedId = useId();
|
||||||
|
const id = idProp ?? generatedId;
|
||||||
|
/** The stored hash while it is being changed, so "Keep" can put it back. */
|
||||||
|
const [original, setOriginal] = useState<string | null>(null);
|
||||||
|
const [revealed, setRevealed] = useState(false);
|
||||||
|
const isHash = !!value && value.startsWith('scrypt:');
|
||||||
|
// Derived rather than stored: a save that brings back a new hash ends the
|
||||||
|
// change without the field having to be told.
|
||||||
|
const changing = original !== null && (value === original || !isHash);
|
||||||
|
|
||||||
|
if (isHash && !changing) {
|
||||||
|
return (
|
||||||
|
<div className="password-field password-field--set">
|
||||||
|
<span className="password-field__status">
|
||||||
|
<IconLock size={12} /> Protected
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-btn admin-btn-ghost admin-btn-sm"
|
||||||
|
// The hash stays in the form until something is typed, so opening
|
||||||
|
// the field and saving is not a way to lose the password.
|
||||||
|
onClick={() => setOriginal(value)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
Change
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-btn admin-btn-ghost admin-btn-sm"
|
||||||
|
onClick={() => onChange(undefined)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="password-field">
|
||||||
|
<div className="password-input-wrapper">
|
||||||
|
<span className="password-icon" aria-hidden="true">
|
||||||
|
<IconLock size={12} />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type={revealed ? 'text' : 'password'}
|
||||||
|
value={changing && value === original ? '' : (value ?? '')}
|
||||||
|
// Emptying the field while changing falls back to the stored hash;
|
||||||
|
// only Remove takes a password away.
|
||||||
|
onChange={(e) => onChange(e.target.value || (changing ? original! : undefined))}
|
||||||
|
placeholder={changing ? 'New password' : placeholder}
|
||||||
|
aria-label={label}
|
||||||
|
autoComplete="new-password"
|
||||||
|
disabled={disabled}
|
||||||
|
autoFocus={changing}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="password-field__reveal"
|
||||||
|
onClick={() => setRevealed((r) => !r)}
|
||||||
|
aria-label={revealed ? 'Hide password' : 'Show password'}
|
||||||
|
aria-pressed={revealed}
|
||||||
|
aria-controls={id}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{revealed ? <IconEyeOff size={14} /> : <IconEye size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{changing && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-btn admin-btn-ghost admin-btn-sm password-field__cancel"
|
||||||
|
onClick={() => {
|
||||||
|
onChange(original);
|
||||||
|
setOriginal(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Keep current password
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,12 +30,14 @@ import { useUnsavedGuard } from '../useUnsavedGuard';
|
|||||||
import { useDraft } from '../useDraft';
|
import { useDraft } from '../useDraft';
|
||||||
import DraftNotice from '../DraftNotice';
|
import DraftNotice from '../DraftNotice';
|
||||||
import { reportIfSessionExpired } from '../sessionExpiry';
|
import { reportIfSessionExpired } from '../sessionExpiry';
|
||||||
|
import { useContentRestored } from '../contentRestored';
|
||||||
import './journal-studio.css';
|
import './journal-studio.css';
|
||||||
import { BlockFields, type AssetPickTarget } from './BlockFields';
|
import { BlockFields, type AssetPickTarget } from './BlockFields';
|
||||||
import { StorySettingsModal } from './StorySettingsModal';
|
import { StorySettingsModal } from './StorySettingsModal';
|
||||||
import { JournalPreview } from './JournalPreview';
|
import { JournalPreview } from './JournalPreview';
|
||||||
import { createBlock, moveBlock } from './blockOps';
|
import { createBlock, moveBlock } from './blockOps';
|
||||||
import { useSplitPane, SPLIT_MIN, SPLIT_MAX } from './splitPane';
|
import { useSplitPane, SPLIT_MIN, SPLIT_MAX } from './splitPane';
|
||||||
|
import { useNotify } from '../Notifications';
|
||||||
|
|
||||||
interface JournalEditorProps {
|
interface JournalEditorProps {
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -44,6 +46,7 @@ interface JournalEditorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps) {
|
export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps) {
|
||||||
|
const notify = useNotify();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
@@ -82,6 +85,12 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
|||||||
const loadDraft = draft.load;
|
const loadDraft = draft.load;
|
||||||
const serverMarkdown = useRef('');
|
const serverMarkdown = useRef('');
|
||||||
|
|
||||||
|
// Bumped when this entry is restored from a backup, to load it again.
|
||||||
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
|
useContentRestored(({ target, slug: restored }) => {
|
||||||
|
if (target === 'journal' && restored === slug) setReloadKey((k) => k + 1);
|
||||||
|
});
|
||||||
|
|
||||||
// Load entry
|
// Load entry
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -113,7 +122,7 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
load();
|
load();
|
||||||
}, [slug, loadDraft]);
|
}, [slug, loadDraft, reloadKey]);
|
||||||
|
|
||||||
// Update markdown and sync blocks
|
// Update markdown and sync blocks
|
||||||
const handleMarkdownChange = (newMd: string) => {
|
const handleMarkdownChange = (newMd: string) => {
|
||||||
@@ -154,6 +163,9 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
|||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
// The editor is not rendered in this state, but Cmd+S still reaches here.
|
// The editor is not rendered in this state, but Cmd+S still reaches here.
|
||||||
if (loadError) return;
|
if (loadError) return;
|
||||||
|
// Nothing to save: every save rotates a backup, so repeated Cmd+S on an
|
||||||
|
// unchanged entry pushed real history out of the ten kept per file.
|
||||||
|
if (!dirty || saving) return;
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -166,15 +178,25 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
serverMarkdown.current = rawMarkdown;
|
// The file as written, not as sent: a password line is hashed on the
|
||||||
draft.saved(rawMarkdown);
|
// way to disk (#690). Taking the sent text as the base would make the
|
||||||
|
// next draft look like a conflicting edit from elsewhere.
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
const written: string =
|
||||||
|
typeof data?.entry?.rawMarkdown === 'string' ? data.entry.rawMarkdown : rawMarkdown;
|
||||||
|
serverMarkdown.current = written;
|
||||||
|
draft.saved(written);
|
||||||
|
if (written !== rawMarkdown) {
|
||||||
|
setRawMarkdown(written);
|
||||||
|
setParsed(parseJournalMarkdown(written));
|
||||||
|
}
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
} else if (!reportIfSessionExpired(res)) {
|
} else if (!reportIfSessionExpired(res)) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
alert(data.error || 'Failed to save');
|
notify('error', data.error || 'Failed to save');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
alert('Error saving entry');
|
notify('error', 'Could not save the entry. Check the connection and try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -324,6 +346,7 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
|||||||
className="journal-editor-title-input"
|
className="journal-editor-title-input"
|
||||||
value={parsed.frontmatter.title || ''}
|
value={parsed.frontmatter.title || ''}
|
||||||
placeholder="Story Title..."
|
placeholder="Story Title..."
|
||||||
|
aria-label="Story title"
|
||||||
onChange={(e) => handleFrontmatterChange({ title: e.target.value })}
|
onChange={(e) => handleFrontmatterChange({ title: e.target.value })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -375,7 +398,7 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
|||||||
type="button"
|
type="button"
|
||||||
className="admin-btn admin-btn-sm admin-btn-primary"
|
className="admin-btn admin-btn-sm admin-btn-primary"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving || !dirty}
|
||||||
>
|
>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
'Saving...'
|
'Saving...'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import type { JournalEntrySummary } from '@/lib/journal';
|
import type { JournalEntrySummary } from '@/lib/journal';
|
||||||
import { serializeJournalMarkdown, sanitizeSlug } from '@/lib/journal';
|
import { serializeJournalMarkdown, sanitizeSlug } from '@/lib/journal';
|
||||||
@@ -16,6 +16,12 @@ import {
|
|||||||
} from '../Icons';
|
} from '../Icons';
|
||||||
import './journal-studio.css';
|
import './journal-studio.css';
|
||||||
import { JournalEditor } from './JournalEditor';
|
import { JournalEditor } from './JournalEditor';
|
||||||
|
import { useNotify } from '../Notifications';
|
||||||
|
import { useAdminFetch } from '../useAdminFetch';
|
||||||
|
import { useContentRestored } from '../contentRestored';
|
||||||
|
import AdminLoadState from '../AdminLoadState';
|
||||||
|
import PageHeader from '../PageHeader';
|
||||||
|
import { useConfirm } from '../ConfirmDialog';
|
||||||
|
|
||||||
interface JournalStudioProps {
|
interface JournalStudioProps {
|
||||||
/** Entry to open, taken from the /admin/journal/[slug] route. */
|
/** Entry to open, taken from the /admin/journal/[slug] route. */
|
||||||
@@ -25,10 +31,17 @@ interface JournalStudioProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioProps) {
|
export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioProps) {
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const notify = useNotify();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [entries, setEntries] = useState<JournalEntrySummary[]>([]);
|
const list = useAdminFetch<{ entries?: JournalEntrySummary[] }>('/api/admin/journal');
|
||||||
const [loading, setLoading] = useState(true);
|
const entries = list.data?.entries ?? [];
|
||||||
const [error, setError] = useState<string | null>(null);
|
const fetchEntries = list.reload;
|
||||||
|
|
||||||
|
// A restored entry may be one that was deleted, or have a new title.
|
||||||
|
useContentRestored(({ target }) => {
|
||||||
|
if (target === 'journal') fetchEntries();
|
||||||
|
});
|
||||||
|
|
||||||
// New Entry Modal
|
// New Entry Modal
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
@@ -38,28 +51,6 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
|
|||||||
/** null = start blank, matching the previous (only) behavior. */
|
/** null = start blank, matching the previous (only) behavior. */
|
||||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
|
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchEntries = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/admin/journal');
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
setEntries(data.entries || []);
|
|
||||||
} else {
|
|
||||||
setError('Failed to load journal entries');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setError('Failed to load journal entries');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchEntries();
|
|
||||||
}, [fetchEntries]);
|
|
||||||
|
|
||||||
const handleCreate = async (e: React.FormEvent) => {
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!newTitle.trim()) return;
|
if (!newTitle.trim()) return;
|
||||||
@@ -98,41 +89,42 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
|
|||||||
setNewTitle('');
|
setNewTitle('');
|
||||||
setNewSlug('');
|
setNewSlug('');
|
||||||
setSelectedTemplateId(null);
|
setSelectedTemplateId(null);
|
||||||
await fetchEntries();
|
fetchEntries();
|
||||||
if (data.entry?.slug) {
|
if (data.entry?.slug) {
|
||||||
router.push(`/admin/journal/${data.entry.slug}`);
|
router.push(`/admin/journal/${data.entry.slug}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
alert(data.error || 'Failed to create journal entry');
|
notify('error', data.error || 'Failed to create journal entry');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
alert('Error creating journal entry');
|
notify('error', 'Could not create the journal entry. Check the connection and try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (slug: string) => {
|
const handleDelete = async (slug: string) => {
|
||||||
if (
|
const ok = await confirm({
|
||||||
!confirm(
|
title: `Delete “${slug}”?`,
|
||||||
`Delete "${slug}"?\n\nA copy is kept and can be restored from Backups on the dashboard.`,
|
message: 'A copy is kept and can be restored under Backups.',
|
||||||
)
|
confirmLabel: 'Delete',
|
||||||
)
|
danger: true,
|
||||||
return;
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/journal/${slug}`, {
|
const res = await fetch(`/api/admin/journal/${slug}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
await fetchEntries();
|
fetchEntries();
|
||||||
if (activeSlug === slug) router.push('/admin/journal');
|
if (activeSlug === slug) router.push('/admin/journal');
|
||||||
} else {
|
} else {
|
||||||
alert('Failed to delete journal entry');
|
notify('error', 'Failed to delete journal entry');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
alert('Error deleting journal entry');
|
notify('error', 'Could not delete the journal entry. Check the connection and try again.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -148,30 +140,28 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="journal-studio">
|
<div className="journal-studio">
|
||||||
<div className="journal-studio-header">
|
<PageHeader
|
||||||
<div>
|
kicker="Content"
|
||||||
<h2>
|
title="Journal"
|
||||||
<IconBook size={20} /> Journal & Photo Essays
|
description="Stories, field notes and longform photo essays, written with a live preview."
|
||||||
</h2>
|
actions={
|
||||||
<p style={{ margin: '4px 0 0', opacity: 0.7, fontSize: '0.9rem' }}>
|
<button
|
||||||
Author visual stories, field notes, and longform photo essays with live preview.
|
type="button"
|
||||||
</p>
|
className="admin-btn admin-btn-primary"
|
||||||
</div>
|
onClick={() => setShowCreateModal(true)}
|
||||||
<button
|
>
|
||||||
type="button"
|
<IconPlus size={16} /> New Journal Entry
|
||||||
className="admin-btn admin-btn-primary"
|
</button>
|
||||||
onClick={() => setShowCreateModal(true)}
|
}
|
||||||
>
|
/>
|
||||||
<IconPlus size={16} /> New Journal Entry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
{(list.loading && !list.data) || list.error ? (
|
||||||
<div style={{ padding: '3rem', textAlign: 'center', opacity: 0.6 }}>
|
<AdminLoadState
|
||||||
Loading journal entries...
|
loading={list.loading}
|
||||||
</div>
|
error={list.error}
|
||||||
) : error ? (
|
onRetry={list.reload}
|
||||||
<div style={{ padding: '3rem', textAlign: 'center', color: '#ef4444' }}>{error}</div>
|
hasData={!!list.data}
|
||||||
|
/>
|
||||||
) : entries.length === 0 ? (
|
) : entries.length === 0 ? (
|
||||||
<div style={{ padding: '5rem 2rem', textAlign: 'center', opacity: 0.6 }}>
|
<div style={{ padding: '5rem 2rem', textAlign: 'center', opacity: 0.6 }}>
|
||||||
<h3>No journal entries yet</h3>
|
<h3>No journal entries yet</h3>
|
||||||
@@ -269,6 +259,7 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
|
|||||||
<form onSubmit={handleCreate}>
|
<form onSubmit={handleCreate}>
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
<label
|
<label
|
||||||
|
htmlFor="journal-new-title"
|
||||||
style={{
|
style={{
|
||||||
display: 'block',
|
display: 'block',
|
||||||
fontSize: '0.85rem',
|
fontSize: '0.85rem',
|
||||||
@@ -279,6 +270,7 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
|
|||||||
Title
|
Title
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="journal-new-title"
|
||||||
type="text"
|
type="text"
|
||||||
className="admin-input"
|
className="admin-input"
|
||||||
placeholder="e.g. Expedition Nordkap"
|
placeholder="e.g. Expedition Nordkap"
|
||||||
@@ -296,6 +288,7 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
|
|||||||
|
|
||||||
<div style={{ marginBottom: '1.5rem' }}>
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
<label
|
<label
|
||||||
|
htmlFor="journal-new-url-slug"
|
||||||
style={{
|
style={{
|
||||||
display: 'block',
|
display: 'block',
|
||||||
fontSize: '0.85rem',
|
fontSize: '0.85rem',
|
||||||
@@ -306,6 +299,7 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
|
|||||||
URL Slug
|
URL Slug
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="journal-new-url-slug"
|
||||||
type="text"
|
type="text"
|
||||||
className="admin-input"
|
className="admin-input"
|
||||||
placeholder="e.g. expedition-nordkap"
|
placeholder="e.g. expedition-nordkap"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { ParsedJournal } from '@/lib/journal';
|
import type { ParsedJournal } from '@/lib/journal';
|
||||||
import type { AssetPickTarget } from './BlockFields';
|
import type { AssetPickTarget } from './BlockFields';
|
||||||
|
import PasswordField from '../fields/PasswordField';
|
||||||
|
|
||||||
type Frontmatter = ParsedJournal['frontmatter'];
|
type Frontmatter = ParsedJournal['frontmatter'];
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ export function StorySettingsModal({
|
|||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
|
htmlFor="story-subtitle"
|
||||||
style={{
|
style={{
|
||||||
display: 'block',
|
display: 'block',
|
||||||
fontSize: '0.8rem',
|
fontSize: '0.8rem',
|
||||||
@@ -37,6 +39,7 @@ export function StorySettingsModal({
|
|||||||
Subtitle
|
Subtitle
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="story-subtitle"
|
||||||
type="text"
|
type="text"
|
||||||
className="admin-input"
|
className="admin-input"
|
||||||
value={frontmatter.subtitle || ''}
|
value={frontmatter.subtitle || ''}
|
||||||
@@ -48,6 +51,7 @@ export function StorySettingsModal({
|
|||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
|
htmlFor="story-author"
|
||||||
style={{
|
style={{
|
||||||
display: 'block',
|
display: 'block',
|
||||||
fontSize: '0.8rem',
|
fontSize: '0.8rem',
|
||||||
@@ -58,6 +62,7 @@ export function StorySettingsModal({
|
|||||||
Author
|
Author
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="story-author"
|
||||||
type="text"
|
type="text"
|
||||||
className="admin-input"
|
className="admin-input"
|
||||||
value={frontmatter.author || ''}
|
value={frontmatter.author || ''}
|
||||||
@@ -67,6 +72,7 @@ export function StorySettingsModal({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
|
htmlFor="story-publish-date"
|
||||||
style={{
|
style={{
|
||||||
display: 'block',
|
display: 'block',
|
||||||
fontSize: '0.8rem',
|
fontSize: '0.8rem',
|
||||||
@@ -77,6 +83,7 @@ export function StorySettingsModal({
|
|||||||
Publish Date
|
Publish Date
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="story-publish-date"
|
||||||
type="date"
|
type="date"
|
||||||
className="admin-input"
|
className="admin-input"
|
||||||
value={frontmatter.date || ''}
|
value={frontmatter.date || ''}
|
||||||
@@ -155,12 +162,10 @@ export function StorySettingsModal({
|
|||||||
>
|
>
|
||||||
Password Protection (Optional)
|
Password Protection (Optional)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<PasswordField
|
||||||
type="password"
|
value={frontmatter.password || undefined}
|
||||||
className="admin-input"
|
onChange={(password) => onChange({ password: password ?? '' })}
|
||||||
value={frontmatter.password || ''}
|
label="Entry password"
|
||||||
placeholder="Leave empty for public access"
|
|
||||||
onChange={(e) => onChange({ password: e.target.value })}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -674,3 +674,39 @@
|
|||||||
background: rgba(239, 68, 68, 0.1);
|
background: rgba(239, 68, 68, 0.1);
|
||||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Phones: the top bar was one non-wrapping row, so the title input shrank to
|
||||||
|
nothing and Save ran off the screen — and there is no ⌘S on a phone (#675).
|
||||||
|
Below 700px it wraps: back button and mode toggle, the title on a line of
|
||||||
|
its own, then the actions with Save taking the remaining width. */
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.journal-editor-topbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-editor-topbar-left {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-editor-title-input {
|
||||||
|
order: 3;
|
||||||
|
flex: 1 1 100%;
|
||||||
|
max-width: none;
|
||||||
|
min-width: 0;
|
||||||
|
border-color: var(--admin-border, rgba(255, 255, 255, 0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-editor-topbar-right {
|
||||||
|
width: 100%;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-editor-topbar-right > .admin-btn-primary {
|
||||||
|
flex: 1;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { IconCamera, IconCopy, IconGripVertical, IconImage, IconTrash, IconX } f
|
|||||||
import { Listbox } from '../Listbox';
|
import { Listbox } from '../Listbox';
|
||||||
import { SORT_OPTIONS } from './sortOptions';
|
import { SORT_OPTIONS } from './sortOptions';
|
||||||
import type { AlbumEntry, HeroPickerTarget, OrderEditorTarget } from './types';
|
import type { AlbumEntry, HeroPickerTarget, OrderEditorTarget } from './types';
|
||||||
|
import PasswordField from '../fields/PasswordField';
|
||||||
|
|
||||||
interface AlbumDrawerProps {
|
interface AlbumDrawerProps {
|
||||||
album: AlbumEntry;
|
album: AlbumEntry;
|
||||||
@@ -71,8 +72,9 @@ export default function AlbumDrawer({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label>Title override</label>
|
<label htmlFor="album-title-override">Title override</label>
|
||||||
<input
|
<input
|
||||||
|
id="album-title-override"
|
||||||
value={album.title || ''}
|
value={album.title || ''}
|
||||||
onChange={(e) => onUpdate({ title: e.target.value || undefined })}
|
onChange={(e) => onUpdate({ title: e.target.value || undefined })}
|
||||||
placeholder={name}
|
placeholder={name}
|
||||||
@@ -80,8 +82,9 @@ export default function AlbumDrawer({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label>Description</label>
|
<label htmlFor="album-description">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="album-description"
|
||||||
value={album.description || ''}
|
value={album.description || ''}
|
||||||
onChange={(e) => onUpdate({ description: e.target.value || undefined })}
|
onChange={(e) => onUpdate({ description: e.target.value || undefined })}
|
||||||
placeholder="Optional description for visitors"
|
placeholder="Optional description for visitors"
|
||||||
@@ -90,19 +93,17 @@ export default function AlbumDrawer({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label>Password protection</label>
|
<label htmlFor="album-password">Password protection</label>
|
||||||
<div className="input-with-icon">
|
<PasswordField
|
||||||
<input
|
id="album-password"
|
||||||
type="password"
|
value={album.password}
|
||||||
value={album.password || ''}
|
onChange={(password) => onUpdate({ password })}
|
||||||
onChange={(e) => onUpdate({ password: e.target.value || undefined })}
|
label="Album password"
|
||||||
placeholder="Leave empty for public access"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label>Downloads</label>
|
<span className="admin-field-label">Downloads</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`admin-toggle-card ${album.download ? 'active' : ''}`}
|
className={`admin-toggle-card ${album.download ? 'active' : ''}`}
|
||||||
@@ -141,8 +142,9 @@ export default function AlbumDrawer({
|
|||||||
|
|
||||||
<div className="admin-sheet-col admin-sheet-col--divided">
|
<div className="admin-sheet-col admin-sheet-col--divided">
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label>Layout override (Experimental)</label>
|
<label htmlFor="album-layout-override">Layout override (Experimental)</label>
|
||||||
<select
|
<select
|
||||||
|
id="album-layout-override"
|
||||||
aria-label="Layout override"
|
aria-label="Layout override"
|
||||||
value={album.grid?.layout || ''}
|
value={album.grid?.layout || ''}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -166,8 +168,9 @@ export default function AlbumDrawer({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label>Cover focal point (Experimental)</label>
|
<label htmlFor="album-cover-focal-point">Cover focal point (Experimental)</label>
|
||||||
<input
|
<input
|
||||||
|
id="album-cover-focal-point"
|
||||||
value={album.coverPosition || ''}
|
value={album.coverPosition || ''}
|
||||||
onChange={(e) => onUpdate({ coverPosition: e.target.value || undefined })}
|
onChange={(e) => onUpdate({ coverPosition: e.target.value || undefined })}
|
||||||
placeholder={'e.g. "50% 25%" or "top" — where the cover crop should anchor'}
|
placeholder={'e.g. "50% 25%" or "top" — where the cover crop should anchor'}
|
||||||
@@ -176,7 +179,7 @@ export default function AlbumDrawer({
|
|||||||
|
|
||||||
{/* Hero Image Selection */}
|
{/* Hero Image Selection */}
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label>Custom Hero Image</label>
|
<span className="admin-field-label">Custom Hero Image</span>
|
||||||
<div className="album-hero-field">
|
<div className="album-hero-field">
|
||||||
{album.heroImage ? (
|
{album.heroImage ? (
|
||||||
<div className="album-hero-preview">
|
<div className="album-hero-preview">
|
||||||
@@ -213,7 +216,9 @@ export default function AlbumDrawer({
|
|||||||
|
|
||||||
{/* Photo order */}
|
{/* Photo order */}
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label id="album-sort-label">Photo order</label>
|
<span className="admin-field-label" id="album-sort-label">
|
||||||
|
Photo order
|
||||||
|
</span>
|
||||||
<Listbox
|
<Listbox
|
||||||
labelledBy="album-sort-label"
|
labelledBy="album-sort-label"
|
||||||
value={album.sort || DEFAULT_ALBUM_SORT}
|
value={album.sort || DEFAULT_ALBUM_SORT}
|
||||||
|
|||||||
213
app/admin/components/page-builder/SortableTiles.tsx
Normal file
213
app/admin/components/page-builder/SortableTiles.tsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two drag-sortable tiles of the page builder (#608): a hero photo, and
|
||||||
|
* a subpage in the page list. Album cards are in ./AlbumCard.tsx.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import { IconFolder, IconGripVertical, IconLock, IconPencil, IconX } from '../Icons';
|
||||||
|
import type { Subpage } from './types';
|
||||||
|
|
||||||
|
// ── Sortable Hero Tile ─────────────────────────────────────────
|
||||||
|
|
||||||
|
export function SortableHeroTile({
|
||||||
|
id,
|
||||||
|
index,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
index: number;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: `hero-${index}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
zIndex: isDragging ? 10 : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} style={style} className="hero-tile" {...attributes}>
|
||||||
|
<div className="hero-tile-drag" {...listeners} title="Drag to reorder">
|
||||||
|
<IconGripVertical size={18} className="svg-icon svg-drag" />
|
||||||
|
</div>
|
||||||
|
<img src={`/api/admin/thumbnail/${id}`} alt="" loading="lazy" />
|
||||||
|
<button className="hero-tile-remove" onClick={onRemove} title="Remove">
|
||||||
|
<IconX size={14} />
|
||||||
|
</button>
|
||||||
|
<span className="hero-tile-index">{index + 1}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sortable Subpage Tile ──────────────────────────────────────
|
||||||
|
|
||||||
|
export function SortableSubpageTile({
|
||||||
|
sp,
|
||||||
|
spIndex,
|
||||||
|
isActive,
|
||||||
|
onClick,
|
||||||
|
getFirstThumb,
|
||||||
|
}: {
|
||||||
|
sp: Subpage;
|
||||||
|
spIndex: number;
|
||||||
|
isActive: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
getFirstThumb: (sp: Subpage) => string | null;
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: `subpage-${spIndex}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
zIndex: isDragging ? 10 : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalAlbums =
|
||||||
|
sp.albums.length + (sp.sections?.reduce((sum, sec) => sum + sec.albums.length, 0) || 0);
|
||||||
|
const firstThumb = getFirstThumb(sp);
|
||||||
|
const slug = sp.name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={`subpage-tile ${isActive ? 'active' : ''}`}
|
||||||
|
onClick={onClick}
|
||||||
|
{...attributes}
|
||||||
|
>
|
||||||
|
<div className="subpage-tile-drag" {...listeners} title="Drag to reorder">
|
||||||
|
<IconGripVertical size={18} className="svg-icon svg-drag" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sp.enabled === false && (
|
||||||
|
<span
|
||||||
|
className="subpage-badge-protected"
|
||||||
|
style={{ background: '#e60012', color: '#fff' }}
|
||||||
|
title="Page is disabled"
|
||||||
|
>
|
||||||
|
Disabled
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sp.hidden === true && sp.enabled !== false && (
|
||||||
|
<span
|
||||||
|
className="subpage-badge-protected"
|
||||||
|
title="Hidden from navigation, reachable by direct link"
|
||||||
|
>
|
||||||
|
Unlisted
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sp.password && (
|
||||||
|
<span className="subpage-badge-protected" title="Password protected">
|
||||||
|
<IconLock size={12} /> Password
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="subpage-tile-cover">
|
||||||
|
{firstThumb ? (
|
||||||
|
<img src={`/api/admin/thumbnail/${firstThumb}`} alt="" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<div className="subpage-tile-placeholder">
|
||||||
|
<IconFolder />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="subpage-hover-overlay">
|
||||||
|
<span className="hover-action-btn">
|
||||||
|
<IconPencil size={14} /> Edit Page
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="subpage-tile-info">
|
||||||
|
<div className="subpage-tile-title-row">
|
||||||
|
<span className="subpage-tile-name">{sp.title || sp.name}</span>
|
||||||
|
<span className="subpage-tile-slug">/{slug}</span>
|
||||||
|
</div>
|
||||||
|
<span className="subpage-tile-meta">
|
||||||
|
<IconFolder /> {totalAlbums} album{totalAlbums !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ── Sortable Subpage Row (stage 4 structure list) ──────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One subpage in the page builder's structure list (UX stage 4): drag handle,
|
||||||
|
* cover, name, state and album count, in one line. Same sortable id as the
|
||||||
|
* tile it replaces, so the builder's drag handler is unchanged.
|
||||||
|
*/
|
||||||
|
export function SortableSubpageRow({
|
||||||
|
sp,
|
||||||
|
spIndex,
|
||||||
|
isActive,
|
||||||
|
onClick,
|
||||||
|
getFirstThumb,
|
||||||
|
}: {
|
||||||
|
sp: Subpage;
|
||||||
|
spIndex: number;
|
||||||
|
isActive: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
getFirstThumb: (sp: Subpage) => string | null;
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: `subpage-${spIndex}`,
|
||||||
|
});
|
||||||
|
const totalAlbums =
|
||||||
|
sp.albums.length + (sp.sections?.reduce((sum, sec) => sum + sec.albums.length, 0) || 0);
|
||||||
|
const thumb = getFirstThumb(sp);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={{
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
zIndex: isDragging ? 10 : undefined,
|
||||||
|
}}
|
||||||
|
className={`pb-row ${isActive ? 'active' : ''} ${sp.enabled === false ? 'is-off' : ''}`}
|
||||||
|
{...attributes}
|
||||||
|
>
|
||||||
|
<span className="pb-row-grip" {...listeners} title="Drag to reorder">
|
||||||
|
<IconGripVertical size={14} />
|
||||||
|
</span>
|
||||||
|
<button type="button" className="pb-row-main" onClick={onClick} aria-current={isActive}>
|
||||||
|
<span className="pb-row-thumb">
|
||||||
|
{thumb ? (
|
||||||
|
<img src={`/api/admin/thumbnail/${thumb}`} alt="" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<IconFolder size={14} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="pb-row-name">
|
||||||
|
{sp.title || sp.name}
|
||||||
|
{sp.password && <IconLock size={11} />}
|
||||||
|
</span>
|
||||||
|
{sp.enabled === false ? (
|
||||||
|
<span className="pb-row-state">Off</span>
|
||||||
|
) : sp.hidden ? (
|
||||||
|
<span className="pb-row-state">Unlisted</span>
|
||||||
|
) : (
|
||||||
|
<span className="pb-row-count">{totalAlbums}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
IconBook,
|
IconBook,
|
||||||
IconEyeOff,
|
IconEyeOff,
|
||||||
IconGlobe,
|
IconGlobe,
|
||||||
IconLock,
|
|
||||||
IconPencil,
|
IconPencil,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
@@ -23,6 +22,7 @@ import {
|
|||||||
} from '../Icons';
|
} from '../Icons';
|
||||||
import AlbumCard, { SortableAlbumCard } from './AlbumCard';
|
import AlbumCard, { SortableAlbumCard } from './AlbumCard';
|
||||||
import SubpagePreview from './SubpagePreview';
|
import SubpagePreview from './SubpagePreview';
|
||||||
|
import PasswordField from '../fields/PasswordField';
|
||||||
import {
|
import {
|
||||||
normalizeSubpageGrid,
|
normalizeSubpageGrid,
|
||||||
type ActiveEditAlbumAddress,
|
type ActiveEditAlbumAddress,
|
||||||
@@ -68,6 +68,12 @@ interface SubpageDrawerProps {
|
|||||||
getAlbumThumbnailId: (id: string) => string | null;
|
getAlbumThumbnailId: (id: string) => string | null;
|
||||||
/** Album to mark, when the sheet was opened from a diagnostics link. */
|
/** Album to mark, when the sheet was opened from a diagnostics link. */
|
||||||
highlightedAlbumId?: string | null;
|
highlightedAlbumId?: string | null;
|
||||||
|
/**
|
||||||
|
* Rendered as the page builder's right-hand panel (UX stage 4) instead of
|
||||||
|
* a slide-over: no backdrop, no close or Done, the structure list beside
|
||||||
|
* it is how you move on.
|
||||||
|
*/
|
||||||
|
inline?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -101,485 +107,497 @@ export default function SubpageDrawer({
|
|||||||
getAlbumCount,
|
getAlbumCount,
|
||||||
getAlbumThumbnailId,
|
getAlbumThumbnailId,
|
||||||
highlightedAlbumId,
|
highlightedAlbumId,
|
||||||
|
inline = false,
|
||||||
}: SubpageDrawerProps) {
|
}: SubpageDrawerProps) {
|
||||||
return (
|
const sheet = (
|
||||||
<div className="subpage-drawer-backdrop" onClick={() => onClose()}>
|
<div
|
||||||
<div className="subpage-drawer-container" onClick={(e) => e.stopPropagation()}>
|
className={`subpage-drawer-container${inline ? ' subpage-drawer-container--inline' : ''}`}
|
||||||
<div className="subpage-drawer-header">
|
onClick={(e) => e.stopPropagation()}
|
||||||
<div className="subpage-drawer-title-group">
|
>
|
||||||
<span className="subpage-drawer-breadcrumb">Pages / Subpages</span>
|
<div className="subpage-drawer-header">
|
||||||
<h3 className="subpage-drawer-title">{sp.title || sp.name || 'Untitled Page'}</h3>
|
<div className="subpage-drawer-title-group">
|
||||||
</div>
|
<span className="subpage-drawer-breadcrumb">Pages / Subpages</span>
|
||||||
<div className="subpage-drawer-header-actions">
|
<h3 className="subpage-drawer-title">{sp.title || sp.name || 'Untitled Page'}</h3>
|
||||||
<div className="segmented-control" style={{ padding: '2px' }}>
|
</div>
|
||||||
<button
|
<div className="subpage-drawer-header-actions">
|
||||||
type="button"
|
<div className="segmented-control" style={{ padding: '2px' }}>
|
||||||
className={`segment-btn ${drawerMode === 'edit' ? 'active' : ''}`}
|
<button
|
||||||
onClick={() => onDrawerModeChange('edit')}
|
type="button"
|
||||||
style={{ padding: '0.2rem 0.6rem', fontSize: '0.75rem' }}
|
className={`segment-btn ${drawerMode === 'edit' ? 'active' : ''}`}
|
||||||
>
|
onClick={() => onDrawerModeChange('edit')}
|
||||||
<IconPencil size={14} /> Edit
|
style={{ padding: '0.2rem 0.6rem', fontSize: '0.75rem' }}
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`segment-btn ${drawerMode === 'preview' ? 'active' : ''}`}
|
|
||||||
onClick={() => onDrawerModeChange('preview')}
|
|
||||||
style={{ padding: '0.2rem 0.6rem', fontSize: '0.75rem' }}
|
|
||||||
>
|
|
||||||
<IconSearch size={14} /> Live Preview
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href={`/${slugify(sp.name)}?fresh=1`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="admin-btn admin-btn-xs admin-btn-ghost"
|
|
||||||
title="Open live page in new tab (bypassing cache)"
|
|
||||||
>
|
>
|
||||||
/{slugify(sp.name)} ↗
|
<IconPencil size={14} /> Edit
|
||||||
</a>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`segment-btn ${drawerMode === 'preview' ? 'active' : ''}`}
|
||||||
|
onClick={() => onDrawerModeChange('preview')}
|
||||||
|
style={{ padding: '0.2rem 0.6rem', fontSize: '0.75rem' }}
|
||||||
|
>
|
||||||
|
<IconSearch size={14} /> Live Preview
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={`/${slugify(sp.name)}?fresh=1`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="admin-btn admin-btn-xs admin-btn-ghost"
|
||||||
|
title="Open live page in new tab (bypassing cache)"
|
||||||
|
>
|
||||||
|
/{slugify(sp.name)} ↗
|
||||||
|
</a>
|
||||||
|
{!inline && (
|
||||||
<button className="admin-btn-icon" onClick={() => onClose()} title="Close drawer">
|
<button className="admin-btn-icon" onClick={() => onClose()} title="Close drawer">
|
||||||
<IconX size={14} />
|
<IconX size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="subpage-drawer-body">
|
<div className="subpage-drawer-body">
|
||||||
{drawerMode === 'preview' ? (
|
{drawerMode === 'preview' ? (
|
||||||
<SubpagePreview sp={sp} immichAlbums={immichAlbums} index={kickerIndex} />
|
<SubpagePreview sp={sp} immichAlbums={immichAlbums} index={kickerIndex} />
|
||||||
) : (
|
) : (
|
||||||
<div className="admin-sheet-columns admin-sheet-columns--settings-aside">
|
<div className="admin-sheet-columns admin-sheet-columns--settings-aside">
|
||||||
<div className="admin-sheet-col">
|
<div className="admin-sheet-col">
|
||||||
<div className="subpage-drawer-section">
|
<div className="subpage-drawer-section">
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label>Page Name (URL Identifier)</label>
|
<label htmlFor="subpage-name">Page Name (URL Identifier)</label>
|
||||||
<div className="input-slug-wrapper">
|
<div className="input-slug-wrapper">
|
||||||
<input
|
<input
|
||||||
className="subpage-name-input"
|
id="subpage-name"
|
||||||
value={sp.name}
|
className="subpage-name-input"
|
||||||
onChange={(e) => updateSubpage(spIndex, { name: e.target.value })}
|
value={sp.name}
|
||||||
placeholder="e.g. dubai, travel, weddings"
|
onChange={(e) => updateSubpage(spIndex, { name: e.target.value })}
|
||||||
/>
|
placeholder="e.g. dubai, travel, weddings"
|
||||||
<span className="input-slug-preview">/{slugify(sp.name)}</span>
|
/>
|
||||||
</div>
|
<span className="input-slug-preview">/{slugify(sp.name)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Subpage metadata */}
|
{/* Subpage metadata */}
|
||||||
<div className="subpage-drawer-section">
|
<div className="subpage-drawer-section">
|
||||||
<div className="admin-field-row">
|
<div className="admin-field-row">
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label>Display Title (optional)</label>
|
<label htmlFor="subpage-display-title">Display Title (optional)</label>
|
||||||
<input
|
<input
|
||||||
value={sp.title || ''}
|
id="subpage-display-title"
|
||||||
onChange={(e) =>
|
value={sp.title || ''}
|
||||||
updateSubpage(spIndex, { title: e.target.value || undefined })
|
onChange={(e) =>
|
||||||
}
|
updateSubpage(spIndex, { title: e.target.value || undefined })
|
||||||
placeholder="Display title (defaults to name)"
|
}
|
||||||
/>
|
placeholder="Display title (defaults to name)"
|
||||||
</div>
|
/>
|
||||||
<div className="admin-field">
|
|
||||||
<label>Subtitle</label>
|
|
||||||
<input
|
|
||||||
value={sp.subtitle || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateSubpage(spIndex, {
|
|
||||||
subtitle: e.target.value || undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
placeholder="Subtitle text"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-field" style={{ marginTop: '1rem' }}>
|
<div className="admin-field">
|
||||||
<label>Visibility & Access</label>
|
<label htmlFor="subpage-subtitle">Subtitle</label>
|
||||||
{/*
|
<input
|
||||||
|
id="subpage-subtitle"
|
||||||
|
value={sp.subtitle || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateSubpage(spIndex, {
|
||||||
|
subtitle: e.target.value || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="Subtitle text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field" style={{ marginTop: '1rem' }}>
|
||||||
|
<span className="admin-field-label">Visibility & Access</span>
|
||||||
|
{/*
|
||||||
One field, three states — enabled/hidden are two YAML flags,
|
One field, three states — enabled/hidden are two YAML flags,
|
||||||
but for the owner it is a single question: how visible is
|
but for the owner it is a single question: how visible is
|
||||||
this page? Splitting it across two toggles produced
|
this page? Splitting it across two toggles produced
|
||||||
contradictory copy ("Disabled (Hidden)" vs "Unlisted").
|
contradictory copy ("Disabled (Hidden)" vs "Unlisted").
|
||||||
*/}
|
*/}
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
key: 'published',
|
key: 'published',
|
||||||
active: sp.enabled !== false && sp.hidden !== true,
|
active: sp.enabled !== false && sp.hidden !== true,
|
||||||
icon: <IconGlobe size={15} />,
|
icon: <IconGlobe size={15} />,
|
||||||
iconColor: '#4ade80',
|
iconColor: '#4ade80',
|
||||||
title: 'Published',
|
title: 'Published',
|
||||||
desc: 'Shown in the header menu and homepage lists, reachable via URL',
|
desc: 'Shown in the header menu and homepage lists, reachable via URL',
|
||||||
patch: { enabled: true, hidden: false },
|
patch: { enabled: true, hidden: false },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'unlisted',
|
key: 'unlisted',
|
||||||
active: sp.enabled !== false && sp.hidden === true,
|
active: sp.enabled !== false && sp.hidden === true,
|
||||||
icon: <IconEyeOff size={15} />,
|
icon: <IconEyeOff size={15} />,
|
||||||
iconColor: '#fbbf24',
|
iconColor: '#fbbf24',
|
||||||
title: 'Unlisted (Experimental)',
|
title: 'Unlisted (Experimental)',
|
||||||
desc: 'Not shown in menus or on the homepage, but reachable via direct link',
|
desc: 'Not shown in menus or on the homepage, but reachable via direct link',
|
||||||
patch: { enabled: true, hidden: true },
|
patch: { enabled: true, hidden: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'disabled',
|
key: 'disabled',
|
||||||
active: sp.enabled === false,
|
active: sp.enabled === false,
|
||||||
icon: <IconBan size={15} />,
|
icon: <IconBan size={15} />,
|
||||||
iconColor: '#f87171',
|
iconColor: '#f87171',
|
||||||
title: 'Disabled',
|
title: 'Disabled',
|
||||||
desc: 'Completely offline — returns 404 even when accessed directly',
|
desc: 'Completely offline — returns 404 even when accessed directly',
|
||||||
patch: { enabled: false, hidden: false },
|
patch: { enabled: false, hidden: false },
|
||||||
},
|
},
|
||||||
] as const
|
] as const
|
||||||
).map((opt) => (
|
).map((opt) => (
|
||||||
<button
|
<button
|
||||||
key={opt.key}
|
key={opt.key}
|
||||||
type="button"
|
type="button"
|
||||||
className={`admin-toggle-card ${opt.active ? 'active' : ''}`}
|
className={`admin-toggle-card ${opt.active ? 'active' : ''}`}
|
||||||
onClick={() => updateSubpage(spIndex, opt.patch)}
|
onClick={() => updateSubpage(spIndex, opt.patch)}
|
||||||
aria-pressed={opt.active}
|
aria-pressed={opt.active}
|
||||||
style={{
|
|
||||||
padding: '10px 14px',
|
|
||||||
width: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
borderRadius: '8px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
marginBottom: '6px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="toggle-card-info" style={{ textAlign: 'left' }}>
|
|
||||||
<span
|
|
||||||
className="toggle-card-title"
|
|
||||||
style={{
|
|
||||||
fontWeight: 600,
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{ color: opt.iconColor, display: 'inline-flex' }}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
{opt.icon}
|
|
||||||
</span>
|
|
||||||
{opt.title}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="toggle-card-desc"
|
|
||||||
style={{
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
display: 'block',
|
|
||||||
opacity: 0.75,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{opt.desc}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className={`switch-toggle ${opt.active ? 'on' : ''}`}>
|
|
||||||
<span className="switch-slider" />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Password lives in the same group: it is the access half
|
|
||||||
of "who gets to see this page". Applies to Published and
|
|
||||||
Unlisted; a Disabled page 404s before the gate. */}
|
|
||||||
<div className="password-input-wrapper" style={{ marginTop: '6px' }}>
|
|
||||||
<span className="password-icon">
|
|
||||||
<IconLock size={12} />
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={sp.password || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateSubpage(spIndex, {
|
|
||||||
password: e.target.value || undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
placeholder="Password protection (optional) — leave empty for public access"
|
|
||||||
disabled={sp.enabled === false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="admin-field" style={{ marginTop: '1rem' }}>
|
|
||||||
<label>Page Layout Style</label>
|
|
||||||
<select
|
|
||||||
aria-label="Page layout style"
|
|
||||||
value={sp.grid?.layout || 'masonry'}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newLayout = e.target.value;
|
|
||||||
updateSubpage(spIndex, {
|
|
||||||
grid: { ...(sp.grid || {}), layout: newLayout },
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 12px',
|
padding: '10px 14px',
|
||||||
borderRadius: '6px',
|
|
||||||
width: '100%',
|
width: '100%',
|
||||||
fontSize: '0.9rem',
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginBottom: '6px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/*
|
<div className="toggle-card-info" style={{ textAlign: 'left' }}>
|
||||||
|
<span
|
||||||
|
className="toggle-card-title"
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{ color: opt.iconColor, display: 'inline-flex' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{opt.icon}
|
||||||
|
</span>
|
||||||
|
{opt.title}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="toggle-card-desc"
|
||||||
|
style={{
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
display: 'block',
|
||||||
|
opacity: 0.75,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{opt.desc}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={`switch-toggle ${opt.active ? 'on' : ''}`}>
|
||||||
|
<span className="switch-slider" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Password lives in the same group: it is the access half
|
||||||
|
of "who gets to see this page". Applies to Published and
|
||||||
|
Unlisted; a Disabled page 404s before the gate. */}
|
||||||
|
<div style={{ marginTop: '6px' }}>
|
||||||
|
<PasswordField
|
||||||
|
value={sp.password}
|
||||||
|
onChange={(password) => updateSubpage(spIndex, { password })}
|
||||||
|
placeholder="Password protection (optional) — leave empty for public access"
|
||||||
|
label="Page password"
|
||||||
|
disabled={sp.enabled === false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field" style={{ marginTop: '1rem' }}>
|
||||||
|
<label htmlFor="subpage-page-layout-style">Page Layout Style</label>
|
||||||
|
<select
|
||||||
|
id="subpage-page-layout-style"
|
||||||
|
aria-label="Page layout style"
|
||||||
|
value={sp.grid?.layout || 'masonry'}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newLayout = e.target.value;
|
||||||
|
updateSubpage(spIndex, {
|
||||||
|
grid: { ...(sp.grid || {}), layout: newLayout },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
width: '100%',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/*
|
||||||
Plain text, no emoji: an <option> cannot contain markup, so the
|
Plain text, no emoji: an <option> cannot contain markup, so the
|
||||||
SVG icon set is not available here — and a decorative emoji is
|
SVG icon set is not available here — and a decorative emoji is
|
||||||
not a substitute for it.
|
not a substitute for it.
|
||||||
*/}
|
*/}
|
||||||
<option value="masonry">Masonry Grid (Dynamic Height)</option>
|
<option value="masonry">Masonry Grid (Dynamic Height)</option>
|
||||||
<option value="uniform">Uniform Grid (Square Tiles)</option>
|
<option value="uniform">Uniform Grid (Square Tiles)</option>
|
||||||
<option value="showcase">Showcase (Featured First Asset)</option>
|
<option value="showcase">Showcase (Featured First Asset)</option>
|
||||||
<option value="filmstrip">Filmstrip (Horizontal Scroll)</option>
|
<option value="filmstrip">Filmstrip (Horizontal Scroll)</option>
|
||||||
<option value="editorial-flow">Editorial Flow</option>
|
<option value="editorial-flow">Editorial Flow</option>
|
||||||
<option value="essay">Photo Essay Mode (Storytelling Editor)</option>
|
<option value="essay">Photo Essay Mode (Storytelling Editor)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Album cover grid — hidden in essay mode, which
|
{/* Album cover grid — hidden in essay mode, which
|
||||||
renders a story instead of a cover grid. The
|
renders a story instead of a cover grid. The
|
||||||
album count is deliberately not checked: it
|
album count is deliberately not checked: it
|
||||||
changes in this very drawer, and a field that
|
changes in this very drawer, and a field that
|
||||||
vanishes while you edit is worse than one that
|
vanishes while you edit is worse than one that
|
||||||
has no effect yet. */}
|
has no effect yet. */}
|
||||||
{sp.grid?.layout !== 'essay' && sp.essayText == null && (
|
{sp.grid?.layout !== 'essay' && sp.essayText == null && (
|
||||||
<GridOverrideFields
|
<GridOverrideFields
|
||||||
label="Album Cover Grid"
|
label="Album Cover Grid"
|
||||||
hint={
|
hint={
|
||||||
<>
|
<>
|
||||||
Overrides how the album covers are tiled on this page — the photos inside
|
Overrides how the album covers are tiled on this page — the photos inside
|
||||||
the albums are not affected. Leave the column count empty to follow the
|
the albums are not affected. Leave the column count empty to follow the
|
||||||
site-wide grid setting, and the gap empty to keep the theme's own
|
site-wide grid setting, and the gap empty to keep the theme's own
|
||||||
spacing. Tablet widths show at most two covers per row.
|
spacing. Tablet widths show at most two covers per row.
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
columnsMin={COVER_GRID_COLUMNS_MIN}
|
columnsMin={COVER_GRID_COLUMNS_MIN}
|
||||||
columnsMax={COVER_GRID_COLUMNS_MAX}
|
columnsMax={COVER_GRID_COLUMNS_MAX}
|
||||||
gapMax={COVER_GRID_GAP_MAX}
|
gapMax={COVER_GRID_GAP_MAX}
|
||||||
value={sp.coverGrid}
|
value={sp.coverGrid}
|
||||||
onChange={(next) =>
|
onChange={(next) =>
|
||||||
updateSubpage(spIndex, {
|
updateSubpage(spIndex, {
|
||||||
coverGrid: normalizeSubpageGrid(next),
|
coverGrid: normalizeSubpageGrid(next),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Photo grid — the albums opened from this page.
|
{/* Photo grid — the albums opened from this page.
|
||||||
Separate from the cover grid above: one field
|
Separate from the cover grid above: one field
|
||||||
used to drive both, so a cover gap retuned
|
used to drive both, so a cover gap retuned
|
||||||
every photo grid on the page (#523). */}
|
every photo grid on the page (#523). */}
|
||||||
{sp.essayText == null && (
|
{sp.essayText == null && (
|
||||||
<GridOverrideFields
|
<GridOverrideFields
|
||||||
label="Photo Grid (Albums On This Page)"
|
label="Photo Grid (Albums On This Page)"
|
||||||
hint={
|
hint={
|
||||||
<>
|
<>
|
||||||
How the photos inside every album on this page are laid out. Leave both
|
How the photos inside every album on this page are laid out. Leave both
|
||||||
empty to follow Settings › Grid; a single album can still override
|
empty to follow Settings › Grid; a single album can still override
|
||||||
this in gallery.yaml.
|
this in gallery.yaml.
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
columnsMin={PHOTO_GRID_COLUMNS_MIN}
|
columnsMin={PHOTO_GRID_COLUMNS_MIN}
|
||||||
columnsMax={PHOTO_GRID_COLUMNS_MAX}
|
columnsMax={PHOTO_GRID_COLUMNS_MAX}
|
||||||
gapMax={PHOTO_GRID_GAP_MAX}
|
gapMax={PHOTO_GRID_GAP_MAX}
|
||||||
value={sp.grid}
|
value={sp.grid}
|
||||||
onChange={(next) =>
|
onChange={(next) =>
|
||||||
updateSubpage(spIndex, { grid: normalizeSubpageGrid(next) })
|
updateSubpage(spIndex, { grid: normalizeSubpageGrid(next) })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Visual Photo Essay Builder (if layout === 'essay') */}
|
|
||||||
{(sp.grid?.layout === 'essay' || sp.essayText != null) && (
|
|
||||||
<div className="subpage-drawer-section">
|
|
||||||
<div className="drawer-section-heading">
|
|
||||||
<h4>
|
|
||||||
<IconBook size={16} /> Storytelling & Photo Essay Builder
|
|
||||||
</h4>
|
|
||||||
<p>Compose text, quotes, and fullbleed/wide photos visually.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EssayBlockEditor
|
|
||||||
markdown={sp.essayText || ''}
|
|
||||||
onChange={(newMarkdown) => updateSubpage(spIndex, { essayText: newMarkdown })}
|
|
||||||
onSelectPhoto={(callback) => {
|
|
||||||
onPickHero({
|
|
||||||
albumId: undefined,
|
|
||||||
title: 'Select Photo for Photo Essay',
|
|
||||||
onSelect: (assetId) => {
|
|
||||||
callback(assetId);
|
|
||||||
onPickHero(null);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-sheet-col admin-sheet-col--divided">
|
{/* Visual Photo Essay Builder (if layout === 'essay') */}
|
||||||
{/* Albums (if no sections) with DnD */}
|
{(sp.grid?.layout === 'essay' || sp.essayText != null) && (
|
||||||
{(!sp.sections || sp.sections.length === 0) && (
|
<div className="subpage-drawer-section">
|
||||||
<div className="subpage-albums">
|
<div className="drawer-section-heading">
|
||||||
<div className="subpage-albums-header">
|
<h4>
|
||||||
<span>Albums in this Page</span>
|
<IconBook size={16} /> Storytelling & Photo Essay Builder
|
||||||
|
</h4>
|
||||||
|
<p>Compose text, quotes, and fullbleed/wide photos visually.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EssayBlockEditor
|
||||||
|
markdown={sp.essayText || ''}
|
||||||
|
onChange={(newMarkdown) => updateSubpage(spIndex, { essayText: newMarkdown })}
|
||||||
|
onSelectPhoto={(callback) => {
|
||||||
|
onPickHero({
|
||||||
|
albumId: undefined,
|
||||||
|
title: 'Select Photo for Photo Essay',
|
||||||
|
onSelect: (assetId) => {
|
||||||
|
callback(assetId);
|
||||||
|
onPickHero(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-sheet-col admin-sheet-col--divided">
|
||||||
|
{/* Albums (if no sections) with DnD */}
|
||||||
|
{(!sp.sections || sp.sections.length === 0) && (
|
||||||
|
<div className="subpage-albums">
|
||||||
|
<div className="subpage-albums-header">
|
||||||
|
<span>Albums in this Page</span>
|
||||||
|
<button
|
||||||
|
className="admin-btn admin-btn-xs admin-btn-primary"
|
||||||
|
onClick={() => onPickAlbum({ type: 'subpage', subpageIndex: spIndex })}
|
||||||
|
>
|
||||||
|
+ Add Album
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={onAlbumDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={sp.albums.map((a, i) => `album-${a.id}-${i}`)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="album-list">
|
||||||
|
{sp.albums.length === 0 && (
|
||||||
|
<p className="empty-hint">
|
||||||
|
No albums added yet. Click + Add Album to pick from Immich.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{sp.albums.map((album, aIndex) => (
|
||||||
|
<SortableAlbumCard
|
||||||
|
key={`${album.id}-${aIndex}`}
|
||||||
|
album={album}
|
||||||
|
index={aIndex}
|
||||||
|
name={getAlbumName(album.id)}
|
||||||
|
count={getAlbumCount(album.id)}
|
||||||
|
thumbnailId={getAlbumThumbnailId(album.id)}
|
||||||
|
highlighted={album.id === highlightedAlbumId}
|
||||||
|
onRemove={() => removeSubpageAlbum(spIndex, aIndex)}
|
||||||
|
onEdit={() =>
|
||||||
|
onEditAlbum({
|
||||||
|
type: 'subpage',
|
||||||
|
subpageIndex: spIndex,
|
||||||
|
albumIndex: aIndex,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sections */}
|
||||||
|
{sp.sections && sp.sections.length > 0 && (
|
||||||
|
<div className="subpage-sections">
|
||||||
|
{sp.sections.map((sec, secIndex) => (
|
||||||
|
<div key={secIndex} className="section-card">
|
||||||
|
<div className="section-header">
|
||||||
|
<input
|
||||||
|
className="section-title-input"
|
||||||
|
value={sec.title}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateSection(spIndex, secIndex, {
|
||||||
|
title: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="Section title"
|
||||||
|
aria-label="Section title"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="admin-btn-icon"
|
||||||
|
onClick={() => removeSection(spIndex, secIndex)}
|
||||||
|
title="Remove section"
|
||||||
|
>
|
||||||
|
<IconX size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<input
|
||||||
|
value={sec.description || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateSection(spIndex, secIndex, {
|
||||||
|
description: e.target.value || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="Section description (optional)"
|
||||||
|
aria-label="Section description"
|
||||||
|
className="section-desc-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="album-list">
|
||||||
|
{sec.albums.map((album, aIndex) => (
|
||||||
|
<AlbumCard
|
||||||
|
key={`${album.id}-${aIndex}`}
|
||||||
|
album={album}
|
||||||
|
name={getAlbumName(album.id)}
|
||||||
|
count={getAlbumCount(album.id)}
|
||||||
|
thumbnailId={getAlbumThumbnailId(album.id)}
|
||||||
|
highlighted={album.id === highlightedAlbumId}
|
||||||
|
onRemove={() => removeSectionAlbum(spIndex, secIndex, aIndex)}
|
||||||
|
onEdit={() =>
|
||||||
|
onEditAlbum({
|
||||||
|
type: 'section',
|
||||||
|
subpageIndex: spIndex,
|
||||||
|
sectionIndex: secIndex,
|
||||||
|
albumIndex: aIndex,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
className="admin-btn admin-btn-xs admin-btn-primary"
|
className="admin-btn admin-btn-xs"
|
||||||
onClick={() => onPickAlbum({ type: 'subpage', subpageIndex: spIndex })}
|
onClick={() =>
|
||||||
|
onPickAlbum({
|
||||||
|
type: 'section',
|
||||||
|
subpageIndex: spIndex,
|
||||||
|
sectionIndex: secIndex,
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
+ Add Album
|
+ Add Album
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<DndContext
|
))}
|
||||||
sensors={sensors}
|
</div>
|
||||||
collisionDetection={closestCenter}
|
)}
|
||||||
onDragEnd={onAlbumDragEnd}
|
|
||||||
>
|
|
||||||
<SortableContext
|
|
||||||
items={sp.albums.map((a, i) => `album-${a.id}-${i}`)}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
|
||||||
<div className="album-list">
|
|
||||||
{sp.albums.length === 0 && (
|
|
||||||
<p className="empty-hint">
|
|
||||||
No albums added yet. Click + Add Album to pick from Immich.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{sp.albums.map((album, aIndex) => (
|
|
||||||
<SortableAlbumCard
|
|
||||||
key={`${album.id}-${aIndex}`}
|
|
||||||
album={album}
|
|
||||||
index={aIndex}
|
|
||||||
name={getAlbumName(album.id)}
|
|
||||||
count={getAlbumCount(album.id)}
|
|
||||||
thumbnailId={getAlbumThumbnailId(album.id)}
|
|
||||||
highlighted={album.id === highlightedAlbumId}
|
|
||||||
onRemove={() => removeSubpageAlbum(spIndex, aIndex)}
|
|
||||||
onEdit={() =>
|
|
||||||
onEditAlbum({
|
|
||||||
type: 'subpage',
|
|
||||||
subpageIndex: spIndex,
|
|
||||||
albumIndex: aIndex,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Sections */}
|
<div className="subpage-actions" style={{ marginTop: '1rem' }}>
|
||||||
|
<button
|
||||||
|
className="admin-btn admin-btn-xs admin-btn-secondary"
|
||||||
|
onClick={() => addSection(spIndex)}
|
||||||
|
>
|
||||||
|
+ Add Section
|
||||||
|
</button>
|
||||||
{sp.sections && sp.sections.length > 0 && (
|
{sp.sections && sp.sections.length > 0 && (
|
||||||
<div className="subpage-sections">
|
|
||||||
{sp.sections.map((sec, secIndex) => (
|
|
||||||
<div key={secIndex} className="section-card">
|
|
||||||
<div className="section-header">
|
|
||||||
<input
|
|
||||||
className="section-title-input"
|
|
||||||
value={sec.title}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateSection(spIndex, secIndex, {
|
|
||||||
title: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
placeholder="Section title"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className="admin-btn-icon"
|
|
||||||
onClick={() => removeSection(spIndex, secIndex)}
|
|
||||||
title="Remove section"
|
|
||||||
>
|
|
||||||
<IconX size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="admin-field">
|
|
||||||
<input
|
|
||||||
value={sec.description || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateSection(spIndex, secIndex, {
|
|
||||||
description: e.target.value || undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
placeholder="Section description (optional)"
|
|
||||||
className="section-desc-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="album-list">
|
|
||||||
{sec.albums.map((album, aIndex) => (
|
|
||||||
<AlbumCard
|
|
||||||
key={`${album.id}-${aIndex}`}
|
|
||||||
album={album}
|
|
||||||
name={getAlbumName(album.id)}
|
|
||||||
count={getAlbumCount(album.id)}
|
|
||||||
thumbnailId={getAlbumThumbnailId(album.id)}
|
|
||||||
highlighted={album.id === highlightedAlbumId}
|
|
||||||
onRemove={() => removeSectionAlbum(spIndex, secIndex, aIndex)}
|
|
||||||
onEdit={() =>
|
|
||||||
onEditAlbum({
|
|
||||||
type: 'section',
|
|
||||||
subpageIndex: spIndex,
|
|
||||||
sectionIndex: secIndex,
|
|
||||||
albumIndex: aIndex,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="admin-btn admin-btn-xs"
|
|
||||||
onClick={() =>
|
|
||||||
onPickAlbum({
|
|
||||||
type: 'section',
|
|
||||||
subpageIndex: spIndex,
|
|
||||||
sectionIndex: secIndex,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
+ Add Album
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="subpage-actions" style={{ marginTop: '1rem' }}>
|
|
||||||
<button
|
<button
|
||||||
className="admin-btn admin-btn-xs admin-btn-secondary"
|
className="admin-btn admin-btn-xs admin-btn-secondary"
|
||||||
onClick={() => addSection(spIndex)}
|
onClick={() => onPickAlbum({ type: 'subpage', subpageIndex: spIndex })}
|
||||||
>
|
>
|
||||||
+ Add Section
|
+ Add Loose Album
|
||||||
</button>
|
</button>
|
||||||
{sp.sections && sp.sections.length > 0 && (
|
)}
|
||||||
<button
|
|
||||||
className="admin-btn admin-btn-xs admin-btn-secondary"
|
|
||||||
onClick={() => onPickAlbum({ type: 'subpage', subpageIndex: spIndex })}
|
|
||||||
>
|
|
||||||
+ Add Loose Album
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="subpage-drawer-footer">
|
<div className="subpage-drawer-footer">
|
||||||
<button
|
<button
|
||||||
className="admin-btn admin-btn-danger admin-btn-sm"
|
className="admin-btn admin-btn-danger admin-btn-sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
removeSubpage(spIndex);
|
removeSubpage(spIndex);
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconTrash size={14} /> Delete Subpage
|
<IconTrash size={14} /> Delete Subpage
|
||||||
</button>
|
</button>
|
||||||
|
{!inline && (
|
||||||
<button className="admin-btn admin-btn-primary admin-btn-sm" onClick={() => onClose()}>
|
<button className="admin-btn admin-btn-primary admin-btn-sm" onClick={() => onClose()}>
|
||||||
Done
|
Done
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (inline) return sheet;
|
||||||
|
return (
|
||||||
|
<div className="subpage-drawer-backdrop" onClick={() => onClose()}>
|
||||||
|
{sheet}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
175
app/admin/components/page-builder/galleryOps.ts
Normal file
175
app/admin/components/page-builder/galleryOps.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* The page builder's edits as pure functions on GalleryState (#608).
|
||||||
|
*
|
||||||
|
* Each takes a state and returns a new one; PageBuilder wraps them in
|
||||||
|
* setGallery() and adds what is not the state's business: confirming a
|
||||||
|
* removal and marking the form dirty. Out here they have tests
|
||||||
|
* (app/admin/__tests__/gallery-ops.test.ts).
|
||||||
|
*
|
||||||
|
* An index that does not exist leaves the state as it is.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { arrayMove } from '@dnd-kit/sortable';
|
||||||
|
import type { GalleryState } from './galleryYaml';
|
||||||
|
import type { AlbumEntry, PickerTarget, Section, Subpage } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a list of albums lives: the standalone list, a subpage's own list, or
|
||||||
|
* a section of a subpage. PickerTarget and ActiveEditAlbumAddress both carry
|
||||||
|
* this shape.
|
||||||
|
*/
|
||||||
|
type AlbumListAddress = Pick<PickerTarget, 'type' | 'subpageIndex' | 'sectionIndex'>;
|
||||||
|
|
||||||
|
function mapSubpage(g: GalleryState, index: number, fn: (sp: Subpage) => Subpage): GalleryState {
|
||||||
|
if (!g.subpages[index]) return g;
|
||||||
|
return { ...g, subpages: g.subpages.map((sp, i) => (i === index ? fn(sp) : sp)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapSection(
|
||||||
|
g: GalleryState,
|
||||||
|
subpageIndex: number,
|
||||||
|
sectionIndex: number,
|
||||||
|
fn: (sec: Section) => Section,
|
||||||
|
): GalleryState {
|
||||||
|
if (!g.subpages[subpageIndex]?.sections?.[sectionIndex]) return g;
|
||||||
|
return mapSubpage(g, subpageIndex, (sp) => ({
|
||||||
|
...sp,
|
||||||
|
sections: sp.sections!.map((sec, i) => (i === sectionIndex ? fn(sec) : sec)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace the album list at an address. */
|
||||||
|
export function mapAlbums(
|
||||||
|
g: GalleryState,
|
||||||
|
at: AlbumListAddress,
|
||||||
|
fn: (albums: AlbumEntry[]) => AlbumEntry[],
|
||||||
|
): GalleryState {
|
||||||
|
if (at.type === 'standalone') return { ...g, albums: fn(g.albums) };
|
||||||
|
if (at.subpageIndex == null) return g;
|
||||||
|
if (at.type === 'subpage') {
|
||||||
|
return mapSubpage(g, at.subpageIndex, (sp) => ({ ...sp, albums: fn(sp.albums) }));
|
||||||
|
}
|
||||||
|
if (at.sectionIndex == null) return g;
|
||||||
|
return mapSection(g, at.subpageIndex, at.sectionIndex, (sec) => ({
|
||||||
|
...sec,
|
||||||
|
albums: fn(sec.albums),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The album list at an address, or undefined when the address is stale. */
|
||||||
|
export function albumsAt(g: GalleryState, at: AlbumListAddress): AlbumEntry[] | undefined {
|
||||||
|
if (at.type === 'standalone') return g.albums;
|
||||||
|
const sp = at.subpageIndex == null ? undefined : g.subpages[at.subpageIndex];
|
||||||
|
if (at.type === 'subpage') return sp?.albums;
|
||||||
|
return at.sectionIndex == null ? undefined : sp?.sections?.[at.sectionIndex]?.albums;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Albums ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const addAlbum = (g: GalleryState, at: AlbumListAddress, entry: AlbumEntry) =>
|
||||||
|
mapAlbums(g, at, (albums) => [...albums, entry]);
|
||||||
|
|
||||||
|
export const updateAlbum = (
|
||||||
|
g: GalleryState,
|
||||||
|
at: AlbumListAddress,
|
||||||
|
albumIndex: number,
|
||||||
|
updates: Partial<AlbumEntry>,
|
||||||
|
) =>
|
||||||
|
mapAlbums(g, at, (albums) => albums.map((a, i) => (i === albumIndex ? { ...a, ...updates } : a)));
|
||||||
|
|
||||||
|
export const removeAlbum = (g: GalleryState, at: AlbumListAddress, albumIndex: number) =>
|
||||||
|
mapAlbums(g, at, (albums) => albums.filter((_, i) => i !== albumIndex));
|
||||||
|
|
||||||
|
export const moveAlbum = (g: GalleryState, at: AlbumListAddress, from: number, to: number) =>
|
||||||
|
mapAlbums(g, at, (albums) => arrayMove(albums, from, to));
|
||||||
|
|
||||||
|
// ── Hero ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const addHero = (g: GalleryState, assetId: string): GalleryState => ({
|
||||||
|
...g,
|
||||||
|
hero: [...g.hero, assetId],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const removeHero = (g: GalleryState, index: number): GalleryState => ({
|
||||||
|
...g,
|
||||||
|
hero: g.hero.filter((_, i) => i !== index),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const moveHero = (g: GalleryState, from: number, to: number): GalleryState => ({
|
||||||
|
...g,
|
||||||
|
hero: arrayMove(g.hero, from, to),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Subpages and sections ───────────────────────────────────────
|
||||||
|
|
||||||
|
export const addSubpage = (g: GalleryState): GalleryState => ({
|
||||||
|
...g,
|
||||||
|
subpages: [
|
||||||
|
...g.subpages,
|
||||||
|
{ name: `New Page ${g.subpages.length + 1}`, albums: [], sections: undefined },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const removeSubpage = (g: GalleryState, index: number): GalleryState => ({
|
||||||
|
...g,
|
||||||
|
subpages: g.subpages.filter((_, i) => i !== index),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateSubpage = (g: GalleryState, index: number, updates: Partial<Subpage>) =>
|
||||||
|
mapSubpage(g, index, (sp) => ({ ...sp, ...updates }));
|
||||||
|
|
||||||
|
export const moveSubpage = (g: GalleryState, from: number, to: number): GalleryState => ({
|
||||||
|
...g,
|
||||||
|
subpages: arrayMove(g.subpages, from, to),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const addSection = (g: GalleryState, subpageIndex: number) =>
|
||||||
|
mapSubpage(g, subpageIndex, (sp) => ({
|
||||||
|
...sp,
|
||||||
|
sections: [...(sp.sections || []), { title: 'New Section', albums: [] }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const removeSection = (g: GalleryState, subpageIndex: number, sectionIndex: number) =>
|
||||||
|
mapSubpage(g, subpageIndex, (sp) => ({
|
||||||
|
...sp,
|
||||||
|
sections: (sp.sections || []).filter((_, i) => i !== sectionIndex),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const updateSection = (
|
||||||
|
g: GalleryState,
|
||||||
|
subpageIndex: number,
|
||||||
|
sectionIndex: number,
|
||||||
|
updates: Partial<Section>,
|
||||||
|
) => mapSection(g, subpageIndex, sectionIndex, (sec) => ({ ...sec, ...updates }));
|
||||||
|
|
||||||
|
// ── Reading ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** The "03 — Collection" number the page renders: counted over enabled subpages only. */
|
||||||
|
export function enabledPosition(g: GalleryState, index: number): number | undefined {
|
||||||
|
const sp = g.subpages[index];
|
||||||
|
if (!sp || sp.enabled === false) return undefined;
|
||||||
|
return g.subpages.slice(0, index).filter((s) => s.enabled !== false).length + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every album published anywhere, so the picker can mark the ones already in use. */
|
||||||
|
export function usedAlbumIds(g: GalleryState): Set<string> {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
g.albums.forEach((a) => ids.add(a.id));
|
||||||
|
g.subpages.forEach((sp) => {
|
||||||
|
sp.albums.forEach((a) => ids.add(a.id));
|
||||||
|
sp.sections?.forEach((sec) => sec.albums.forEach((a) => ids.add(a.id)));
|
||||||
|
});
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the open subpage sheet goes after a drag, so the sheet stays on the
|
||||||
|
* page it was showing.
|
||||||
|
*/
|
||||||
|
export function followMovedIndex(open: number | null, from: number, to: number): number | null {
|
||||||
|
if (open === null) return null;
|
||||||
|
if (open === from) return to;
|
||||||
|
if (from < open && to >= open) return open - 1;
|
||||||
|
if (from > open && to <= open) return open + 1;
|
||||||
|
return open;
|
||||||
|
}
|
||||||
130
app/admin/components/page-builder/galleryYaml.ts
Normal file
130
app/admin/components/page-builder/galleryYaml.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* gallery.yaml ⇄ the page builder's model (#608).
|
||||||
|
*
|
||||||
|
* This is the code that decides what ends up in gallery.yaml when the
|
||||||
|
* builder saves. It lived inside the 1,200-line PageBuilder component, where
|
||||||
|
* nothing could test it; as pure functions it has a table of cases in
|
||||||
|
* app/admin/__tests__/gallery-yaml.test.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { parseAlbumEntries, serializeAlbumEntries } from './albumEntries';
|
||||||
|
import { seedCoverGrid, type AlbumEntry, type Subpage } from './types';
|
||||||
|
|
||||||
|
export interface GalleryState {
|
||||||
|
hero: string[];
|
||||||
|
albums: AlbumEntry[];
|
||||||
|
subpages: Subpage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepts both subpage shapes gallery.yaml allows: the list of objects the
|
||||||
|
* builder writes, and the older map of name → albums (or name → object).
|
||||||
|
*/
|
||||||
|
export function parseGalleryYaml(raw: Record<string, unknown>): GalleryState {
|
||||||
|
const hero = Array.isArray(raw.hero) ? raw.hero : raw.hero ? [raw.hero as string] : [];
|
||||||
|
const albums = parseAlbumEntries(
|
||||||
|
raw.albums as Array<string | Record<string, string>> | undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
let subpages: Subpage[] = [];
|
||||||
|
if (Array.isArray(raw.subpages)) {
|
||||||
|
subpages = (raw.subpages as Array<Record<string, unknown>>).map((sp) => ({
|
||||||
|
name: (sp.name as string) || '',
|
||||||
|
title: sp.title as string | undefined,
|
||||||
|
subtitle: sp.subtitle as string | undefined,
|
||||||
|
password: sp.password as string | undefined,
|
||||||
|
enabled: sp.enabled !== false,
|
||||||
|
hidden: sp.hidden === true,
|
||||||
|
proofing: typeof sp.proofing === 'boolean' ? sp.proofing : undefined,
|
||||||
|
location: sp.location as string | undefined,
|
||||||
|
essayText: sp.essayText as string | undefined,
|
||||||
|
essayFile: sp.essayFile as string | undefined,
|
||||||
|
albums: parseAlbumEntries(sp.albums as Array<string | Record<string, string>> | undefined),
|
||||||
|
sections: sp.sections
|
||||||
|
? (sp.sections as Array<Record<string, unknown>>).map((sec) => ({
|
||||||
|
title: (sec.title as string) || '',
|
||||||
|
description: sec.description as string | undefined,
|
||||||
|
albums: parseAlbumEntries(sec.albums as Array<string | Record<string, string>>),
|
||||||
|
}))
|
||||||
|
: undefined,
|
||||||
|
grid: sp.grid as Subpage['grid'],
|
||||||
|
coverGrid:
|
||||||
|
(sp.coverGrid as Subpage['coverGrid']) ?? seedCoverGrid(sp.grid as Subpage['grid']),
|
||||||
|
}));
|
||||||
|
} else if (raw.subpages && typeof raw.subpages === 'object') {
|
||||||
|
subpages = Object.entries(raw.subpages as Record<string, unknown>).map(([name, value]) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return { name, albums: parseAlbumEntries(value), sections: undefined, enabled: true };
|
||||||
|
}
|
||||||
|
const sp = value as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
title: sp.title as string | undefined,
|
||||||
|
subtitle: sp.subtitle as string | undefined,
|
||||||
|
password: sp.password as string | undefined,
|
||||||
|
enabled: sp.enabled !== false,
|
||||||
|
hidden: sp.hidden === true,
|
||||||
|
proofing: typeof sp.proofing === 'boolean' ? sp.proofing : undefined,
|
||||||
|
location: sp.location as string | undefined,
|
||||||
|
essayText: sp.essayText as string | undefined,
|
||||||
|
essayFile: sp.essayFile as string | undefined,
|
||||||
|
albums: parseAlbumEntries(sp.albums as Array<string | Record<string, string>> | undefined),
|
||||||
|
sections: undefined,
|
||||||
|
grid: sp.grid as Subpage['grid'],
|
||||||
|
coverGrid:
|
||||||
|
(sp.coverGrid as Subpage['coverGrid']) ?? seedCoverGrid(sp.grid as Subpage['grid']),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { hero, albums, subpages };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The gallery.yaml object for a builder state. Empty groups are left out. */
|
||||||
|
export function serializeGallery(gallery: GalleryState): Record<string, unknown> {
|
||||||
|
const yamlData: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (gallery.hero.length > 0) {
|
||||||
|
yamlData.hero = gallery.hero;
|
||||||
|
}
|
||||||
|
if (gallery.albums.length > 0) {
|
||||||
|
yamlData.albums = serializeAlbumEntries(gallery.albums);
|
||||||
|
}
|
||||||
|
if (gallery.subpages.length > 0) {
|
||||||
|
yamlData.subpages = gallery.subpages.map((sp) => {
|
||||||
|
const entry: Record<string, unknown> = { name: sp.name };
|
||||||
|
if (sp.title) entry.title = sp.title;
|
||||||
|
if (sp.subtitle) entry.subtitle = sp.subtitle;
|
||||||
|
if (sp.password) entry.password = sp.password;
|
||||||
|
if (sp.enabled === false) entry.enabled = false;
|
||||||
|
if (sp.hidden === true) entry.hidden = true;
|
||||||
|
// Both values mean something: false switches proofing off for this page.
|
||||||
|
if (sp.proofing !== undefined) entry.proofing = sp.proofing;
|
||||||
|
if (sp.essayText) entry.essayText = sp.essayText;
|
||||||
|
if (sp.essayFile) entry.essayFile = sp.essayFile;
|
||||||
|
if (sp.grid) entry.grid = sp.grid;
|
||||||
|
if (sp.coverGrid) entry.coverGrid = sp.coverGrid;
|
||||||
|
if (sp.location) entry.location = sp.location;
|
||||||
|
|
||||||
|
if (sp.sections && sp.sections.length > 0) {
|
||||||
|
entry.sections = sp.sections.map((sec) => {
|
||||||
|
const s: Record<string, unknown> = {
|
||||||
|
title: sec.title,
|
||||||
|
albums: serializeAlbumEntries(sec.albums),
|
||||||
|
};
|
||||||
|
if (sec.description) s.description = sec.description;
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
if (sp.albums.length > 0) {
|
||||||
|
entry.albums = serializeAlbumEntries(sp.albums);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
entry.albums = serializeAlbumEntries(sp.albums);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return yamlData;
|
||||||
|
}
|
||||||
@@ -38,6 +38,11 @@ export interface Subpage {
|
|||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
/**
|
||||||
|
* Client proofing on this page, overriding the site-wide setting in either
|
||||||
|
* direction. No control in the builder yet; carried so a save keeps it.
|
||||||
|
*/
|
||||||
|
proofing?: boolean;
|
||||||
/** EXPERIMENTAL: reachable by direct link, but not shown in navigation */
|
/** EXPERIMENTAL: reachable by direct link, but not shown in navigation */
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
essayText?: string;
|
essayText?: string;
|
||||||
|
|||||||
287
app/admin/components/settings/AboutSection.tsx
Normal file
287
app/admin/components/settings/AboutSection.tsx
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import * as Icons from '../Icons';
|
||||||
|
import type { SaveStatus } from '../SaveBar';
|
||||||
|
import { useDraft, readDraft } from '../useDraft';
|
||||||
|
import { useContentRestored } from '../contentRestored';
|
||||||
|
|
||||||
|
interface AboutMeta {
|
||||||
|
portrait?: string;
|
||||||
|
name?: string;
|
||||||
|
location?: string;
|
||||||
|
gear?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The About editor's state, as kept in a draft. */
|
||||||
|
interface AboutDraft {
|
||||||
|
meta: AboutMeta;
|
||||||
|
body: string;
|
||||||
|
gearText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a draft of the About page is compared against: the file's content, not
|
||||||
|
* the textarea's. `gearText` is derived from `meta.gear` and would make a
|
||||||
|
* trailing newline read as a change on disk.
|
||||||
|
*/
|
||||||
|
function aboutFingerprint(about: AboutDraft): string {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { gear: _gear, ...meta } = about.meta;
|
||||||
|
const gear = about.gearText
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return JSON.stringify({ meta, gear, body: about.body });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The About page editor's state: content/about.md, loaded over its own
|
||||||
|
* endpoint and saved on its own, but reached through the settings nav and
|
||||||
|
* saved by the same save bar and ⌘S (#554).
|
||||||
|
*
|
||||||
|
* A hook rather than state inside the section, because the section unmounts
|
||||||
|
* when another one is opened while an About edit is still unsaved, and the
|
||||||
|
* save bar has to keep offering it.
|
||||||
|
*/
|
||||||
|
export function useAboutEditor(active: boolean) {
|
||||||
|
const [aboutMeta, setAboutMeta] = useState<AboutMeta>({});
|
||||||
|
const [aboutBody, setAboutBody] = useState('');
|
||||||
|
const [aboutLoading, setAboutLoading] = useState(false);
|
||||||
|
const [aboutLoaded, setAboutLoaded] = useState(false);
|
||||||
|
const [aboutSaving, setAboutSaving] = useState(false);
|
||||||
|
const [aboutDirty, setAboutDirty] = useState(false);
|
||||||
|
const [aboutStatus, setAboutStatus] = useState<SaveStatus>(null);
|
||||||
|
const [aboutGearText, setAboutGearText] = useState('');
|
||||||
|
const [serverAbout, setServerAbout] = useState<AboutDraft | null>(null);
|
||||||
|
|
||||||
|
// Unsaved About edits survive a tab switch or Reload, like settings (#592).
|
||||||
|
const draft = useDraft<AboutDraft>(
|
||||||
|
'about',
|
||||||
|
{ meta: aboutMeta, body: aboutBody, gearText: aboutGearText },
|
||||||
|
aboutDirty,
|
||||||
|
);
|
||||||
|
const loadDraft = draft.load;
|
||||||
|
|
||||||
|
const applyAbout = (value: AboutDraft) => {
|
||||||
|
setAboutMeta(value.meta);
|
||||||
|
setAboutBody(value.body);
|
||||||
|
setAboutGearText(value.gearText);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAboutContent = useCallback(async () => {
|
||||||
|
setAboutLoading(true);
|
||||||
|
setAboutStatus(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/about');
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(
|
||||||
|
res.status === 401
|
||||||
|
? 'Your session has expired. Sign in again to continue.'
|
||||||
|
: `The server answered ${res.status}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
const loaded: AboutDraft = {
|
||||||
|
meta: data.meta || {},
|
||||||
|
body: data.body || '',
|
||||||
|
gearText: data.meta?.gear?.join('\n') || '',
|
||||||
|
};
|
||||||
|
setServerAbout(loaded);
|
||||||
|
const restored = loadDraft(aboutFingerprint(loaded));
|
||||||
|
const value = restored ?? loaded;
|
||||||
|
setAboutMeta(value.meta);
|
||||||
|
setAboutBody(value.body);
|
||||||
|
setAboutGearText(value.gearText);
|
||||||
|
setAboutDirty(restored !== null);
|
||||||
|
setAboutLoaded(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load about content:', err);
|
||||||
|
// aboutLoaded stays false, which is what blocks the save below: an empty
|
||||||
|
// editor written to about.md would replace the page with nothing.
|
||||||
|
setAboutStatus({
|
||||||
|
kind: 'error',
|
||||||
|
message: `Error: ${err instanceof Error ? err.message : 'The About page could not be loaded.'}`,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setAboutLoading(false);
|
||||||
|
}
|
||||||
|
}, [loadDraft]);
|
||||||
|
|
||||||
|
async function saveAboutContent() {
|
||||||
|
if (!aboutLoaded) return;
|
||||||
|
|
||||||
|
setAboutSaving(true);
|
||||||
|
setAboutStatus(null);
|
||||||
|
const cleanedMeta = { ...aboutMeta };
|
||||||
|
for (const [k, v] of Object.entries(cleanedMeta)) {
|
||||||
|
if (v === '' || v === undefined) delete cleanedMeta[k as keyof typeof cleanedMeta];
|
||||||
|
}
|
||||||
|
const gearLines = aboutGearText
|
||||||
|
.split('\n')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (gearLines.length > 0) {
|
||||||
|
cleanedMeta.gear = gearLines;
|
||||||
|
} else {
|
||||||
|
delete cleanedMeta.gear;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/about', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ meta: cleanedMeta, body: aboutBody }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
const saved: AboutDraft = { meta: cleanedMeta, body: aboutBody, gearText: aboutGearText };
|
||||||
|
setServerAbout(saved);
|
||||||
|
draft.saved(aboutFingerprint(saved));
|
||||||
|
setAboutDirty(false);
|
||||||
|
setAboutStatus({ kind: 'success', message: data.message || 'Saved!' });
|
||||||
|
setTimeout(() => setAboutStatus(null), 4000);
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
setAboutStatus({ kind: 'error', message: `Error: ${err.error}` });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setAboutStatus({ kind: 'error', message: 'Error: Failed to save' });
|
||||||
|
} finally {
|
||||||
|
setAboutSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loaded the first time the About section opens, and only then: the editor
|
||||||
|
// stays mounted across sections, so reloading on every visit would overwrite
|
||||||
|
// unsaved About edits with the file on disk.
|
||||||
|
useEffect(() => {
|
||||||
|
if (active && !aboutLoaded) loadAboutContent();
|
||||||
|
}, [active, aboutLoaded, loadAboutContent]);
|
||||||
|
|
||||||
|
// A kept About draft loads the file at once, from any section, so the save
|
||||||
|
// bar owns up to those edits before the About section is ever opened.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active && readDraft('about')) loadAboutContent();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// A restored about.md replaces what this editor loaded.
|
||||||
|
useContentRestored(({ target }) => {
|
||||||
|
if (target === 'about' && aboutLoaded) loadAboutContent();
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateAboutMeta(key: string, value: unknown) {
|
||||||
|
setAboutMeta((m) => ({ ...m, [key]: value }));
|
||||||
|
setAboutDirty(true);
|
||||||
|
setAboutStatus(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
meta: aboutMeta,
|
||||||
|
body: aboutBody,
|
||||||
|
gearText: aboutGearText,
|
||||||
|
loading: aboutLoading,
|
||||||
|
saving: aboutSaving,
|
||||||
|
dirty: aboutDirty,
|
||||||
|
status: aboutStatus as SaveStatus,
|
||||||
|
updateMeta: updateAboutMeta,
|
||||||
|
setBody: (value: string) => {
|
||||||
|
setAboutBody(value);
|
||||||
|
setAboutDirty(true);
|
||||||
|
setAboutStatus(null);
|
||||||
|
},
|
||||||
|
setGearText: (value: string) => {
|
||||||
|
setAboutGearText(value);
|
||||||
|
setAboutDirty(true);
|
||||||
|
setAboutStatus(null);
|
||||||
|
},
|
||||||
|
save: saveAboutContent,
|
||||||
|
draftStatus: draft.status,
|
||||||
|
dismissDraft: draft.dismiss,
|
||||||
|
discardDraft: () => {
|
||||||
|
draft.discard();
|
||||||
|
if (serverAbout) applyAbout(serverAbout);
|
||||||
|
setAboutDirty(false);
|
||||||
|
setAboutStatus(null);
|
||||||
|
},
|
||||||
|
restoreDraft: () => {
|
||||||
|
const value = draft.takeConflicting();
|
||||||
|
if (!value) return;
|
||||||
|
applyAbout(value);
|
||||||
|
setAboutDirty(true);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AboutEditor = ReturnType<typeof useAboutEditor>;
|
||||||
|
|
||||||
|
export default function AboutSection({ about }: { about: AboutEditor }) {
|
||||||
|
return (
|
||||||
|
<div className="settings-panel">
|
||||||
|
<div className="settings-section-header">
|
||||||
|
<h3>
|
||||||
|
<Icons.IconCamera size={18} /> About Page
|
||||||
|
</h3>
|
||||||
|
<p className="settings-section-sub">
|
||||||
|
Edit the portrait, biography and gear shown on the About page. Use General to show or hide
|
||||||
|
the page itself.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{about.loading ? (
|
||||||
|
<div className="admin-spinner" style={{ margin: '2rem auto' }} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="about-portrait-asset-id">Portrait Asset ID</label>
|
||||||
|
<input
|
||||||
|
id="about-portrait-asset-id"
|
||||||
|
value={about.meta.portrait || ''}
|
||||||
|
onChange={(e) => about.updateMeta('portrait', e.target.value)}
|
||||||
|
placeholder="Immich asset UUID for the portrait photo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="about-name">Name</label>
|
||||||
|
<input
|
||||||
|
id="about-name"
|
||||||
|
value={about.meta.name || ''}
|
||||||
|
onChange={(e) => about.updateMeta('name', e.target.value)}
|
||||||
|
placeholder="Your name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="about-location">Location</label>
|
||||||
|
<input
|
||||||
|
id="about-location"
|
||||||
|
value={about.meta.location || ''}
|
||||||
|
onChange={(e) => about.updateMeta('location', e.target.value)}
|
||||||
|
placeholder="City, Country"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="about-gear">Gear (one per line)</label>
|
||||||
|
<textarea
|
||||||
|
id="about-gear"
|
||||||
|
value={about.gearText}
|
||||||
|
onChange={(e) => about.setGearText(e.target.value)}
|
||||||
|
placeholder={`Leica Q3\nSummilux 35mm f/1.4`}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="about-biography">Biography (Markdown)</label>
|
||||||
|
<textarea
|
||||||
|
id="about-biography"
|
||||||
|
value={about.body}
|
||||||
|
onChange={(e) => about.setBody(e.target.value)}
|
||||||
|
placeholder="Photographer based in..."
|
||||||
|
rows={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
app/admin/components/settings/FooterSection.tsx
Normal file
118
app/admin/components/settings/FooterSection.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as Icons from '../Icons';
|
||||||
|
import type { SectionProps } from './types';
|
||||||
|
|
||||||
|
export default function FooterSection({ settings, update }: SectionProps) {
|
||||||
|
return (
|
||||||
|
<div className="settings-panel">
|
||||||
|
<div className="settings-section-header">
|
||||||
|
<h3>
|
||||||
|
<Icons.IconLink size={18} /> Footer & Social Links
|
||||||
|
</h3>
|
||||||
|
<p className="settings-section-sub">
|
||||||
|
Display branding, Instagram, email and website links in portfolio footer.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="footer-brand-name">Footer Brand Name</label>
|
||||||
|
<input
|
||||||
|
id="footer-brand-name"
|
||||||
|
value={settings.footer?.name || ''}
|
||||||
|
onChange={(e) => update('footer.name', e.target.value)}
|
||||||
|
placeholder="My Photography"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="footer-instagram-url">Instagram URL</label>
|
||||||
|
<input
|
||||||
|
id="footer-instagram-url"
|
||||||
|
value={settings.footer?.instagram || ''}
|
||||||
|
onChange={(e) => update('footer.instagram', e.target.value)}
|
||||||
|
placeholder="https://instagram.com/your-handle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field-row">
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="footer-contact-email">Contact Email</label>
|
||||||
|
<input
|
||||||
|
id="footer-contact-email"
|
||||||
|
value={settings.footer?.email || ''}
|
||||||
|
onChange={(e) => update('footer.email', e.target.value)}
|
||||||
|
placeholder="hello@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="footer-personal-website">Personal Website</label>
|
||||||
|
<input
|
||||||
|
id="footer-personal-website"
|
||||||
|
value={settings.footer?.website || ''}
|
||||||
|
onChange={(e) => update('footer.website', e.target.value)}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-section-header" style={{ marginTop: '2rem' }}>
|
||||||
|
<h3>
|
||||||
|
<Icons.IconLink size={18} /> Header Navigation Links (Experimental)
|
||||||
|
</h3>
|
||||||
|
<p className="settings-section-sub">
|
||||||
|
External links shown after your pages in the header menu. Only http(s) URLs are allowed;
|
||||||
|
they open in a new tab.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(settings.navLinks || []).map((link, i) => (
|
||||||
|
<div className="admin-field-row" key={i}>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="footer-label">Label</label>
|
||||||
|
<input
|
||||||
|
id="footer-label"
|
||||||
|
value={link.label || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...(settings.navLinks || [])];
|
||||||
|
next[i] = { ...next[i], label: e.target.value };
|
||||||
|
update('navLinks', next);
|
||||||
|
}}
|
||||||
|
placeholder="Shop"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="footer-url">URL</label>
|
||||||
|
<input
|
||||||
|
id="footer-url"
|
||||||
|
value={link.url || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...(settings.navLinks || [])];
|
||||||
|
next[i] = { ...next[i], url: e.target.value };
|
||||||
|
update('navLinks', next);
|
||||||
|
}}
|
||||||
|
placeholder="https://shop.example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-btn admin-btn-sm"
|
||||||
|
style={{ alignSelf: 'flex-end' }}
|
||||||
|
onClick={() => {
|
||||||
|
const next = (settings.navLinks || []).filter((_, j) => j !== i);
|
||||||
|
update('navLinks', next.length > 0 ? next : undefined);
|
||||||
|
}}
|
||||||
|
title="Remove link"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-btn admin-btn-sm"
|
||||||
|
onClick={() => update('navLinks', [...(settings.navLinks || []), { label: '', url: '' }])}
|
||||||
|
>
|
||||||
|
+ Add external link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
311
app/admin/components/settings/GeneralSection.tsx
Normal file
311
app/admin/components/settings/GeneralSection.tsx
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import * as Icons from '../Icons';
|
||||||
|
import type { SaveStatus } from '../SaveBar';
|
||||||
|
import { resolveExifDisplay } from '@/lib/config/schema';
|
||||||
|
import { SUPPORTED_LOCALES } from '@/lib/i18n';
|
||||||
|
import { FeatureGroup, FeatureRow, SettingRow } from './fields';
|
||||||
|
import type { SectionProps } from './types';
|
||||||
|
import { useConfirm } from '../ConfirmDialog';
|
||||||
|
|
||||||
|
export default function GeneralSection({ settings, update, updateMany }: SectionProps) {
|
||||||
|
const confirm = useConfirm();
|
||||||
|
// Collapsed by default: the four metadata switches are a detail of one
|
||||||
|
// decision, and showing them permanently is what made the section a wall (#510).
|
||||||
|
const [metadataOpen, setMetadataOpen] = useState(false);
|
||||||
|
const [faviconUploading, setFaviconUploading] = useState(false);
|
||||||
|
const [faviconStatus, setFaviconStatus] = useState<SaveStatus>(null);
|
||||||
|
|
||||||
|
// Resolved the same way the site resolves it, so the switches show what a
|
||||||
|
// visitor actually sees — including a config that only ever set the older
|
||||||
|
// `exifOnHover`.
|
||||||
|
const exif = resolveExifDisplay(settings.exif, settings.exifOnHover);
|
||||||
|
|
||||||
|
// The master switch has no key of its own and needs none: "off" is all four
|
||||||
|
// groups off, a state the site already acts on — with nothing left to show,
|
||||||
|
// the lightbox withdraws its info button and the `i` key entirely (#506).
|
||||||
|
const anyMetadata = exif.camera || exif.settings || exif.location || exif.caption;
|
||||||
|
const metadataSummary =
|
||||||
|
[
|
||||||
|
exif.camera && 'Camera & lens',
|
||||||
|
exif.settings && 'Exposure',
|
||||||
|
exif.location && 'Location',
|
||||||
|
exif.caption && 'Description',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ') || 'Nothing published — the lightbox hides its info panel';
|
||||||
|
|
||||||
|
/** Turning it back on selects everything; the details below narrow it again. */
|
||||||
|
const toggleMetadata = () =>
|
||||||
|
updateMany({
|
||||||
|
'exif.camera': !anyMetadata,
|
||||||
|
'exif.settings': !anyMetadata,
|
||||||
|
'exif.location': !anyMetadata,
|
||||||
|
'exif.caption': !anyMetadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-panel">
|
||||||
|
<div className="settings-section-header">
|
||||||
|
<h3>
|
||||||
|
<Icons.IconGear size={18} /> General Site Settings
|
||||||
|
</h3>
|
||||||
|
<p className="settings-section-sub">
|
||||||
|
Configure basic site identity, language, and core feature toggles.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="general-site-title">Site Title</label>
|
||||||
|
<input
|
||||||
|
id="general-site-title"
|
||||||
|
value={settings.title || ''}
|
||||||
|
onChange={(e) => update('title', e.target.value)}
|
||||||
|
placeholder="My Portfolio"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="general-subtitle">Subtitle</label>
|
||||||
|
<input
|
||||||
|
id="general-subtitle"
|
||||||
|
value={settings.subtitle || ''}
|
||||||
|
onChange={(e) => update('subtitle', e.target.value)}
|
||||||
|
placeholder="A visual journal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="general-language">Language</label>
|
||||||
|
<select
|
||||||
|
id="general-language"
|
||||||
|
aria-label="Language"
|
||||||
|
value={settings.lang || 'en'}
|
||||||
|
onChange={(e) => update('lang', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="en">English (US)</option>
|
||||||
|
<option value="de">Deutsch (DE)</option>
|
||||||
|
<option value="fr">Français (FR)</option>
|
||||||
|
<option value="es">Español (ES)</option>
|
||||||
|
<option value="it">Italiano (IT)</option>
|
||||||
|
<option value="nl">Nederlands (NL)</option>
|
||||||
|
<option value="ja">日本語 (JA)</option>
|
||||||
|
</select>
|
||||||
|
<p className="admin-field-hint">
|
||||||
|
The visitor-facing interface is translated for {SUPPORTED_LOCALES.join(', ')}. Other
|
||||||
|
languages still set <code><html lang></code> and date formatting, but show the
|
||||||
|
English interface. The admin panel is always English.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-section-divider" />
|
||||||
|
|
||||||
|
<div className="settings-section-header">
|
||||||
|
<h3>
|
||||||
|
<Icons.IconSparkles size={18} /> Portfolio Features & Modules
|
||||||
|
</h3>
|
||||||
|
<p className="settings-section-sub">
|
||||||
|
Optional pages, and what the site reveals about your photos and counts about visits.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FeatureGroup
|
||||||
|
icon={<Icons.IconFrame size={13} />}
|
||||||
|
title="Modules"
|
||||||
|
description="Each one adds a page or a control visitors can use."
|
||||||
|
>
|
||||||
|
<div className="feature-list">
|
||||||
|
<FeatureRow
|
||||||
|
icon={<Icons.IconCamera size={15} />}
|
||||||
|
title="About page"
|
||||||
|
description="Portrait, bio and gear at /about"
|
||||||
|
checked={settings.about?.enabled !== false}
|
||||||
|
onToggle={() => update('about.enabled', settings.about?.enabled === false)}
|
||||||
|
href="/admin/settings/about"
|
||||||
|
/>
|
||||||
|
<FeatureRow
|
||||||
|
icon={<Icons.IconMap size={15} />}
|
||||||
|
title="Map"
|
||||||
|
description="Photo locations on a world map at /map"
|
||||||
|
checked={settings.map === true}
|
||||||
|
onToggle={() => update('map', !settings.map)}
|
||||||
|
/>
|
||||||
|
<FeatureRow
|
||||||
|
icon={<Icons.IconHeart size={15} />}
|
||||||
|
title="Client proofing"
|
||||||
|
description="Visitors heart, filter and export a selection of photos"
|
||||||
|
checked={settings.proofing?.enabled !== false}
|
||||||
|
onToggle={() => update('proofing.enabled', settings.proofing?.enabled === false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FeatureGroup>
|
||||||
|
|
||||||
|
<FeatureGroup
|
||||||
|
icon={<Icons.IconShieldCheck size={13} />}
|
||||||
|
title="What visitors learn about you"
|
||||||
|
description="What each photo reveals, and what is counted about a visit."
|
||||||
|
chip="visible to visitors"
|
||||||
|
>
|
||||||
|
<div className={`metadata-card ${anyMetadata ? 'active' : ''}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="metadata-card-main"
|
||||||
|
onClick={toggleMetadata}
|
||||||
|
aria-pressed={anyMetadata}
|
||||||
|
>
|
||||||
|
<span className="toggle-card-info">
|
||||||
|
<span className="toggle-card-title">
|
||||||
|
<Icons.IconCamera size={16} /> Photo metadata
|
||||||
|
</span>
|
||||||
|
<span className="toggle-card-desc">{metadataSummary}</span>
|
||||||
|
</span>
|
||||||
|
<span className={`switch-toggle ${anyMetadata ? 'on' : ''}`}>
|
||||||
|
<span className="switch-slider" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="metadata-details-btn"
|
||||||
|
onClick={() => setMetadataOpen((open) => !open)}
|
||||||
|
aria-expanded={metadataOpen}
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
<Icons.IconChevronDown
|
||||||
|
size={13}
|
||||||
|
className={`metadata-details-chevron${metadataOpen ? ' open' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{metadataOpen && (
|
||||||
|
<div className="metadata-details">
|
||||||
|
<SettingRow
|
||||||
|
title="Camera & Lens"
|
||||||
|
description="Body, lens and focal length"
|
||||||
|
checked={exif.camera}
|
||||||
|
onToggle={() => update('exif.camera', !exif.camera)}
|
||||||
|
/>
|
||||||
|
<SettingRow
|
||||||
|
title="Exposure Settings"
|
||||||
|
description="Aperture, shutter speed and ISO"
|
||||||
|
checked={exif.settings}
|
||||||
|
onToggle={() => update('exif.settings', !exif.settings)}
|
||||||
|
/>
|
||||||
|
<SettingRow
|
||||||
|
title="Location"
|
||||||
|
description="City and country from the photo's GPS data"
|
||||||
|
checked={exif.location}
|
||||||
|
onToggle={() => update('exif.location', !exif.location)}
|
||||||
|
/>
|
||||||
|
<SettingRow
|
||||||
|
title="Photo Description"
|
||||||
|
description="The description written in Immich"
|
||||||
|
checked={exif.caption}
|
||||||
|
onToggle={() => update('exif.caption', !exif.caption)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Dependent, not a peer: this only controls the grid overlay, and
|
||||||
|
the overlay carries camera and lens. With those off it has
|
||||||
|
nothing to show, which used to leave it switched on and
|
||||||
|
silently doing nothing (#510). */}
|
||||||
|
<SettingRow
|
||||||
|
indented
|
||||||
|
title="Summary on grid hover"
|
||||||
|
description="Camera and lens over the photo, not only in the lightbox"
|
||||||
|
checked={settings.exifOnHover !== false}
|
||||||
|
disabled={!exif.camera}
|
||||||
|
hint={exif.camera ? undefined : 'Needs Camera & Lens'}
|
||||||
|
onToggle={() => update('exifOnHover', settings.exifOnHover === false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="feature-list">
|
||||||
|
<FeatureRow
|
||||||
|
icon={<Icons.IconBarChart size={15} />}
|
||||||
|
title="Analytics"
|
||||||
|
description="Cookieless view counts, stored on your server only"
|
||||||
|
checked={settings.analytics !== false}
|
||||||
|
onToggle={() => update('analytics', settings.analytics === false)}
|
||||||
|
href="/admin/analytics"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FeatureGroup>
|
||||||
|
|
||||||
|
<div className="admin-field favicon-field">
|
||||||
|
<span className="admin-field-label">Favicon</span>
|
||||||
|
<div className="favicon-row">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".svg,.png,.ico,.jpg,.jpeg"
|
||||||
|
disabled={faviconUploading}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setFaviconStatus(null);
|
||||||
|
setFaviconUploading(true);
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/favicon', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setFaviconStatus(
|
||||||
|
res.ok
|
||||||
|
? { kind: 'success', message: data.message }
|
||||||
|
: { kind: 'error', message: `Error: ${data.error}` },
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setFaviconStatus({ kind: 'error', message: 'Error: Upload failed' });
|
||||||
|
} finally {
|
||||||
|
setFaviconUploading(false);
|
||||||
|
e.target.value = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-btn"
|
||||||
|
disabled={faviconUploading}
|
||||||
|
onClick={async () => {
|
||||||
|
// Deletes the uploaded file at once, outside the staged form.
|
||||||
|
const ok = await confirm({
|
||||||
|
title: 'Reset the favicon?',
|
||||||
|
message: 'The uploaded icon is deleted and the bundled default is used again.',
|
||||||
|
confirmLabel: 'Reset',
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
setFaviconStatus(null);
|
||||||
|
setFaviconUploading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/favicon', { method: 'DELETE' });
|
||||||
|
const data = await res.json();
|
||||||
|
setFaviconStatus(
|
||||||
|
res.ok
|
||||||
|
? { kind: 'success', message: data.message }
|
||||||
|
: { kind: 'error', message: `Error: ${data.error}` },
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setFaviconStatus({ kind: 'error', message: 'Error: Reset failed' });
|
||||||
|
} finally {
|
||||||
|
setFaviconUploading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
{faviconUploading && <div className="admin-spinner" />}
|
||||||
|
</div>
|
||||||
|
{faviconStatus && (
|
||||||
|
<p className={`save-message ${faviconStatus.kind}`}>{faviconStatus.message}</p>
|
||||||
|
)}
|
||||||
|
<span className="admin-field-hint">
|
||||||
|
SVG, PNG, ICO, or JPEG — max 512 kB. Stored in the content volume. Reset restores the
|
||||||
|
bundled default.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
204
app/admin/components/settings/GridSection.tsx
Normal file
204
app/admin/components/settings/GridSection.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as Icons from '../Icons';
|
||||||
|
import OptionGrid, { toOptions } from '../fields/OptionGrid';
|
||||||
|
import {
|
||||||
|
PHOTO_GRID_COLUMNS_MAX,
|
||||||
|
PHOTO_GRID_COLUMNS_MIN,
|
||||||
|
PHOTO_GRID_GAP_MAX,
|
||||||
|
} from '@/lib/config/schema';
|
||||||
|
import type { SectionProps } from './types';
|
||||||
|
|
||||||
|
const LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow', 'justified'];
|
||||||
|
const ASPECT_RATIOS = ['1', '3/2', '2/3', '16/9', 'auto'];
|
||||||
|
|
||||||
|
const LAYOUT_INFO: Record<string, { label: string; desc: string }> = {
|
||||||
|
masonry: {
|
||||||
|
label: 'Masonry',
|
||||||
|
desc: 'Dynamic pinterest-style staggered columns',
|
||||||
|
},
|
||||||
|
uniform: { label: 'Uniform Grid', desc: 'Clean equal aspect ratio grid' },
|
||||||
|
showcase: {
|
||||||
|
label: 'Showcase',
|
||||||
|
desc: 'Featured hero photos mixed with smaller tiles',
|
||||||
|
},
|
||||||
|
filmstrip: {
|
||||||
|
label: 'Filmstrip',
|
||||||
|
desc: 'Horizontal scrollable film strip timeline',
|
||||||
|
},
|
||||||
|
'editorial-flow': {
|
||||||
|
label: 'Editorial Flow',
|
||||||
|
desc: 'Magazine story layout with varying photo sizes',
|
||||||
|
},
|
||||||
|
justified: {
|
||||||
|
label: 'Justified (Experimental)',
|
||||||
|
desc: 'Equal-height rows that fill the full width',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const LAYOUT_OPTIONS = toOptions(LAYOUTS, LAYOUT_INFO);
|
||||||
|
|
||||||
|
function LayoutPreview({ value }: { value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="grid-card-preview">
|
||||||
|
<div className={`mini-layout-demo layout-demo-${value}`}>
|
||||||
|
{value === 'masonry' && (
|
||||||
|
<div className="demo-masonry-col-group">
|
||||||
|
<div className="demo-col">
|
||||||
|
<div className="demo-tile h-high" />
|
||||||
|
<div className="demo-tile h-low" />
|
||||||
|
</div>
|
||||||
|
<div className="demo-col">
|
||||||
|
<div className="demo-tile h-low" />
|
||||||
|
<div className="demo-tile h-high" />
|
||||||
|
</div>
|
||||||
|
<div className="demo-col">
|
||||||
|
<div className="demo-tile h-med" />
|
||||||
|
<div className="demo-tile h-med" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{value === 'uniform' && (
|
||||||
|
<div className="demo-uniform-grid">
|
||||||
|
<div className="demo-tile" />
|
||||||
|
<div className="demo-tile" />
|
||||||
|
<div className="demo-tile" />
|
||||||
|
<div className="demo-tile" />
|
||||||
|
<div className="demo-tile" />
|
||||||
|
<div className="demo-tile" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{value === 'showcase' && (
|
||||||
|
<div className="demo-showcase-grid">
|
||||||
|
<div className="demo-tile demo-hero" />
|
||||||
|
<div className="demo-col">
|
||||||
|
<div className="demo-tile" />
|
||||||
|
<div className="demo-tile" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{value === 'filmstrip' && (
|
||||||
|
<div className="demo-filmstrip-row">
|
||||||
|
<div className="demo-tile strip" />
|
||||||
|
<div className="demo-tile strip" />
|
||||||
|
<div className="demo-tile strip" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{value === 'editorial-flow' && (
|
||||||
|
<div className="demo-editorial-flow">
|
||||||
|
<div className="demo-tile wide" />
|
||||||
|
<div className="demo-row">
|
||||||
|
<div className="demo-tile" />
|
||||||
|
<div className="demo-tile" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{value === 'justified' && (
|
||||||
|
<div className="demo-editorial-flow">
|
||||||
|
<div className="demo-row">
|
||||||
|
<div className="demo-tile wide" />
|
||||||
|
<div className="demo-tile" />
|
||||||
|
</div>
|
||||||
|
<div className="demo-row">
|
||||||
|
<div className="demo-tile" />
|
||||||
|
<div className="demo-tile wide" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ASPECT_RATIO_INFO: Record<string, { label: string; desc: string }> = {
|
||||||
|
'1': { label: 'Square (1:1)', desc: '1:1 ratio square crops' },
|
||||||
|
'3/2': { label: 'Landscape (3:2)', desc: 'Standard 35mm DSLR landscape' },
|
||||||
|
'2/3': { label: 'Portrait (2:3)', desc: 'Vertical portrait orientation' },
|
||||||
|
'16/9': { label: 'Cinema (16:9)', desc: 'Widescreen 16:9 cinematic ratio' },
|
||||||
|
auto: {
|
||||||
|
label: 'Original Auto',
|
||||||
|
desc: 'Uncropped original image proportions',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ASPECT_RATIO_OPTIONS = toOptions(ASPECT_RATIOS, ASPECT_RATIO_INFO);
|
||||||
|
|
||||||
|
function AspectRatioPreview({ value }: { value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="ratio-card-preview">
|
||||||
|
<div className={`mini-aspect-box ratio-${value.replace('/', '-')}`}>
|
||||||
|
<div className="mini-aspect-inner" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GridSection({ settings, update }: SectionProps) {
|
||||||
|
return (
|
||||||
|
<div className="settings-panel">
|
||||||
|
<div className="settings-section-header">
|
||||||
|
<h3>
|
||||||
|
<Icons.IconGrid size={18} /> Grid & Layout Engine
|
||||||
|
</h3>
|
||||||
|
<p className="settings-section-sub">
|
||||||
|
Configure photography gallery column structures and thumbnail aspect ratios.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OptionGrid
|
||||||
|
label="Layout Algorithm"
|
||||||
|
options={LAYOUT_OPTIONS}
|
||||||
|
value={settings.grid?.layout || 'masonry'}
|
||||||
|
onSelect={(v) => update('grid.layout', v)}
|
||||||
|
cardClassName="grid-layout-card"
|
||||||
|
renderPreview={(o) => <LayoutPreview value={o.value} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OptionGrid
|
||||||
|
label="Aspect Ratio"
|
||||||
|
options={ASPECT_RATIO_OPTIONS}
|
||||||
|
value={settings.grid?.aspectRatio || '1'}
|
||||||
|
onSelect={(v) => update('grid.aspectRatio', v)}
|
||||||
|
cardClassName="ratio-card"
|
||||||
|
renderPreview={(o) => <AspectRatioPreview value={o.value} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="settings-section-divider" />
|
||||||
|
|
||||||
|
<div className="settings-section-header">
|
||||||
|
<h3>
|
||||||
|
<Icons.IconColumns size={18} /> Spacing & Columns
|
||||||
|
</h3>
|
||||||
|
<p className="settings-section-sub">
|
||||||
|
Adjust column counts and grid gap spacing. The column count also tiles the album covers on
|
||||||
|
a subpage; their spacing stays with the theme unless a page overrides it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-field-row">
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="grid-columns">
|
||||||
|
Columns ({PHOTO_GRID_COLUMNS_MIN} - {PHOTO_GRID_COLUMNS_MAX})
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="grid-columns"
|
||||||
|
type="number"
|
||||||
|
min={PHOTO_GRID_COLUMNS_MIN}
|
||||||
|
max={PHOTO_GRID_COLUMNS_MAX}
|
||||||
|
value={settings.grid?.columns ?? 3}
|
||||||
|
onChange={(e) => update('grid.columns', parseInt(e.target.value) || 3)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="grid-gap-spacing">Gap Spacing (px)</label>
|
||||||
|
<input
|
||||||
|
id="grid-gap-spacing"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={PHOTO_GRID_GAP_MAX}
|
||||||
|
value={settings.grid?.gap ?? 12}
|
||||||
|
onChange={(e) => update('grid.gap', parseInt(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
255
app/admin/components/settings/LegalSection.tsx
Normal file
255
app/admin/components/settings/LegalSection.tsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as Icons from '../Icons';
|
||||||
|
import ToggleCard from '../fields/ToggleCard';
|
||||||
|
import PrivacyEditor from '../PrivacyEditor';
|
||||||
|
import { CONTACT_RETENTION_DEFAULT, CONTACT_RETENTION_MAX, isHttpUrl } from '@/lib/config/schema';
|
||||||
|
import type { SectionProps } from './types';
|
||||||
|
|
||||||
|
export default function LegalSection({ settings, update }: SectionProps) {
|
||||||
|
// The Impressum drops a non-http(s) contact URL with a warning in the server
|
||||||
|
// log, where nobody looks; say it here instead.
|
||||||
|
const contactUrl = settings.legal?.contactUrl?.trim();
|
||||||
|
const contactUrlInvalid = !!contactUrl && !isHttpUrl(contactUrl);
|
||||||
|
const notifyUrl = settings.contact?.notifyUrl?.trim();
|
||||||
|
const notifyUrlInvalid = !!notifyUrl && !isHttpUrl(notifyUrl);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-panel">
|
||||||
|
<div className="settings-section-header">
|
||||||
|
<h3>
|
||||||
|
<Icons.IconScale size={18} /> Legal Notice & Impressum
|
||||||
|
</h3>
|
||||||
|
<p className="settings-section-sub">
|
||||||
|
Configure the legal disclosure page required by the German Digitale-Dienste-Gesetz (DDG).
|
||||||
|
</p>
|
||||||
|
{settings.legal?.enabled && (
|
||||||
|
<a
|
||||||
|
href="/impressum"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="admin-btn admin-btn-sm settings-section-action"
|
||||||
|
>
|
||||||
|
View /impressum ↗
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-toggle-cards-grid" style={{ marginBottom: '1.25rem' }}>
|
||||||
|
<ToggleCard
|
||||||
|
icon={<Icons.IconFileText size={16} />}
|
||||||
|
title="Enable Impressum Page (/impressum)"
|
||||||
|
description="Automatically generates and links /impressum in footer"
|
||||||
|
checked={settings.legal?.enabled === true}
|
||||||
|
onToggle={() => update('legal.enabled', !settings.legal?.enabled)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{settings.legal?.enabled && (
|
||||||
|
<>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="legal-heading">Heading</label>
|
||||||
|
<input
|
||||||
|
id="legal-heading"
|
||||||
|
value={settings.legal?.heading || ''}
|
||||||
|
onChange={(e) => update('legal.heading', e.target.value)}
|
||||||
|
placeholder="Angaben gemäß § 5 DDG"
|
||||||
|
/>
|
||||||
|
<p className="admin-field-hint">
|
||||||
|
Leave empty for the § 5 DDG line in the site language. Set it when a different law
|
||||||
|
applies, e.g. § 5 ECG in Austria.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field-row">
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="legal-full-name-business-name">Full Name / Business Name</label>
|
||||||
|
<input
|
||||||
|
id="legal-full-name-business-name"
|
||||||
|
value={settings.legal?.name || ''}
|
||||||
|
onChange={(e) => update('legal.name', e.target.value)}
|
||||||
|
placeholder="Max Mustermann"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="legal-street-address">Street Address</label>
|
||||||
|
<input
|
||||||
|
id="legal-street-address"
|
||||||
|
value={settings.legal?.address || ''}
|
||||||
|
onChange={(e) => update('legal.address', e.target.value)}
|
||||||
|
placeholder="Musterstraße 1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field-row">
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="legal-zip-city">ZIP & City</label>
|
||||||
|
<input
|
||||||
|
id="legal-zip-city"
|
||||||
|
value={settings.legal?.zipCity || ''}
|
||||||
|
onChange={(e) => update('legal.zipCity', e.target.value)}
|
||||||
|
placeholder="12345 Berlin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="legal-country">Country</label>
|
||||||
|
<input
|
||||||
|
id="legal-country"
|
||||||
|
value={settings.legal?.country || ''}
|
||||||
|
onChange={(e) => update('legal.country', e.target.value)}
|
||||||
|
placeholder="Germany"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field-row">
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="legal-email">Legal Email</label>
|
||||||
|
<input
|
||||||
|
id="legal-email"
|
||||||
|
value={settings.legal?.email || ''}
|
||||||
|
onChange={(e) => update('legal.email', e.target.value)}
|
||||||
|
placeholder="legal@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="legal-phone-number">Phone Number</label>
|
||||||
|
<input
|
||||||
|
id="legal-phone-number"
|
||||||
|
value={settings.legal?.phone || ''}
|
||||||
|
onChange={(e) => update('legal.phone', e.target.value)}
|
||||||
|
placeholder="+49 123 456789"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field-row">
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="legal-contact-url">Contact Form URL</label>
|
||||||
|
<input
|
||||||
|
id="legal-contact-url"
|
||||||
|
type="url"
|
||||||
|
value={settings.legal?.contactUrl || ''}
|
||||||
|
onChange={(e) => update('legal.contactUrl', e.target.value)}
|
||||||
|
placeholder="https://example.com/contact"
|
||||||
|
aria-invalid={contactUrlInvalid || undefined}
|
||||||
|
aria-describedby={contactUrlInvalid ? 'legal-contact-url-error' : undefined}
|
||||||
|
/>
|
||||||
|
{contactUrlInvalid && (
|
||||||
|
<p
|
||||||
|
id="legal-contact-url-error"
|
||||||
|
className="admin-field-hint admin-field-hint--error"
|
||||||
|
>
|
||||||
|
Must start with https:// or http://. Any other link is left off the page.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="legal-contact-link-text">Contact Link Text</label>
|
||||||
|
<input
|
||||||
|
id="legal-contact-link-text"
|
||||||
|
value={settings.legal?.contactLabel || ''}
|
||||||
|
onChange={(e) => update('legal.contactLabel', e.target.value)}
|
||||||
|
placeholder="Contact form"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field-row">
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="legal-vat-id">VAT ID</label>
|
||||||
|
<input
|
||||||
|
id="legal-vat-id"
|
||||||
|
value={settings.legal?.vatId || ''}
|
||||||
|
onChange={(e) => update('legal.vatId', e.target.value)}
|
||||||
|
placeholder="DE123456789"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="legal-tax-number">Tax Number</label>
|
||||||
|
<input
|
||||||
|
id="legal-tax-number"
|
||||||
|
value={settings.legal?.taxId || ''}
|
||||||
|
onChange={(e) => update('legal.taxId', e.target.value)}
|
||||||
|
placeholder="12/345/67890"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="legal-additional-disclosures">Additional Disclosures</label>
|
||||||
|
<textarea
|
||||||
|
id="legal-additional-disclosures"
|
||||||
|
value={settings.legal?.extraInfo || ''}
|
||||||
|
onChange={(e) => update('legal.extraInfo', e.target.value)}
|
||||||
|
placeholder="Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="settings-section-divider" />
|
||||||
|
|
||||||
|
<div className="admin-toggle-cards-grid" style={{ marginBottom: '1.25rem' }}>
|
||||||
|
<ToggleCard
|
||||||
|
icon={<Icons.IconFileText size={16} />}
|
||||||
|
title="Contact Form (/contact)"
|
||||||
|
description="Messages are stored on this server and read under Messages. No mail is sent."
|
||||||
|
checked={settings.contact?.enabled === true}
|
||||||
|
onToggle={() => update('contact.enabled', !settings.contact?.enabled)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{settings.contact?.enabled && (
|
||||||
|
<div className="admin-field-row">
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="contact-notify-url">Notification URL (ntfy)</label>
|
||||||
|
<input
|
||||||
|
id="contact-notify-url"
|
||||||
|
type="url"
|
||||||
|
value={settings.contact?.notifyUrl || ''}
|
||||||
|
onChange={(e) => update('contact.notifyUrl', e.target.value)}
|
||||||
|
placeholder="https://ntfy.sh/your-secret-topic"
|
||||||
|
aria-invalid={notifyUrlInvalid || undefined}
|
||||||
|
aria-describedby="contact-notify-url-hint"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
id="contact-notify-url-hint"
|
||||||
|
className={`admin-field-hint${notifyUrlInvalid ? ' admin-field-hint--error' : ''}`}
|
||||||
|
>
|
||||||
|
{notifyUrlInvalid
|
||||||
|
? 'Must start with https:// or http://.'
|
||||||
|
: 'Gets a fixed "new message" push, with nothing about the sender. Pick a topic name nobody can guess. CONTACT_NOTIFY_URL overrides this.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="contact-retention">Delete messages after (days)</label>
|
||||||
|
<input
|
||||||
|
id="contact-retention"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={CONTACT_RETENTION_MAX}
|
||||||
|
value={settings.contact?.retentionDays ?? CONTACT_RETENTION_DEFAULT}
|
||||||
|
onChange={(e) =>
|
||||||
|
update(
|
||||||
|
'contact.retentionDays',
|
||||||
|
parseInt(e.target.value) || CONTACT_RETENTION_DEFAULT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="settings-section-divider" />
|
||||||
|
|
||||||
|
<div className="admin-toggle-cards-grid" style={{ marginBottom: '1.25rem' }}>
|
||||||
|
<ToggleCard
|
||||||
|
icon={<Icons.IconShieldCheck size={16} />}
|
||||||
|
title="Privacy Policy (/privacy)"
|
||||||
|
description="Shown and linked in the footer once the text below is saved"
|
||||||
|
checked={settings.privacy?.enabled !== false}
|
||||||
|
onToggle={() => update('privacy.enabled', settings.privacy?.enabled === false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PrivacyEditor />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
app/admin/components/settings/SecuritySection.tsx
Normal file
139
app/admin/components/settings/SecuritySection.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as Icons from '../Icons';
|
||||||
|
import ToggleCard from '../fields/ToggleCard';
|
||||||
|
import PasswordField from '../fields/PasswordField';
|
||||||
|
import { resolveWatermarkOpacity } from '@/lib/config/schema';
|
||||||
|
import type { SectionProps } from './types';
|
||||||
|
|
||||||
|
export default function SecuritySection({ settings, update }: SectionProps) {
|
||||||
|
return (
|
||||||
|
<div className="settings-panel">
|
||||||
|
<div className="settings-section-header">
|
||||||
|
<h3>
|
||||||
|
<Icons.IconLock size={18} /> Site Password
|
||||||
|
</h3>
|
||||||
|
<p className="settings-section-sub">
|
||||||
|
Puts the whole public site behind one password — useful while a portfolio is still being
|
||||||
|
built, or for a folio only ever shown to clients.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="security-site-password">Site Password</label>
|
||||||
|
<PasswordField
|
||||||
|
id="security-site-password"
|
||||||
|
value={settings.sitePassword}
|
||||||
|
onChange={(password) => update('sitePassword', password ?? '')}
|
||||||
|
placeholder="Leave empty for a public site"
|
||||||
|
label="Site password"
|
||||||
|
/>
|
||||||
|
<p className="admin-field-hint">
|
||||||
|
Stored as a <code>scrypt:…</code> hash in <code>settings.yaml</code>, never as typed. The{' '}
|
||||||
|
<code>SITE_PASSWORD</code> environment variable overrides this field. The admin panel
|
||||||
|
keeps its own password and is never behind this gate.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-section-divider" />
|
||||||
|
|
||||||
|
<div className="settings-section-header">
|
||||||
|
<h3>
|
||||||
|
<Icons.IconShieldCheck size={18} /> Asset Protection & Watermark
|
||||||
|
</h3>
|
||||||
|
<p className="settings-section-sub">
|
||||||
|
Configure image protection rules, right-click prevention, and watermark overlay.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-toggle-cards-grid">
|
||||||
|
<ToggleCard
|
||||||
|
icon={<Icons.IconLock size={16} />}
|
||||||
|
title="Disable Right-Click Menu"
|
||||||
|
description="Prevents context menu on portfolio images to hinder unauthorized downloads"
|
||||||
|
checked={settings.protection?.disableRightClick === true}
|
||||||
|
onToggle={() =>
|
||||||
|
update('protection.disableRightClick', !settings.protection?.disableRightClick)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ToggleCard
|
||||||
|
icon={<Icons.IconBan size={16} />}
|
||||||
|
title="Disable Image Dragging"
|
||||||
|
description="Prevents visitors from dragging images off the portfolio page"
|
||||||
|
checked={settings.protection?.disableImageDrag === true}
|
||||||
|
onToggle={() =>
|
||||||
|
update('protection.disableImageDrag', !settings.protection?.disableImageDrag)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-section-divider" />
|
||||||
|
|
||||||
|
<div className="settings-section-header">
|
||||||
|
<h3>
|
||||||
|
<Icons.IconSparkles size={18} /> Dynamic Watermark Overlay
|
||||||
|
</h3>
|
||||||
|
<p className="settings-section-sub">Overlay copyright branding text on Lightbox images.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-toggle-cards-grid" style={{ marginBottom: '1.25rem' }}>
|
||||||
|
<ToggleCard
|
||||||
|
icon={<Icons.IconFrame size={16} />}
|
||||||
|
title="Enable Watermark"
|
||||||
|
description="Overlay copyright text on portfolio image views"
|
||||||
|
checked={settings.watermark?.enabled === true}
|
||||||
|
onToggle={() => update('watermark.enabled', !settings.watermark?.enabled)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{settings.watermark?.enabled && (
|
||||||
|
<>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="security-watermark-text">Watermark Text</label>
|
||||||
|
<input
|
||||||
|
id="security-watermark-text"
|
||||||
|
value={settings.watermark?.text || ''}
|
||||||
|
onChange={(e) => update('watermark.text', e.target.value)}
|
||||||
|
placeholder="© Ralfo Photography"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-field-row">
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="security-position">Position</label>
|
||||||
|
<select
|
||||||
|
id="security-position"
|
||||||
|
aria-label="Watermark position"
|
||||||
|
value={settings.watermark?.position || 'bottom-right'}
|
||||||
|
onChange={(e) => update('watermark.position', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="bottom-right">Bottom Right</option>
|
||||||
|
<option value="bottom-left">Bottom Left</option>
|
||||||
|
<option value="center">Center Overlay</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
{/* Read through the same normaliser the lightbox uses, so a
|
||||||
|
config written as a percentage shows as "90%" here rather
|
||||||
|
than "9000%" — and is written back as a fraction on save. */}
|
||||||
|
<label htmlFor="security-opacity-100">
|
||||||
|
Opacity ({Math.round(resolveWatermarkOpacity(settings.watermark?.opacity) * 100)}
|
||||||
|
%)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="security-opacity-100"
|
||||||
|
type="range"
|
||||||
|
min="0.1"
|
||||||
|
max="1"
|
||||||
|
step="0.05"
|
||||||
|
value={resolveWatermarkOpacity(settings.watermark?.opacity)}
|
||||||
|
onChange={(e) => update('watermark.opacity', parseFloat(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
app/admin/components/settings/SeoSection.tsx
Normal file
141
app/admin/components/settings/SeoSection.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as Icons from '../Icons';
|
||||||
|
import ToggleCard from '../fields/ToggleCard';
|
||||||
|
import type { SectionProps } from './types';
|
||||||
|
|
||||||
|
export default function SeoSection({
|
||||||
|
settings,
|
||||||
|
update,
|
||||||
|
siteUrlInfo,
|
||||||
|
}: SectionProps & {
|
||||||
|
/** Resolved site URL and its origin, so the panel can name SITE_URL (#472). */
|
||||||
|
siteUrlInfo: { effective: string | null; source: 'env' | 'settings' | 'none' } | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="settings-panel">
|
||||||
|
<div className="settings-section-header">
|
||||||
|
<h3>
|
||||||
|
<Icons.IconSearch size={18} /> Search Engine Optimization (SEO)
|
||||||
|
</h3>
|
||||||
|
<p className="settings-section-sub">
|
||||||
|
Customize search engine metadata, OpenGraph tags, and indexing rules.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live Google Search Result Snippet Card */}
|
||||||
|
<div className="google-snippet-preview">
|
||||||
|
<div className="google-snippet-header">
|
||||||
|
<span>
|
||||||
|
<Icons.IconGlobe size={14} /> Google Search Result Preview
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="google-snippet-card">
|
||||||
|
<div className="google-snippet-url">
|
||||||
|
https://yourportfolio.com <span className="google-snippet-arrow">▼</span>
|
||||||
|
</div>
|
||||||
|
<div className="google-snippet-title">
|
||||||
|
{settings.seo?.title || settings.title || 'My Photography Portfolio'}
|
||||||
|
</div>
|
||||||
|
<div className="google-snippet-desc">
|
||||||
|
{settings.seo?.description ||
|
||||||
|
settings.subtitle ||
|
||||||
|
'A curated selection of photography work.'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="seo-site-url">Site URL</label>
|
||||||
|
<input
|
||||||
|
id="seo-site-url"
|
||||||
|
value={settings.url || ''}
|
||||||
|
onChange={(e) => update('url', e.target.value)}
|
||||||
|
placeholder="https://folio.example"
|
||||||
|
/>
|
||||||
|
<p style={{ fontSize: '0.8rem', opacity: 0.7, marginTop: '4px' }}>
|
||||||
|
Needed for <code>sitemap.xml</code>, the feed and structured data — those are generated
|
||||||
|
without a request, so the address cannot be derived from it. Leave empty and the sitemap
|
||||||
|
stays empty rather than guessing.
|
||||||
|
{siteUrlInfo?.source === 'env' && (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
Currently supplied by the <code>SITE_URL</code> environment variable (
|
||||||
|
<code>{siteUrlInfo.effective}</code>). A value entered here takes precedence once
|
||||||
|
saved.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="seo-meta-title">SEO Meta Title</label>
|
||||||
|
<input
|
||||||
|
id="seo-meta-title"
|
||||||
|
value={settings.seo?.title || ''}
|
||||||
|
onChange={(e) => update('seo.title', e.target.value)}
|
||||||
|
placeholder="Overrides default site title for Google search results"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="seo-subpage-title-template">Subpage Title Template</label>
|
||||||
|
<input
|
||||||
|
id="seo-subpage-title-template"
|
||||||
|
value={settings.seo?.titleTemplate || ''}
|
||||||
|
onChange={(e) => update('seo.titleTemplate', e.target.value)}
|
||||||
|
placeholder={`%s | ${settings.seo?.title || settings.title || 'My Portfolio'}`}
|
||||||
|
/>
|
||||||
|
<p style={{ fontSize: '0.8rem', opacity: 0.7, marginTop: '4px' }}>
|
||||||
|
Template for subpages & albums. Use <code>%s</code> as placeholder for the page title.
|
||||||
|
<br />
|
||||||
|
<strong>Preview:</strong>{' '}
|
||||||
|
{(
|
||||||
|
settings.seo?.titleTemplate ||
|
||||||
|
`%s | ${settings.seo?.title || settings.title || 'My Portfolio'}`
|
||||||
|
).replace('%s', 'Landscapes')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="seo-meta-description">SEO Meta Description</label>
|
||||||
|
<textarea
|
||||||
|
id="seo-meta-description"
|
||||||
|
value={settings.seo?.description || ''}
|
||||||
|
onChange={(e) => update('seo.description', e.target.value)}
|
||||||
|
placeholder="A curated selection of photography work..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-section-divider" />
|
||||||
|
|
||||||
|
<div className="settings-section-header">
|
||||||
|
<h3>
|
||||||
|
<Icons.IconGlobe size={18} /> Search Crawler Directives
|
||||||
|
</h3>
|
||||||
|
<p className="settings-section-sub">
|
||||||
|
Control how Googlebot and other web crawlers index your site.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-toggle-cards-grid">
|
||||||
|
<ToggleCard
|
||||||
|
icon={<Icons.IconBan size={16} />}
|
||||||
|
title="noindex (Hide from Google)"
|
||||||
|
description="Instructs search engines NOT to index this site in search results"
|
||||||
|
checked={settings.seo?.noIndex === true}
|
||||||
|
onToggle={() => update('seo.noIndex', !settings.seo?.noIndex)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ToggleCard
|
||||||
|
icon={<Icons.IconLink size={16} />}
|
||||||
|
title="nofollow (Block Link Following)"
|
||||||
|
description="Instructs search engine crawlers not to follow outgoing links"
|
||||||
|
checked={settings.seo?.noFollow === true}
|
||||||
|
onToggle={() => update('seo.noFollow', !settings.seo?.noFollow)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
495
app/admin/components/settings/ThemeSection.tsx
Normal file
495
app/admin/components/settings/ThemeSection.tsx
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, type CSSProperties } from 'react';
|
||||||
|
import * as Icons from '../Icons';
|
||||||
|
import OptionGrid, { toOptions } from '../fields/OptionGrid';
|
||||||
|
import { DEFAULT_PRESET } from '@/lib/config/theme';
|
||||||
|
import { effectiveTheme, FeatureRow } from './fields';
|
||||||
|
import type { SectionProps } from './types';
|
||||||
|
|
||||||
|
const PRESETS = ['studio-modern', 'studio', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
|
||||||
|
const PHOTO_FRAMES = ['none', 'passepartout', 'shadow'];
|
||||||
|
const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic', 'cover'];
|
||||||
|
|
||||||
|
const PHOTO_FRAME_INFO: Record<string, { label: string; desc: string }> = {
|
||||||
|
none: { label: 'None', desc: 'Flush image with crisp edges' },
|
||||||
|
passepartout: {
|
||||||
|
label: 'Passepartout',
|
||||||
|
desc: 'Classic gallery matting border',
|
||||||
|
},
|
||||||
|
shadow: { label: 'Shadow', desc: 'Soft floating drop shadow' },
|
||||||
|
};
|
||||||
|
const PHOTO_FRAME_OPTIONS = toOptions(PHOTO_FRAMES, PHOTO_FRAME_INFO);
|
||||||
|
|
||||||
|
function PhotoFramePreview({ value }: { value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="frame-card-preview">
|
||||||
|
<div className={`mini-frame-demo frame-${value}`}>
|
||||||
|
<div className="mini-frame-photo" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const HERO_STYLE_INFO: Record<string, { label: string; desc: string }> = {
|
||||||
|
split: { label: 'Split', desc: 'Side-by-side title & photo' },
|
||||||
|
fullbleed: { label: 'Fullbleed', desc: 'Edge-to-edge full width banner' },
|
||||||
|
minimal: { label: 'Minimal', desc: 'Centered title with subtle photo' },
|
||||||
|
stacked: { label: 'Stacked', desc: 'Title stacked directly over photo' },
|
||||||
|
typographic: { label: 'Typographic', desc: 'Oversized magazine masthead' },
|
||||||
|
mosaic: { label: 'Mosaic', desc: 'Dynamic photo collage layout' },
|
||||||
|
cover: {
|
||||||
|
label: 'Cover (Experimental)',
|
||||||
|
desc: 'Fullscreen splash with a single Enter link',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const HERO_STYLE_OPTIONS = toOptions(HERO_STYLES, HERO_STYLE_INFO);
|
||||||
|
|
||||||
|
function HeroStylePreview({ value }: { value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="hero-card-preview">
|
||||||
|
<div className={`mini-hero-demo hero-demo-${value}`}>
|
||||||
|
{value === 'split' && (
|
||||||
|
<>
|
||||||
|
<div className="hero-demo-text">
|
||||||
|
<div className="demo-line title" />
|
||||||
|
<div className="demo-line sub" />
|
||||||
|
</div>
|
||||||
|
<div className="hero-demo-photo" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{value === 'fullbleed' && (
|
||||||
|
<div className="hero-demo-full">
|
||||||
|
<div className="demo-line title light" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{value === 'minimal' && (
|
||||||
|
<>
|
||||||
|
<div className="hero-demo-center-text">
|
||||||
|
<div className="demo-line title short" />
|
||||||
|
</div>
|
||||||
|
<div className="hero-demo-photo small" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{value === 'stacked' && (
|
||||||
|
<>
|
||||||
|
<div className="hero-demo-text">
|
||||||
|
<div className="demo-line title" />
|
||||||
|
</div>
|
||||||
|
<div className="hero-demo-photo banner" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{value === 'typographic' && (
|
||||||
|
<>
|
||||||
|
<div className="demo-line title giant" />
|
||||||
|
<div className="hero-demo-grid2">
|
||||||
|
<div className="hero-demo-photo" />
|
||||||
|
<div className="hero-demo-photo" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{value === 'mosaic' && (
|
||||||
|
<div className="hero-demo-mosaic">
|
||||||
|
<div className="hero-demo-photo big" />
|
||||||
|
<div className="hero-demo-photo-col">
|
||||||
|
<div className="hero-demo-photo" />
|
||||||
|
<div className="hero-demo-photo" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card metadata for the theme picker. `font`, `radius` and `frame` mirror the
|
||||||
|
* real preset definitions in lib/config/theme.ts so the mini mockups show what
|
||||||
|
* the preset actually does; `gap` is the visual density of its gallery grid.
|
||||||
|
*/
|
||||||
|
const THEME_INFO: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
desc: string;
|
||||||
|
label: string;
|
||||||
|
accent: string;
|
||||||
|
bg: string;
|
||||||
|
tile: string;
|
||||||
|
font: string;
|
||||||
|
type: 'serif' | 'sans' | 'mono';
|
||||||
|
radius: number;
|
||||||
|
frame: 'none' | 'passepartout' | 'shadow';
|
||||||
|
gap: number;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
studio: {
|
||||||
|
label: 'Studio',
|
||||||
|
desc: 'Clean, high-contrast grid with sans-serif type.',
|
||||||
|
bg: '#141414',
|
||||||
|
tile: '#242424',
|
||||||
|
accent: '#e60012',
|
||||||
|
font: 'Playfair Display',
|
||||||
|
type: 'serif',
|
||||||
|
radius: 0,
|
||||||
|
frame: 'passepartout',
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
'studio-modern': {
|
||||||
|
label: 'Studio Modern',
|
||||||
|
desc: 'Leica precision: Archivo grotesque, mono EXIF, red signal accents.',
|
||||||
|
bg: '#121212',
|
||||||
|
tile: '#191919',
|
||||||
|
accent: '#e60012',
|
||||||
|
font: 'Archivo',
|
||||||
|
type: 'sans',
|
||||||
|
radius: 0,
|
||||||
|
frame: 'none',
|
||||||
|
gap: 3,
|
||||||
|
},
|
||||||
|
minimal: {
|
||||||
|
label: 'Minimal',
|
||||||
|
desc: 'Pure raw layouts with tiny gaps and high whitespace.',
|
||||||
|
bg: '#ffffff',
|
||||||
|
tile: '#f3f3f3',
|
||||||
|
accent: '#111111',
|
||||||
|
font: 'Geist',
|
||||||
|
type: 'sans',
|
||||||
|
radius: 0,
|
||||||
|
frame: 'none',
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
editorial: {
|
||||||
|
label: 'Editorial',
|
||||||
|
desc: 'Warm backgrounds, elegant serifs and large headers.',
|
||||||
|
bg: '#fbf9f4',
|
||||||
|
tile: '#e5dfd4',
|
||||||
|
accent: '#b89053',
|
||||||
|
font: 'Bodoni Moda',
|
||||||
|
type: 'serif',
|
||||||
|
radius: 0,
|
||||||
|
frame: 'shadow',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
classic: {
|
||||||
|
label: 'Classic',
|
||||||
|
desc: 'Soft traditional photographer portfolio with shadows.',
|
||||||
|
bg: '#f7f7f7',
|
||||||
|
tile: '#ffffff',
|
||||||
|
accent: '#444444',
|
||||||
|
font: 'Cinzel',
|
||||||
|
type: 'serif',
|
||||||
|
radius: 12,
|
||||||
|
frame: 'passepartout',
|
||||||
|
gap: 7,
|
||||||
|
},
|
||||||
|
noir: {
|
||||||
|
label: 'Noir',
|
||||||
|
desc: 'High drama absolute pitch black, stark high-fashion look.',
|
||||||
|
bg: '#000000',
|
||||||
|
tile: '#151515',
|
||||||
|
accent: '#ffffff',
|
||||||
|
font: 'Libre Baskerville',
|
||||||
|
type: 'serif',
|
||||||
|
radius: 0,
|
||||||
|
frame: 'passepartout',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
monograph: {
|
||||||
|
label: 'Monograph',
|
||||||
|
desc: 'Typewriter monospace font, grid borders and document feel.',
|
||||||
|
bg: '#f4f4f6',
|
||||||
|
tile: '#ffffff',
|
||||||
|
accent: '#555555',
|
||||||
|
font: 'Instrument Serif',
|
||||||
|
type: 'mono',
|
||||||
|
radius: 0,
|
||||||
|
frame: 'none',
|
||||||
|
gap: 5,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/* The preset group sits below THEME_INFO on purpose. PRESET_OPTIONS is built
|
||||||
|
at module evaluation and reads it through themeInfo(); declared above, it
|
||||||
|
hit the temporal dead zone and the whole Settings page failed to load with
|
||||||
|
"Cannot access 'THEME_INFO' before initialization". tsc does not catch it
|
||||||
|
because the read goes through a hoisted function, and a production build
|
||||||
|
succeeds because the module is only evaluated in the browser (#542). */
|
||||||
|
/** The theme picker's own metadata, with the fallback the card grid needs. */
|
||||||
|
function themeInfo(value: string) {
|
||||||
|
return (
|
||||||
|
THEME_INFO[value] || {
|
||||||
|
label: value,
|
||||||
|
desc: '',
|
||||||
|
bg: '#fff',
|
||||||
|
tile: '#eee',
|
||||||
|
accent: '#333',
|
||||||
|
font: 'System',
|
||||||
|
type: 'sans' as const,
|
||||||
|
radius: 0,
|
||||||
|
frame: 'none' as const,
|
||||||
|
gap: 4,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRESET_OPTIONS = PRESETS.map((value) => ({
|
||||||
|
value,
|
||||||
|
label: themeInfo(value).label,
|
||||||
|
desc: themeInfo(value).desc,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function PresetPreview({ value }: { value: string }) {
|
||||||
|
const i = themeInfo(value);
|
||||||
|
return (
|
||||||
|
<div className="preset-card-preview" data-frame={i.frame}>
|
||||||
|
<div className="mini-header">
|
||||||
|
<span className={`mini-specimen type-${i.type}`}>Aa</span>
|
||||||
|
<span className="mini-line" />
|
||||||
|
</div>
|
||||||
|
<div className="mini-grid">
|
||||||
|
<div className="mini-tile" />
|
||||||
|
<div className="mini-tile" />
|
||||||
|
<div className="mini-tile" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PresetSpecs({ value }: { value: string }) {
|
||||||
|
const i = themeInfo(value);
|
||||||
|
return (
|
||||||
|
<span className="preset-card-specs">
|
||||||
|
{i.font} · radius {i.radius} · {i.frame}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ThemeSection({ settings, update }: SectionProps) {
|
||||||
|
// Read off the page on mount: the toggle previews light and dark on the
|
||||||
|
// admin itself, and the page may already be in either.
|
||||||
|
const [currentMode, setCurrentMode] = useState<'dark' | 'light'>(() =>
|
||||||
|
typeof document !== 'undefined' &&
|
||||||
|
document.documentElement.getAttribute('data-theme') === 'light'
|
||||||
|
? 'light'
|
||||||
|
: 'dark',
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleMode = (mode: 'dark' | 'light') => {
|
||||||
|
setCurrentMode(mode);
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.documentElement.setAttribute('data-theme', mode);
|
||||||
|
localStorage.setItem('theme', mode);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Same idea for the theme: an unset field shows the preset's own value, not
|
||||||
|
// a fallback of this form's — Classic used to read "split hero, no frame".
|
||||||
|
const theme = effectiveTheme(settings.theme);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-panel theme-settings-panel">
|
||||||
|
<div className="settings-section-header">
|
||||||
|
<h3>
|
||||||
|
<Icons.IconPalette size={18} /> Theme Presets & Color Mode
|
||||||
|
</h3>
|
||||||
|
<p className="settings-section-sub">
|
||||||
|
Choose a typography preset and preview in Light or Dark mode.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="theme-visitor-default-mode">Visitor Default Mode</label>
|
||||||
|
<select
|
||||||
|
id="theme-visitor-default-mode"
|
||||||
|
aria-label="Visitor default mode"
|
||||||
|
value={settings.mode || 'dark'}
|
||||||
|
onChange={(e) => update('mode', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="dark">Dark</option>
|
||||||
|
<option value="light">Light</option>
|
||||||
|
<option value="auto">Follow the visitor's system</option>
|
||||||
|
</select>
|
||||||
|
<p className="admin-field-hint">
|
||||||
|
What a first-time visitor sees. The light/dark toggle in the site header still lets them
|
||||||
|
choose, and their choice is remembered on their device. The preview below is your own, and
|
||||||
|
does not change this.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OptionGrid
|
||||||
|
label="Preset"
|
||||||
|
options={PRESET_OPTIONS}
|
||||||
|
value={settings.theme?.preset || DEFAULT_PRESET}
|
||||||
|
onSelect={(v) => update('theme.preset', v)}
|
||||||
|
cardClassName="theme-preset-card"
|
||||||
|
renderPreview={(o) => <PresetPreview value={o.value} />}
|
||||||
|
renderSpecs={(o) => <PresetSpecs value={o.value} />}
|
||||||
|
cardStyle={(o) => {
|
||||||
|
const i = themeInfo(o.value);
|
||||||
|
return {
|
||||||
|
'--preset-accent': i.accent,
|
||||||
|
'--preset-bg': i.bg,
|
||||||
|
'--preset-tile': i.tile,
|
||||||
|
'--preset-radius': `${i.radius}px`,
|
||||||
|
'--preset-gap': `${i.gap}px`,
|
||||||
|
} as CSSProperties;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="admin-field">
|
||||||
|
<span className="admin-field-label">Color Mode</span>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
color: 'var(--admin-text-muted)',
|
||||||
|
margin: '0.25rem 0 0.6rem',
|
||||||
|
lineHeight: '1.4',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Presets like <strong>Editorial</strong>, <strong>Minimal</strong> &{' '}
|
||||||
|
<strong>Classic</strong> feature warm light/cream backgrounds in Light Mode and charcoal
|
||||||
|
in Dark Mode.
|
||||||
|
</p>
|
||||||
|
<div className="segmented-control">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`segment-btn ${currentMode === 'light' ? 'active' : ''}`}
|
||||||
|
onClick={() => toggleMode('light')}
|
||||||
|
>
|
||||||
|
<Icons.IconSun size={14} /> Light Mode (Cream / Beige)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`segment-btn ${currentMode === 'dark' ? 'active' : ''}`}
|
||||||
|
onClick={() => toggleMode('dark')}
|
||||||
|
>
|
||||||
|
<Icons.IconMoon size={14} /> Dark Mode (Charcoal)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-section-divider" />
|
||||||
|
|
||||||
|
<div className="settings-section-header">
|
||||||
|
<h3>
|
||||||
|
<Icons.IconTarget size={18} /> Accent Color
|
||||||
|
</h3>
|
||||||
|
<p className="settings-section-sub">
|
||||||
|
Pick a primary accent color for links, buttons, and highlights.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-field">
|
||||||
|
<div className="accent-picker-wrapper">
|
||||||
|
<div className="color-swatches-row">
|
||||||
|
{[
|
||||||
|
{ hex: '#e60012', name: 'Studio Red' },
|
||||||
|
{ hex: '#b89053', name: 'Editorial Gold' },
|
||||||
|
{ hex: '#10b981', name: 'Emerald' },
|
||||||
|
{ hex: '#3b82f6', name: 'Sapphire' },
|
||||||
|
{ hex: '#8b5cf6', name: 'Violet' },
|
||||||
|
{ hex: '#ffffff', name: 'Monochrome White' },
|
||||||
|
{ hex: '#000000', name: 'Obsidian Black' },
|
||||||
|
].map((swatch) => {
|
||||||
|
const isSelected = theme.accent.toLowerCase() === swatch.hex.toLowerCase();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={swatch.hex}
|
||||||
|
type="button"
|
||||||
|
className={`color-swatch-btn ${isSelected ? 'active' : ''}`}
|
||||||
|
style={{ backgroundColor: swatch.hex }}
|
||||||
|
onClick={() => update('theme.accent', swatch.hex)}
|
||||||
|
title={swatch.name}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="color-field">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
aria-label="Accent colour"
|
||||||
|
value={theme.accent}
|
||||||
|
onChange={(e) => update('theme.accent', e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
aria-label="Accent colour (hex)"
|
||||||
|
type="text"
|
||||||
|
value={settings.theme?.accent || ''}
|
||||||
|
onChange={(e) => update('theme.accent', e.target.value)}
|
||||||
|
placeholder={theme.accent}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-section-divider" />
|
||||||
|
|
||||||
|
<div className="settings-section-header">
|
||||||
|
<h3>
|
||||||
|
<Icons.IconFrame size={18} /> Photo Frame & Layout
|
||||||
|
</h3>
|
||||||
|
<p className="settings-section-sub">
|
||||||
|
Customize image presentation borders and hero layouts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OptionGrid
|
||||||
|
label="Photo Frame"
|
||||||
|
options={PHOTO_FRAME_OPTIONS}
|
||||||
|
value={theme.photoFrame}
|
||||||
|
onSelect={(v) => update('theme.photoFrame', v)}
|
||||||
|
cardClassName="frame-card"
|
||||||
|
renderPreview={(o) => <PhotoFramePreview value={o.value} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OptionGrid
|
||||||
|
label="Hero Style"
|
||||||
|
options={HERO_STYLE_OPTIONS}
|
||||||
|
value={theme.heroStyle}
|
||||||
|
onSelect={(v) => update('theme.heroStyle', v)}
|
||||||
|
cardClassName="hero-card"
|
||||||
|
renderPreview={(o) => <HeroStylePreview value={o.value} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="settings-section-divider" />
|
||||||
|
|
||||||
|
<div className="settings-section-header">
|
||||||
|
<h3>
|
||||||
|
<Icons.IconSparkles size={18} /> Look & motion
|
||||||
|
</h3>
|
||||||
|
<p className="settings-section-sub">Effects and small controls on every page.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="feature-list">
|
||||||
|
<FeatureRow
|
||||||
|
icon={<Icons.IconSparkles size={15} />}
|
||||||
|
title="Page transitions"
|
||||||
|
description="A short fade between pages"
|
||||||
|
checked={settings.transitions !== false}
|
||||||
|
onToggle={() => update('transitions', settings.transitions === false)}
|
||||||
|
/>
|
||||||
|
<FeatureRow
|
||||||
|
icon={<Icons.IconArrowUp size={15} />}
|
||||||
|
title="Scroll-to-top button"
|
||||||
|
description="A floating arrow on long pages"
|
||||||
|
checked={settings.scrollToTop !== false}
|
||||||
|
onToggle={() => update('scrollToTop', settings.scrollToTop === false)}
|
||||||
|
/>
|
||||||
|
<FeatureRow
|
||||||
|
icon={<Icons.IconFilm size={15} />}
|
||||||
|
title="Film grain"
|
||||||
|
description="Analog noise over the background"
|
||||||
|
checked={theme.grain}
|
||||||
|
onToggle={() => update('theme.grain', !theme.grain)}
|
||||||
|
/>
|
||||||
|
<FeatureRow
|
||||||
|
icon={<Icons.IconTarget size={15} />}
|
||||||
|
title="Header accent dot"
|
||||||
|
description="A dot beside the active section heading"
|
||||||
|
checked={theme.headerDot}
|
||||||
|
onToggle={() => update('theme.headerDot', !theme.headerDot)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
app/admin/components/settings/fields.tsx
Normal file
154
app/admin/components/settings/fields.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import * as Icons from '../Icons';
|
||||||
|
// Direct import from the theme module, not from '@/lib/config': the config
|
||||||
|
// index pulls in `fs` and cannot be bundled into a client component.
|
||||||
|
import { resolveTheme } from '@/lib/config/theme';
|
||||||
|
import type { SettingsYaml } from '@/lib/config/schema';
|
||||||
|
import type { Settings } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One labelled block of related switches.
|
||||||
|
*
|
||||||
|
* The features panel was a single flat grid of identical cards, so nothing said
|
||||||
|
* which switches belonged together — and a switch that publishes private notes
|
||||||
|
* looked exactly like one that controls a fade (#510).
|
||||||
|
*/
|
||||||
|
export function FeatureGroup({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
chip,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
icon: ReactNode;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
/** Short marker for a group that needs its own weight, e.g. what visitors see. */
|
||||||
|
chip?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="settings-group">
|
||||||
|
<div className="settings-group-head">
|
||||||
|
<span className="settings-group-title">
|
||||||
|
{icon}
|
||||||
|
{title}
|
||||||
|
{chip && <span className="settings-group-chip">{chip}</span>}
|
||||||
|
</span>
|
||||||
|
<span className="settings-group-desc">{description}</span>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A switch inside a group panel. Deliberately lighter than a toggle card: these
|
||||||
|
* are settings *of* the panel they sit in, and rendering them as peers of the
|
||||||
|
* top-level cards is what hid the structure in the first place.
|
||||||
|
*/
|
||||||
|
export function SettingRow({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
checked,
|
||||||
|
onToggle,
|
||||||
|
disabled,
|
||||||
|
hint,
|
||||||
|
indented,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
checked: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
/** A row whose parent switch is off — shown, but inert and explained. */
|
||||||
|
disabled?: boolean;
|
||||||
|
hint?: string;
|
||||||
|
indented?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`setting-row${indented ? ' setting-row--indented' : ''}${
|
||||||
|
disabled ? ' setting-row--disabled' : ''
|
||||||
|
}`}
|
||||||
|
onClick={onToggle}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-pressed={checked}
|
||||||
|
>
|
||||||
|
<span className="setting-row-info">
|
||||||
|
<span className="setting-row-title">{title}</span>
|
||||||
|
<span className="setting-row-desc">{description}</span>
|
||||||
|
{hint && (
|
||||||
|
<span className="setting-row-hint">
|
||||||
|
<Icons.IconBan size={11} /> {hint}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className={`switch-toggle switch-toggle--sm ${checked && !disabled ? 'on' : ''}`}>
|
||||||
|
<span className="switch-slider" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One switch in a feature list: a single dense line instead of a card. Cards
|
||||||
|
* gave every switch the same weight, so a fade and a whole page looked alike;
|
||||||
|
* rows grouped by meaning let the group heading carry that weight instead.
|
||||||
|
* The optional link sits beside the switch, not inside it — a link nested in
|
||||||
|
* a button is invalid and swallows the click.
|
||||||
|
*/
|
||||||
|
export function FeatureRow({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
checked,
|
||||||
|
onToggle,
|
||||||
|
href,
|
||||||
|
}: {
|
||||||
|
icon: ReactNode;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
checked: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
/** Where this feature is configured beyond on/off. */
|
||||||
|
href?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="feature-row">
|
||||||
|
<button type="button" className="feature-row-main" onClick={onToggle} aria-pressed={checked}>
|
||||||
|
<span className="feature-row-icon">{icon}</span>
|
||||||
|
<span className="feature-row-info">
|
||||||
|
<span className="feature-row-title">{title}</span>
|
||||||
|
<span className="feature-row-desc">{description}</span>
|
||||||
|
</span>
|
||||||
|
<span className={`switch-toggle ${checked ? 'on' : ''}`}>
|
||||||
|
<span className="switch-slider" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/* Reserved even without a link, so every switch lines up in one column. */}
|
||||||
|
{href ? (
|
||||||
|
<Link href={href} className="feature-row-link">
|
||||||
|
Configure →
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="feature-row-link" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The theme as the site resolves it. A preset name the form does not know (a
|
||||||
|
* hand-edited typo) falls back to the default instead of taking the panel down.
|
||||||
|
*/
|
||||||
|
export function effectiveTheme(raw: Settings['theme']) {
|
||||||
|
try {
|
||||||
|
return resolveTheme(raw as SettingsYaml['theme']);
|
||||||
|
} catch {
|
||||||
|
return resolveTheme(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
95
app/admin/components/settings/types.ts
Normal file
95
app/admin/components/settings/types.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* Shared shape of the settings form (#554). The sections edit one object
|
||||||
|
* that mirrors settings.yaml; SettingsEditor owns it and hands each section
|
||||||
|
* the object plus the two ways to change it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
/** Absolute site URL for sitemap, feed and structured data (#472). */
|
||||||
|
url?: string;
|
||||||
|
lang?: string;
|
||||||
|
sitePassword?: string;
|
||||||
|
mode?: 'light' | 'dark' | 'auto';
|
||||||
|
exifOnHover?: boolean;
|
||||||
|
exif?: {
|
||||||
|
camera?: boolean;
|
||||||
|
settings?: boolean;
|
||||||
|
location?: boolean;
|
||||||
|
caption?: boolean;
|
||||||
|
};
|
||||||
|
map?: boolean;
|
||||||
|
transitions?: boolean;
|
||||||
|
scrollToTop?: boolean;
|
||||||
|
analytics?: boolean;
|
||||||
|
theme?: {
|
||||||
|
preset?: string;
|
||||||
|
accent?: string;
|
||||||
|
photoFrame?: string;
|
||||||
|
grain?: boolean;
|
||||||
|
headerDot?: boolean;
|
||||||
|
heroStyle?: string;
|
||||||
|
};
|
||||||
|
grid?: {
|
||||||
|
columns?: number;
|
||||||
|
gap?: number;
|
||||||
|
aspectRatio?: string;
|
||||||
|
layout?: string;
|
||||||
|
};
|
||||||
|
footer?: {
|
||||||
|
name?: string;
|
||||||
|
instagram?: string;
|
||||||
|
email?: string;
|
||||||
|
website?: string;
|
||||||
|
};
|
||||||
|
/** EXPERIMENTAL: external links appended to the header navigation */
|
||||||
|
navLinks?: Array<{ label?: string; url?: string }>;
|
||||||
|
contact?: { enabled?: boolean; notifyUrl?: string; retentionDays?: number };
|
||||||
|
privacy?: { enabled?: boolean };
|
||||||
|
legal?: {
|
||||||
|
enabled?: boolean;
|
||||||
|
heading?: string;
|
||||||
|
name?: string;
|
||||||
|
address?: string;
|
||||||
|
zipCity?: string;
|
||||||
|
country?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
contactUrl?: string;
|
||||||
|
contactLabel?: string;
|
||||||
|
taxId?: string;
|
||||||
|
vatId?: string;
|
||||||
|
extraInfo?: string;
|
||||||
|
};
|
||||||
|
seo?: {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
titleTemplate?: string;
|
||||||
|
noIndex?: boolean;
|
||||||
|
noFollow?: boolean;
|
||||||
|
};
|
||||||
|
protection?: {
|
||||||
|
disableRightClick?: boolean;
|
||||||
|
disableImageDrag?: boolean;
|
||||||
|
};
|
||||||
|
proofing?: {
|
||||||
|
enabled?: boolean;
|
||||||
|
allowMailto?: boolean;
|
||||||
|
};
|
||||||
|
watermark?: {
|
||||||
|
enabled?: boolean;
|
||||||
|
text?: string;
|
||||||
|
opacity?: number;
|
||||||
|
position?: 'bottom-right' | 'bottom-left' | 'center';
|
||||||
|
};
|
||||||
|
about?: { enabled?: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SectionProps {
|
||||||
|
settings: Settings;
|
||||||
|
/** Set one dotted path, e.g. `theme.accent`. An empty value removes the key. */
|
||||||
|
update: (path: string, value: unknown) => void;
|
||||||
|
/** Set several paths in one state update. */
|
||||||
|
updateMany: (entries: Record<string, unknown>) => void;
|
||||||
|
}
|
||||||
85
app/admin/components/useAdminFetch.ts
Normal file
85
app/admin/components/useAdminFetch.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loading, data and a typed error for an admin screen (#609).
|
||||||
|
*
|
||||||
|
* Every screen used to write this by hand: set loading, fetch, check res.ok,
|
||||||
|
* set data, catch, set error, clear loading. The copies disagreed on errors:
|
||||||
|
* `err: any` with `err.message`, a silent `catch {}`, a boolean flag. A silent
|
||||||
|
* catch is how a failed load turns into an empty form. Here a failure is
|
||||||
|
* always a message, and a 401 always goes through reportIfSessionExpired.
|
||||||
|
*
|
||||||
|
* Pair it with <AdminLoadState> for the spinner and the error-with-retry.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { reportIfSessionExpired } from './sessionExpiry';
|
||||||
|
|
||||||
|
export type AdminResult<T> = { data: T; error: null } | { data: null; error: string };
|
||||||
|
|
||||||
|
/** One GET, with the admin's error conventions. Usable outside the hook too. */
|
||||||
|
export async function adminGet<T>(url: string): Promise<AdminResult<T>> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) {
|
||||||
|
if (reportIfSessionExpired(res)) {
|
||||||
|
return { data: null, error: 'Your session expired. Sign in again.' };
|
||||||
|
}
|
||||||
|
const body = (await res.json().catch(() => null)) as { error?: unknown } | null;
|
||||||
|
const detail = typeof body?.error === 'string' ? `: ${body.error}` : '';
|
||||||
|
return { data: null, error: `Could not load (HTTP ${res.status})${detail}.` };
|
||||||
|
}
|
||||||
|
return { data: (await res.json()) as T, error: null };
|
||||||
|
} catch {
|
||||||
|
return { data: null, error: 'Could not reach the server.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminFetch<T> {
|
||||||
|
data: T | null;
|
||||||
|
/** Only the latest attempt's error; cleared while a reload is in flight. */
|
||||||
|
error: string | null;
|
||||||
|
/** True until the current URL has answered, including after reload(). */
|
||||||
|
loading: boolean;
|
||||||
|
reload: () => void;
|
||||||
|
/** Local edit after a successful mutation, without a round trip. */
|
||||||
|
mutate: (update: (data: T) => T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `url === null` fetches nothing (a closed modal, say). Data from the previous
|
||||||
|
* answer stays visible while a reload runs.
|
||||||
|
*/
|
||||||
|
export function useAdminFetch<T>(url: string | null): AdminFetch<T> {
|
||||||
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
const key = url === null ? null : `${attempt}:${url}`;
|
||||||
|
const [result, setResult] = useState<(AdminResult<T> & { key: string }) | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (key === null || url === null) return;
|
||||||
|
let live = true;
|
||||||
|
// State is set in the callback, never synchronously in the effect.
|
||||||
|
adminGet<T>(url).then((r) => {
|
||||||
|
if (live) setResult({ ...r, key });
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [key, url]);
|
||||||
|
|
||||||
|
const loading = key !== null && result?.key !== key;
|
||||||
|
const reload = useCallback(() => setAttempt((n) => n + 1), []);
|
||||||
|
const mutate = useCallback((update: (data: T) => T) => {
|
||||||
|
setResult((r) =>
|
||||||
|
r && r.error === null ? { key: r.key, data: update(r.data), error: null } : r,
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: result?.data ?? null,
|
||||||
|
error: loading ? null : (result?.error ?? null),
|
||||||
|
loading,
|
||||||
|
reload,
|
||||||
|
mutate,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -46,7 +46,35 @@ export function readDraft<T>(key: string): Draft<T> | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a draft holds a password as typed. Passwords are stored as scrypt
|
||||||
|
* hashes (#706), but between typing and saving the editor holds the plaintext,
|
||||||
|
* and sessionStorage is readable by any script on the admin origin. Such a
|
||||||
|
* draft is not kept: the unsaved-changes guard still warns before leaving, and
|
||||||
|
* the password is typed again after a reload.
|
||||||
|
*/
|
||||||
|
export function holdsPlaintextPassword(value: unknown): boolean {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
// Journal markdown: a `password:` line in the frontmatter.
|
||||||
|
const fm = /^---\n([\s\S]*?)\n---/.exec(value)?.[1] ?? '';
|
||||||
|
return /^password:[ \t]*(?!['"]?scrypt:)['"]?\S/m.test(fm);
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) return value.some(holdsPlaintextPassword);
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return Object.entries(value).some(([k, v]) =>
|
||||||
|
/^(?:password|sitePassword)$/.test(k) && typeof v === 'string'
|
||||||
|
? v !== '' && !v.startsWith('scrypt:')
|
||||||
|
: holdsPlaintextPassword(v),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export function writeDraft<T>(key: string, draft: Draft<T>): void {
|
export function writeDraft<T>(key: string, draft: Draft<T>): void {
|
||||||
|
if (holdsPlaintextPassword(draft.value)) {
|
||||||
|
clearDraft(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
window.sessionStorage.setItem(PREFIX + key, JSON.stringify(draft));
|
window.sessionStorage.setItem(PREFIX + key, JSON.stringify(draft));
|
||||||
} catch {
|
} catch {
|
||||||
@@ -73,13 +101,19 @@ export type DraftStatus = 'none' | 'restored' | 'conflict';
|
|||||||
export function useDraft<T>(key: string, value: T, dirty: boolean) {
|
export function useDraft<T>(key: string, value: T, dirty: boolean) {
|
||||||
/** The server fingerprint the current edits started from; null until loaded. */
|
/** The server fingerprint the current edits started from; null until loaded. */
|
||||||
const baseRef = useRef<string | null>(null);
|
const baseRef = useRef<string | null>(null);
|
||||||
|
/**
|
||||||
|
* True while a `conflict` waits for an answer. The editor shows the server
|
||||||
|
* state meanwhile — not dirty — and without this hold the effect below would
|
||||||
|
* clear the very draft "Restore my changes" is about to read.
|
||||||
|
*/
|
||||||
|
const holdRef = useRef(false);
|
||||||
const [status, setStatus] = useState<DraftStatus>('none');
|
const [status, setStatus] = useState<DraftStatus>('none');
|
||||||
|
|
||||||
// Mirror the edits while there are any, and drop the draft once there are
|
// Mirror the edits while there are any, and drop the draft once there are
|
||||||
// none — a save or a discard. Nothing is written before `load()` has set a
|
// none — a save or a discard. Nothing is written before `load()` has set a
|
||||||
// base, so the empty state an editor mounts with never lands in storage.
|
// base, so the empty state an editor mounts with never lands in storage.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (baseRef.current === null) return;
|
if (baseRef.current === null || holdRef.current) return;
|
||||||
if (dirty) writeDraft(key, { base: baseRef.current, value });
|
if (dirty) writeDraft(key, { base: baseRef.current, value });
|
||||||
else clearDraft(key);
|
else clearDraft(key);
|
||||||
}, [key, value, dirty]);
|
}, [key, value, dirty]);
|
||||||
@@ -91,6 +125,7 @@ export function useDraft<T>(key: string, value: T, dirty: boolean) {
|
|||||||
const load = useCallback(
|
const load = useCallback(
|
||||||
(base: string): T | null => {
|
(base: string): T | null => {
|
||||||
baseRef.current = base;
|
baseRef.current = base;
|
||||||
|
holdRef.current = false;
|
||||||
const draft = readDraft<T>(key);
|
const draft = readDraft<T>(key);
|
||||||
if (!draft) {
|
if (!draft) {
|
||||||
setStatus('none');
|
setStatus('none');
|
||||||
@@ -100,6 +135,7 @@ export function useDraft<T>(key: string, value: T, dirty: boolean) {
|
|||||||
setStatus('restored');
|
setStatus('restored');
|
||||||
return draft.value;
|
return draft.value;
|
||||||
}
|
}
|
||||||
|
holdRef.current = true;
|
||||||
setStatus('conflict');
|
setStatus('conflict');
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
@@ -110,6 +146,7 @@ export function useDraft<T>(key: string, value: T, dirty: boolean) {
|
|||||||
const saved = useCallback(
|
const saved = useCallback(
|
||||||
(base: string) => {
|
(base: string) => {
|
||||||
baseRef.current = base;
|
baseRef.current = base;
|
||||||
|
holdRef.current = false;
|
||||||
clearDraft(key);
|
clearDraft(key);
|
||||||
setStatus('none');
|
setStatus('none');
|
||||||
},
|
},
|
||||||
@@ -118,6 +155,7 @@ export function useDraft<T>(key: string, value: T, dirty: boolean) {
|
|||||||
|
|
||||||
/** The held-back draft of a `conflict`, for "restore anyway". */
|
/** The held-back draft of a `conflict`, for "restore anyway". */
|
||||||
const takeConflicting = useCallback((): T | null => {
|
const takeConflicting = useCallback((): T | null => {
|
||||||
|
holdRef.current = false;
|
||||||
const draft = readDraft<T>(key);
|
const draft = readDraft<T>(key);
|
||||||
setStatus(draft ? 'restored' : 'none');
|
setStatus(draft ? 'restored' : 'none');
|
||||||
return draft?.value ?? null;
|
return draft?.value ?? null;
|
||||||
@@ -125,6 +163,7 @@ export function useDraft<T>(key: string, value: T, dirty: boolean) {
|
|||||||
|
|
||||||
/** Forget the draft. The editor reloads the server state itself. */
|
/** Forget the draft. The editor reloads the server state itself. */
|
||||||
const discard = useCallback(() => {
|
const discard = useCallback(() => {
|
||||||
|
holdRef.current = false;
|
||||||
clearDraft(key);
|
clearDraft(key);
|
||||||
setStatus('none');
|
setStatus('none');
|
||||||
}, [key]);
|
}, [key]);
|
||||||
|
|||||||
5
app/admin/messages/page.tsx
Normal file
5
app/admin/messages/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import MessagesView from '../components/MessagesView';
|
||||||
|
|
||||||
|
export default function AdminMessagesPage() {
|
||||||
|
return <MessagesView />;
|
||||||
|
}
|
||||||
@@ -1,15 +1,11 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import AdminOverview from './components/AdminOverview';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `/admin` and `/admin/pages` both rendered the page builder, so the same
|
* `/admin` is the overview: what needs attention, and the site at a glance
|
||||||
* screen had two addresses: two routes to keep in step, two things to find
|
* (docs/admin-ux-concept.md). It used to redirect to the page builder, which
|
||||||
* when something about the builder changes, and no way to tell which one a
|
* left unread messages and doctor warnings to be found by visiting the right
|
||||||
* bookmark or a bug report meant.
|
* tab. The page builder keeps its own address at `/admin/pages`.
|
||||||
*
|
|
||||||
* `/admin/pages` wins because that is what the Pages tab links to. `/admin`
|
|
||||||
* stays as the entry point people type and bookmark — and it is what the
|
|
||||||
* public diagnostic banner links to — so it redirects rather than disappearing.
|
|
||||||
*/
|
*/
|
||||||
export default function AdminHomePage() {
|
export default function AdminHomePage() {
|
||||||
redirect('/admin/pages');
|
return <AdminOverview />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import PageBuilder from '../components/PageBuilder';
|
import PageBuilder from '../components/PageBuilder';
|
||||||
|
import PageHeader from '../components/PageHeader';
|
||||||
|
|
||||||
export default function AdminPagesPage() {
|
export default function AdminPagesPage() {
|
||||||
return <PageBuilder />;
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
kicker="Content"
|
||||||
|
title="Pages"
|
||||||
|
description="The home page hero, standalone albums and every subpage. Drag to reorder; changes go live when saved."
|
||||||
|
/>
|
||||||
|
<PageBuilder />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
138
app/admin/styles/messages.css
Normal file
138
app/admin/styles/messages.css
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
/* The contact form inbox (#702). */
|
||||||
|
|
||||||
|
.messages-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-count {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--admin-accent);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A hint, not an error: --admin-warning, never the accent (a preset may make
|
||||||
|
the accent red). */
|
||||||
|
.messages-notice {
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
|
border: 1px solid var(--admin-warning);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--admin-text);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-notice a {
|
||||||
|
color: var(--admin-warning);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-empty {
|
||||||
|
color: var(--admin-text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--admin-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--admin-bg-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-item + .messages-item {
|
||||||
|
border-top: 1px solid var(--admin-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 10px minmax(8rem, 12rem) 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--admin-text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-summary:hover {
|
||||||
|
background: var(--admin-bg-hover, rgba(128, 128, 128, 0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-item--unread .messages-dot {
|
||||||
|
background: var(--admin-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-item--unread .messages-from {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-from,
|
||||||
|
.messages-preview {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-preview,
|
||||||
|
.messages-date {
|
||||||
|
color: var(--admin-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-date {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-body {
|
||||||
|
padding: 0 1rem 1rem calc(1rem + 10px + 0.75rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-meta {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--admin-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-text {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.55;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.messages-summary {
|
||||||
|
grid-template-columns: 10px 1fr auto;
|
||||||
|
}
|
||||||
|
.messages-preview {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.messages-body {
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
100
app/admin/styles/notifications.css
Normal file
100
app/admin/styles/notifications.css
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/* The admin notification stack (#600), bottom right, above the save bar. */
|
||||||
|
|
||||||
|
.admin-notifications {
|
||||||
|
position: fixed;
|
||||||
|
right: 1.25rem;
|
||||||
|
bottom: 5.5rem;
|
||||||
|
z-index: 1100;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
max-width: min(420px, calc(100vw - 2.5rem));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-notifications__group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-notification {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.7rem 0.8rem 0.7rem 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--admin-border);
|
||||||
|
border-left-width: 4px;
|
||||||
|
background: var(--admin-bg-raised);
|
||||||
|
color: var(--admin-text);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-notification--success {
|
||||||
|
border-left-color: var(--admin-success, #10b981);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-notification--error {
|
||||||
|
border-left-color: var(--admin-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-notification__text {
|
||||||
|
flex: 1;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-notification__close {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 2px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--admin-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-notification__close:hover {
|
||||||
|
color: var(--admin-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.admin-notifications {
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* AdminLoadState (#609): the shared failed-load panel. */
|
||||||
|
.admin-load-error {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid var(--admin-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--admin-bg-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-load-error p {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
color: var(--admin-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-notification__action {
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
border: 1px solid var(--admin-border-hover);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: none;
|
||||||
|
color: var(--admin-text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-notification__action:hover {
|
||||||
|
background: var(--admin-bg-elevated);
|
||||||
|
}
|
||||||
1249
app/admin/styles/redesign.css
Normal file
1249
app/admin/styles/redesign.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -635,3 +635,71 @@
|
|||||||
.status-val.unknown {
|
.status-val.unknown {
|
||||||
color: var(--admin-text-secondary);
|
color: var(--admin-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-field-hint--error {
|
||||||
|
color: var(--admin-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A link out of a section header, e.g. to the page the section configures.
|
||||||
|
It shows the saved state, not unsaved edits. */
|
||||||
|
.settings-section-action {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Privacy policy editor (#699) ──────────────────────────────── */
|
||||||
|
|
||||||
|
.privacy-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.privacy-editor__facts {
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
border: 1px solid var(--admin-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--admin-bg-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.privacy-editor__facts h4 {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.privacy-editor__facts ul {
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.privacy-editor__facts li {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.privacy-editor__facts li span:last-child {
|
||||||
|
flex-basis: 100%;
|
||||||
|
color: var(--admin-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.privacy-editor__badge {
|
||||||
|
padding: 0 0.4rem;
|
||||||
|
border: 1px solid var(--admin-warning);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--admin-warning);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.privacy-editor__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -323,3 +323,53 @@
|
|||||||
.password-input-wrapper input {
|
.password-input-wrapper input {
|
||||||
padding-left: 2.25rem !important;
|
padding-left: 2.25rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── PasswordField (#690) ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
.password-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-field--set {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-field__status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-right: auto;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--admin-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-field .password-input-wrapper input {
|
||||||
|
padding-right: 2.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-field__reveal {
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--admin-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-field__reveal:hover {
|
||||||
|
color: var(--admin-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-field__cancel {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ const config = {
|
|||||||
subpages: [],
|
subpages: [],
|
||||||
albumPasswords: {},
|
albumPasswords: {},
|
||||||
theme: { accent: '#e60012', fonts: { heading: 'Inter', body: 'Inter', caption: 'Inter' } },
|
theme: { accent: '#e60012', fonts: { heading: 'Inter', body: 'Inter', caption: 'Inter' } },
|
||||||
|
contact: { enabled: true, retentionDays: 90 },
|
||||||
|
siteUrl: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock('@/lib/config', () => ({
|
vi.mock('@/lib/config', () => ({
|
||||||
@@ -89,6 +91,14 @@ const GATED: { name: string; call: () => Promise<Response> }[] = [
|
|||||||
{ params: Promise.resolve({ album: 'tok' }) } as never,
|
{ params: Promise.resolve({ album: 'tok' }) } as never,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Stores whatever a visitor sends; a locked site accepts nothing from strangers.
|
||||||
|
name: 'POST /api/contact',
|
||||||
|
call: async () =>
|
||||||
|
(await import('../contact/route')).POST(
|
||||||
|
new NextRequest('http://localhost/api/contact', { method: 'POST', body: '{}' }) as never,
|
||||||
|
),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
describe('public content routes behind a locked site', () => {
|
describe('public content routes behind a locked site', () => {
|
||||||
@@ -142,4 +152,24 @@ describe('routes that must stay open', () => {
|
|||||||
);
|
);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The password gate is set in the theme's fonts; locking them would render
|
||||||
|
// the gate itself in fallback fonts. Three public font names give nothing away.
|
||||||
|
it('GET /api/fonts/css answers a locked site', async () => {
|
||||||
|
config.sitePassword = 'letmein';
|
||||||
|
const res = await (
|
||||||
|
await import('../fonts/css/route')
|
||||||
|
).GET(request('/api/fonts/css?family=Inter%3Ax') as never);
|
||||||
|
expect(res.status).not.toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/fonts/file/[name] answers a locked site', async () => {
|
||||||
|
config.sitePassword = 'letmein';
|
||||||
|
const res = await (
|
||||||
|
await import('../fonts/file/[name]/route')
|
||||||
|
).GET(request('/api/fonts/file/x.woff2') as never, {
|
||||||
|
params: Promise.resolve({ name: 'x.woff2' }),
|
||||||
|
});
|
||||||
|
expect(res.status).not.toBe(401);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const ROUTES: {
|
|||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
load: () => Promise<Record<string, unknown>>;
|
load: () => Promise<Record<string, unknown>>;
|
||||||
method: 'GET' | 'PUT' | 'POST' | 'DELETE';
|
method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'PATCH';
|
||||||
args: () => unknown[];
|
args: () => unknown[];
|
||||||
}[] = [
|
}[] = [
|
||||||
{
|
{
|
||||||
@@ -124,6 +124,44 @@ const ROUTES: {
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
args: () => [],
|
args: () => [],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Names and email addresses of everyone who used the contact form.
|
||||||
|
name: 'GET /api/admin/messages',
|
||||||
|
path: 'messages',
|
||||||
|
load: () => import('../messages/route'),
|
||||||
|
method: 'GET',
|
||||||
|
args: () => [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'PATCH /api/admin/messages',
|
||||||
|
path: 'messages',
|
||||||
|
load: () => import('../messages/route'),
|
||||||
|
method: 'PATCH',
|
||||||
|
args: () => [
|
||||||
|
new NextRequest('http://localhost/api/admin/messages', { method: 'PATCH', body: '{}' }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'DELETE /api/admin/messages',
|
||||||
|
path: 'messages',
|
||||||
|
load: () => import('../messages/route'),
|
||||||
|
method: 'DELETE',
|
||||||
|
args: () => [new NextRequest('http://localhost/api/admin/messages?id=x', { method: 'DELETE' })],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'GET /api/admin/privacy',
|
||||||
|
path: 'privacy',
|
||||||
|
load: () => import('../privacy/route'),
|
||||||
|
method: 'GET',
|
||||||
|
args: () => [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'PUT /api/admin/privacy',
|
||||||
|
path: 'privacy',
|
||||||
|
load: () => import('../privacy/route'),
|
||||||
|
method: 'PUT',
|
||||||
|
args: () => [new Request('http://localhost/api/admin/privacy', { method: 'PUT', body: '{}' })],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'GET /api/admin/backups',
|
name: 'GET /api/admin/backups',
|
||||||
path: 'backups',
|
path: 'backups',
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ const baseConfig = {
|
|||||||
sitePassword: '',
|
sitePassword: '',
|
||||||
immich: { apiUrl: '', apiKey: '' },
|
immich: { apiUrl: '', apiKey: '' },
|
||||||
immichTimeoutMs: 1000,
|
immichTimeoutMs: 1000,
|
||||||
|
legal: {
|
||||||
|
enabled: false,
|
||||||
|
name: '',
|
||||||
|
address: '',
|
||||||
|
zipCity: '',
|
||||||
|
country: '',
|
||||||
|
} as Record<string, unknown>,
|
||||||
|
contact: { enabled: false, retentionDays: 90 } as Record<string, unknown>,
|
||||||
|
privacy: { enabled: true },
|
||||||
};
|
};
|
||||||
const getConfigMock = vi.fn(() => baseConfig);
|
const getConfigMock = vi.fn(() => baseConfig);
|
||||||
|
|
||||||
@@ -40,6 +49,8 @@ vi.mock('@/lib/config', () => ({
|
|||||||
|
|
||||||
vi.mock('@/lib/env', () => ({ env: { AUTH_SECRET: 'x'.repeat(64) } }));
|
vi.mock('@/lib/env', () => ({ env: { AUTH_SECRET: 'x'.repeat(64) } }));
|
||||||
vi.mock('@/lib/admin/journal-service', () => ({ listJournalEntries: async () => [] }));
|
vi.mock('@/lib/admin/journal-service', () => ({ listJournalEntries: async () => [] }));
|
||||||
|
const readSettingsYamlMock = vi.fn(async (): Promise<unknown> => null);
|
||||||
|
vi.mock('@/lib/admin/yaml-service', () => ({ readSettingsYaml: () => readSettingsYamlMock() }));
|
||||||
|
|
||||||
import { GET } from '../doctor/route';
|
import { GET } from '../doctor/route';
|
||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
@@ -215,3 +226,36 @@ describe('GET /api/admin/doctor — album slug collisions', () => {
|
|||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* resolveLegal() drops a non-http(s) contact URL and only tells the server
|
||||||
|
* log. The doctor reads the raw settings.yaml so the admin sees why the link
|
||||||
|
* is gone.
|
||||||
|
*/
|
||||||
|
describe('GET /api/admin/doctor — Impressum', () => {
|
||||||
|
const complete = {
|
||||||
|
enabled: true,
|
||||||
|
name: 'Ralf',
|
||||||
|
address: 'Street 1',
|
||||||
|
zipCity: '10247 Berlin',
|
||||||
|
country: 'Germany',
|
||||||
|
email: 'mail@example.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('stays out of the report while the page is off', async () => {
|
||||||
|
const body = await (await GET(req())).json();
|
||||||
|
expect(body.findings.find((f: { id: string }) => f.id === 'legal')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names a contact URL that the page dropped', async () => {
|
||||||
|
readSettingsYamlMock.mockResolvedValueOnce({
|
||||||
|
legal: { ...complete, contactUrl: 'javascript:alert(1)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await (await GET(req())).json();
|
||||||
|
const finding = body.findings.find((f: { id: string }) => f.id === 'legal');
|
||||||
|
|
||||||
|
expect(finding?.level).toBe('warn');
|
||||||
|
expect(finding?.detail).toContain('contact form URL is ignored');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -75,3 +75,23 @@ describe('PUT /api/admin/settings', () => {
|
|||||||
expect(writeSettingsYaml).toHaveBeenCalledOnce();
|
expect(writeSettingsYaml).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** The panel sends the site password as typed; the file must not keep it that way (#690). */
|
||||||
|
describe('PUT /api/admin/settings — site password', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it('writes the site password as an scrypt hash', async () => {
|
||||||
|
const res = await put({ title: 'Folio', sitePassword: 'letmein' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const written = vi.mocked(writeSettingsYaml).mock.calls[0][0] as { sitePassword: string };
|
||||||
|
expect(written.sitePassword).toMatch(/^scrypt:/);
|
||||||
|
expect(JSON.stringify(written)).not.toContain('letmein');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an open site open', async () => {
|
||||||
|
await put({ title: 'Folio', sitePassword: '' });
|
||||||
|
const written = vi.mocked(writeSettingsYaml).mock.calls[0][0] as { sitePassword: string };
|
||||||
|
expect(written.sitePassword).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { listJournalBackups, restoreJournalBackup } from '@/lib/admin/journal-se
|
|||||||
import { invalidateConfigCache } from '@/lib/config';
|
import { invalidateConfigCache } from '@/lib/config';
|
||||||
import { immich } from '@/lib/immich';
|
import { immich } from '@/lib/immich';
|
||||||
|
|
||||||
export type BackupTarget = 'gallery' | 'settings' | 'about' | 'journal';
|
export type BackupTarget = 'gallery' | 'settings' | 'about' | 'privacy' | 'journal';
|
||||||
|
|
||||||
export interface BackupItem {
|
export interface BackupItem {
|
||||||
filename: string;
|
filename: string;
|
||||||
@@ -51,6 +51,7 @@ export const GET = withAdmin(async () => {
|
|||||||
parseBackupInfo(f, 'settings'),
|
parseBackupInfo(f, 'settings'),
|
||||||
);
|
);
|
||||||
const about = (await listBackups('about.md')).map((f) => parseBackupInfo(f, 'about'));
|
const about = (await listBackups('about.md')).map((f) => parseBackupInfo(f, 'about'));
|
||||||
|
const privacy = (await listBackups('privacy.md')).map((f) => parseBackupInfo(f, 'privacy'));
|
||||||
// Newest first across all entries, so a just-deleted entry sits on top.
|
// Newest first across all entries, so a just-deleted entry sits on top.
|
||||||
const journal = (await listJournalBackups())
|
const journal = (await listJournalBackups())
|
||||||
.map((b) => ({
|
.map((b) => ({
|
||||||
@@ -65,6 +66,7 @@ export const GET = withAdmin(async () => {
|
|||||||
gallery,
|
gallery,
|
||||||
settings,
|
settings,
|
||||||
about,
|
about,
|
||||||
|
privacy,
|
||||||
journal,
|
journal,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,12 +5,18 @@ import path from 'node:path';
|
|||||||
import { getConfig, slugify } from '@/lib/config';
|
import { getConfig, slugify } from '@/lib/config';
|
||||||
import { env } from '@/lib/env';
|
import { env } from '@/lib/env';
|
||||||
import { listJournalEntries } from '@/lib/admin/journal-service';
|
import { listJournalEntries } from '@/lib/admin/journal-service';
|
||||||
|
import { readSettingsYaml } from '@/lib/admin/yaml-service';
|
||||||
|
import { readPrivacy } from '@/lib/privacy';
|
||||||
import {
|
import {
|
||||||
checkAlbumIds,
|
checkAlbumIds,
|
||||||
checkAlbumSlugCollisions,
|
checkAlbumSlugCollisions,
|
||||||
checkAlbumsShared,
|
checkAlbumsShared,
|
||||||
checkAuthSecret,
|
checkAuthSecret,
|
||||||
|
checkCdn,
|
||||||
checkImmichCalls,
|
checkImmichCalls,
|
||||||
|
checkContact,
|
||||||
|
checkLegal,
|
||||||
|
checkPrivacy,
|
||||||
checkPasswords,
|
checkPasswords,
|
||||||
checkProxyHops,
|
checkProxyHops,
|
||||||
checkWritable,
|
checkWritable,
|
||||||
@@ -20,6 +26,8 @@ import {
|
|||||||
type AlbumRef,
|
type AlbumRef,
|
||||||
type AlbumSlugGroup,
|
type AlbumSlugGroup,
|
||||||
type DoctorFinding,
|
type DoctorFinding,
|
||||||
|
type ContactRef,
|
||||||
|
type LegalRef,
|
||||||
type PasswordRef,
|
type PasswordRef,
|
||||||
} from '@/lib/admin/doctor';
|
} from '@/lib/admin/doctor';
|
||||||
|
|
||||||
@@ -44,6 +52,9 @@ export const GET = withAdmin(async (request: NextRequest) => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const cdn = checkCdn(env.CDN_URL, !!config.sitePassword, config.trustedProxyHops);
|
||||||
|
if (cdn) findings.push(cdn);
|
||||||
|
|
||||||
// ── Immich: the three calls Folio actually depends on ────────────────
|
// ── Immich: the three calls Folio actually depends on ────────────────
|
||||||
const calls: Array<{ endpoint: string; ok: boolean }> = [];
|
const calls: Array<{ endpoint: string; ok: boolean }> = [];
|
||||||
let albums: AlbumRef[] = [];
|
let albums: AlbumRef[] = [];
|
||||||
@@ -135,6 +146,27 @@ export const GET = withAdmin(async (request: NextRequest) => {
|
|||||||
}
|
}
|
||||||
findings.push(checkPasswords(passwords));
|
findings.push(checkPasswords(passwords));
|
||||||
|
|
||||||
|
// ── Impressum and contact form: judged on the raw blocks, see checkLegal ──
|
||||||
|
let rawLegal: LegalRef = config.legal;
|
||||||
|
let rawContact: ContactRef = config.contact;
|
||||||
|
try {
|
||||||
|
const settings = await readSettingsYaml();
|
||||||
|
rawLegal = settings?.legal ?? rawLegal;
|
||||||
|
rawContact = settings?.contact ?? rawContact;
|
||||||
|
} catch {
|
||||||
|
// Fall back to the resolved blocks; only a dropped URL goes unseen.
|
||||||
|
}
|
||||||
|
const legal = checkLegal(rawLegal, config.contact.enabled);
|
||||||
|
if (legal) findings.push(legal);
|
||||||
|
const contact = checkContact(rawContact, env.CONTACT_NOTIFY_URL);
|
||||||
|
if (contact) findings.push(contact);
|
||||||
|
const privacy = checkPrivacy({
|
||||||
|
legalEnabled: config.legal.enabled,
|
||||||
|
privacyEnabled: config.privacy.enabled,
|
||||||
|
hasText: readPrivacy() !== '',
|
||||||
|
});
|
||||||
|
if (privacy) findings.push(privacy);
|
||||||
|
|
||||||
// ── Writability of the content volume ────────────────────────────────
|
// ── Writability of the content volume ────────────────────────────────
|
||||||
const contentDir = path.join(process.cwd(), 'content');
|
const contentDir = path.join(process.cwd(), 'content');
|
||||||
const unwritable: string[] = [];
|
const unwritable: string[] = [];
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import { readGalleryYaml, writeGalleryYaml } from '@/lib/admin/yaml-service';
|
|||||||
import { invalidateConfigCache, deriveGallery } from '@/lib/config';
|
import { invalidateConfigCache, deriveGallery } from '@/lib/config';
|
||||||
import { immich } from '@/lib/immich';
|
import { immich } from '@/lib/immich';
|
||||||
import type { GalleryYaml } from '@/lib/config/schema';
|
import type { GalleryYaml } from '@/lib/config/schema';
|
||||||
|
import { hashPasswordKeys } from '@/lib/admin/passwordHashing';
|
||||||
|
|
||||||
|
const PASSWORD_KEY = new Set(['password']);
|
||||||
|
|
||||||
/** GET: Read current gallery.yaml config. */
|
/** GET: Read current gallery.yaml config. */
|
||||||
export const GET = withAdmin(async () => {
|
export const GET = withAdmin(async () => {
|
||||||
@@ -44,7 +47,13 @@ export const PUT = withAdmin(async (request: Request) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await writeGalleryYaml(gallery);
|
// Subpage and album passwords, wherever the entry sits (#690).
|
||||||
|
const toWrite = await hashPasswordKeys(
|
||||||
|
gallery,
|
||||||
|
PASSWORD_KEY,
|
||||||
|
await readGalleryYaml().catch(() => null),
|
||||||
|
);
|
||||||
|
await writeGalleryYaml(toWrite);
|
||||||
invalidateConfigCache();
|
invalidateConfigCache();
|
||||||
immich.invalidateAll();
|
immich.invalidateAll();
|
||||||
// Revalidate all pages so the homepage picks up new hero images immediately
|
// Revalidate all pages so the homepage picks up new hero images immediately
|
||||||
@@ -53,6 +62,8 @@ export const PUT = withAdmin(async (request: Request) => {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Saved successfully. Backup of previous version created.',
|
message: 'Saved successfully. Backup of previous version created.',
|
||||||
|
// What was written, passwords hashed, so the editor can take it over.
|
||||||
|
gallery: toWrite,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Admin] Failed to write gallery.yaml:', err);
|
console.error('[Admin] Failed to write gallery.yaml:', err);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { withAdmin } from '@/lib/admin/withAdmin';
|
import { withAdmin } from '@/lib/admin/withAdmin';
|
||||||
import { isValidSlug, sanitizeSlug, parseJournalMarkdown } from '@/lib/journal';
|
import { isValidSlug, sanitizeSlug, parseJournalMarkdown } from '@/lib/journal';
|
||||||
|
import { hashFrontmatterPassword } from '@/lib/admin/passwordHashing';
|
||||||
import {
|
import {
|
||||||
readJournalEntry,
|
readJournalEntry,
|
||||||
writeJournalEntry,
|
writeJournalEntry,
|
||||||
@@ -59,10 +60,18 @@ export const PUT = withAdmin(async (request: Request, context: RouteContext) =>
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
return NextResponse.json({ error: 'Target slug already exists' }, { status: 409 });
|
return NextResponse.json({ error: 'Target slug already exists' }, { status: 409 });
|
||||||
}
|
}
|
||||||
await writeJournalEntry(targetSlug, rawMarkdown);
|
}
|
||||||
|
|
||||||
|
// The entry's password is stored hashed (#690). The file as it is now
|
||||||
|
// supplies the hash to keep when the password did not change.
|
||||||
|
const current = await readJournalEntry(slug);
|
||||||
|
const markdown = await hashFrontmatterPassword(rawMarkdown, current?.rawMarkdown ?? null);
|
||||||
|
|
||||||
|
if (targetSlug !== slug) {
|
||||||
|
await writeJournalEntry(targetSlug, markdown);
|
||||||
await deleteJournalEntry(slug);
|
await deleteJournalEntry(slug);
|
||||||
} else {
|
} else {
|
||||||
await writeJournalEntry(slug, rawMarkdown);
|
await writeJournalEntry(slug, markdown);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await readJournalEntry(targetSlug);
|
const updated = await readJournalEntry(targetSlug);
|
||||||
|
|||||||
44
app/api/admin/messages/route.ts
Normal file
44
app/api/admin/messages/route.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { withAdmin } from '@/lib/admin/withAdmin';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { deleteMessage, isMessageId, listMessages, setRead } from '@/lib/contact';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The contact form inbox (#702). Listing also applies the retention period,
|
||||||
|
* so opening the inbox is enough to purge what has expired.
|
||||||
|
*/
|
||||||
|
export const GET = withAdmin(async () => {
|
||||||
|
const { contact } = getConfig();
|
||||||
|
const messages = await listMessages(contact.retentionDays);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
enabled: contact.enabled,
|
||||||
|
notifyConfigured: !!contact.notifyUrl,
|
||||||
|
retentionDays: contact.retentionDays,
|
||||||
|
messages,
|
||||||
|
},
|
||||||
|
{ headers: { 'Cache-Control': 'no-store' } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** PATCH { id, read } — mark a message read or unread. */
|
||||||
|
export const PATCH = withAdmin(async (request: NextRequest) => {
|
||||||
|
const body = (await request.json().catch(() => null)) as { id?: unknown; read?: unknown } | null;
|
||||||
|
if (!body || !isMessageId(body.id) || typeof body.read !== 'boolean') {
|
||||||
|
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
|
||||||
|
}
|
||||||
|
return (await setRead(body.id, body.read))
|
||||||
|
? NextResponse.json({ ok: true })
|
||||||
|
: NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** DELETE ?id=<id> */
|
||||||
|
export const DELETE = withAdmin(async (request: NextRequest) => {
|
||||||
|
const id = request.nextUrl.searchParams.get('id');
|
||||||
|
if (!isMessageId(id)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
|
||||||
|
}
|
||||||
|
return (await deleteMessage(id))
|
||||||
|
? NextResponse.json({ ok: true })
|
||||||
|
: NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
});
|
||||||
78
app/api/admin/privacy/route.ts
Normal file
78
app/api/admin/privacy/route.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { withAdmin } from '@/lib/admin/withAdmin';
|
||||||
|
import { atomicWrite } from '@/lib/atomicWrite';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { env } from '@/lib/env';
|
||||||
|
import { PRIVACY_FILENAME, processingFacts, readPrivacy, starterHeadings } from '@/lib/privacy';
|
||||||
|
|
||||||
|
const CONTENT_DIR = path.resolve(process.cwd(), 'content');
|
||||||
|
const MAX_BACKUPS = 10;
|
||||||
|
/** Far beyond any real policy; keeps a runaway paste out of the content volume. */
|
||||||
|
const MAX_LENGTH = 200_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The privacy policy (#699). GET also returns what this installation
|
||||||
|
* processes (lib/privacy.ts), which the editor shows next to the text.
|
||||||
|
*/
|
||||||
|
export const GET = withAdmin(async () => {
|
||||||
|
const config = getConfig();
|
||||||
|
const facts = processingFacts(config, {
|
||||||
|
CDN_URL: env.CDN_URL,
|
||||||
|
hasPasswords:
|
||||||
|
!!config.sitePassword ||
|
||||||
|
config.subpages.some((sp) => !!sp.password) ||
|
||||||
|
Object.values(config.albumPasswords).some(Boolean),
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
body: readPrivacy(CONTENT_DIR),
|
||||||
|
enabled: config.privacy.enabled,
|
||||||
|
facts,
|
||||||
|
starter: starterHeadings(config.lang, facts),
|
||||||
|
},
|
||||||
|
{ headers: { 'Cache-Control': 'no-store' } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** PUT { body } — writes content/privacy.md, keeping a backup like about.md. */
|
||||||
|
export const PUT = withAdmin(async (request: Request) => {
|
||||||
|
const data = (await request.json().catch(() => null)) as { body?: unknown } | null;
|
||||||
|
if (!data || typeof data.body !== 'string' || data.body.length > MAX_LENGTH) {
|
||||||
|
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(CONTENT_DIR, PRIVACY_FILENAME);
|
||||||
|
await fs.mkdir(CONTENT_DIR, { recursive: true });
|
||||||
|
|
||||||
|
// Same rule as the about route (#630): only a missing file means there is
|
||||||
|
// nothing to back up. Any other failure aborts before the live file changes.
|
||||||
|
let fileExists = true;
|
||||||
|
try {
|
||||||
|
await fs.access(filePath);
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||||
|
fileExists = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileExists) {
|
||||||
|
const backupDir = path.join(CONTENT_DIR, '.backups');
|
||||||
|
await fs.mkdir(backupDir, { recursive: true });
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
await fs.copyFile(filePath, path.join(backupDir, `${PRIVACY_FILENAME}.${timestamp}.bak`));
|
||||||
|
const backups = (await fs.readdir(backupDir))
|
||||||
|
.filter((e) => e.startsWith(PRIVACY_FILENAME) && e.endsWith('.bak'))
|
||||||
|
.sort();
|
||||||
|
while (backups.length > MAX_BACKUPS) {
|
||||||
|
await fs.unlink(path.join(backupDir, backups.shift()!));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = data.body.trim();
|
||||||
|
await atomicWrite(filePath, body ? `${body}\n` : '');
|
||||||
|
revalidatePath('/', 'layout');
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'Privacy policy saved.' });
|
||||||
|
});
|
||||||
@@ -5,6 +5,9 @@ import { readSettingsYaml, writeSettingsYaml } from '@/lib/admin/yaml-service';
|
|||||||
import { invalidateConfigCache, getConfigOrNull } from '@/lib/config';
|
import { invalidateConfigCache, getConfigOrNull } from '@/lib/config';
|
||||||
import { immich } from '@/lib/immich';
|
import { immich } from '@/lib/immich';
|
||||||
import { validateSettings } from '@/lib/config/settingsSchema';
|
import { validateSettings } from '@/lib/config/settingsSchema';
|
||||||
|
import { hashPasswordKeys } from '@/lib/admin/passwordHashing';
|
||||||
|
|
||||||
|
const SITE_PASSWORD_KEY = new Set(['sitePassword']);
|
||||||
import type { SettingsYaml } from '@/lib/config/schema';
|
import type { SettingsYaml } from '@/lib/config/schema';
|
||||||
|
|
||||||
/** GET: Read current settings.yaml config. */
|
/** GET: Read current settings.yaml config. */
|
||||||
@@ -42,9 +45,13 @@ export const PUT = withAdmin(async (request: Request) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = body.settings as SettingsYaml;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Only the top-level site password: settings.yaml has no other password.
|
||||||
|
const settings = await hashPasswordKeys(
|
||||||
|
body.settings as SettingsYaml,
|
||||||
|
SITE_PASSWORD_KEY,
|
||||||
|
await readSettingsYaml().catch(() => null),
|
||||||
|
);
|
||||||
await writeSettingsYaml(settings);
|
await writeSettingsYaml(settings);
|
||||||
invalidateConfigCache();
|
invalidateConfigCache();
|
||||||
immich.invalidateAll();
|
immich.invalidateAll();
|
||||||
@@ -52,6 +59,8 @@ export const PUT = withAdmin(async (request: Request) => {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Saved successfully. Backup of previous version created.',
|
message: 'Saved successfully. Backup of previous version created.',
|
||||||
|
// The stored site password, hashed, so the field can read "Protected".
|
||||||
|
sitePassword: settings.sitePassword,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Admin] Failed to write settings.yaml:', err);
|
console.error('[Admin] Failed to write settings.yaml:', err);
|
||||||
|
|||||||
71
app/api/contact/route.ts
Normal file
71
app/api/contact/route.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Contact form submission (#702). POST /api/contact
|
||||||
|
* { name, email, message, website (honeypot), startedAt }
|
||||||
|
*
|
||||||
|
* The message is stored under content/messages/ and the owner is notified
|
||||||
|
* through contact.notifyUrl when one is set (lib/contact.ts).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { siteLockResponse } from '@/lib/auth';
|
||||||
|
import { notifyNewMessage, saveMessage, validateContact } from '@/lib/contact';
|
||||||
|
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
|
||||||
|
|
||||||
|
/** A person sends one message, maybe a correction. Three a minute is generous. */
|
||||||
|
const CONTACT_RPM = 3;
|
||||||
|
|
||||||
|
/** Bodies larger than any valid message plus its fields are refused unread. */
|
||||||
|
const MAX_BODY_BYTES = 16 * 1024;
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const config = getConfig();
|
||||||
|
if (!config.contact.enabled) {
|
||||||
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The page sits behind the site password; so does the endpoint it posts to.
|
||||||
|
const locked = siteLockResponse(request);
|
||||||
|
if (locked) return locked;
|
||||||
|
|
||||||
|
const ip = getClientIp(request);
|
||||||
|
const { success, resetAt } = checkRateLimit(`contact:${ip}`, CONTACT_RPM);
|
||||||
|
if (!success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Too many requests' },
|
||||||
|
{ status: 429, headers: { 'Retry-After': String(retryAfterSeconds(resetAt)) } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number(request.headers.get('content-length') ?? 0) > MAX_BODY_BYTES) {
|
||||||
|
return NextResponse.json({ error: 'Too large' }, { status: 413 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = validateContact((body ?? {}) as Record<string, unknown>);
|
||||||
|
if (!result.ok) {
|
||||||
|
// A bot gets the same answer as a person, so it has nothing to tune against.
|
||||||
|
if (result.reason === 'spam') return NextResponse.json({ ok: true });
|
||||||
|
return NextResponse.json({ error: 'Invalid message' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = await saveMessage(
|
||||||
|
{ name: result.name, email: result.email, message: result.message },
|
||||||
|
config.contact.retentionDays,
|
||||||
|
);
|
||||||
|
if (saved === 'full') {
|
||||||
|
console.warn('[Folio] contact: inbox is full, message refused');
|
||||||
|
return NextResponse.json({ error: 'Inbox full' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const inboxUrl = config.siteUrl ? `${config.siteUrl.replace(/\/$/, '')}/admin/messages` : null;
|
||||||
|
await notifyNewMessage(config.contact.notifyUrl, inboxUrl);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
50
app/api/fonts/css/route.ts
Normal file
50
app/api/fonts/css/route.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { getGoogleCss, parseFamilies, rewriteCss } from '@/lib/fonts';
|
||||||
|
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The theme's font stylesheet, fetched from Google by the server and served
|
||||||
|
* from this origin (lib/fonts.ts).
|
||||||
|
*
|
||||||
|
* Not behind the site password: the password gate itself is set in the
|
||||||
|
* theme's fonts, and a stylesheet naming three public fonts gives nothing away.
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const ip = getClientIp(request);
|
||||||
|
const { success, resetAt } = checkRateLimit(`fonts:${ip}`, getConfig().rateLimitRpm);
|
||||||
|
if (!success) {
|
||||||
|
return new NextResponse(null, {
|
||||||
|
status: 429,
|
||||||
|
headers: { 'Retry-After': String(retryAfterSeconds(resetAt)), 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const families = parseFamilies(request.nextUrl.searchParams.getAll('family'));
|
||||||
|
if (!families) {
|
||||||
|
return new NextResponse('/* invalid font families */', {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'text/css; charset=utf-8', 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const css = await getGoogleCss(families);
|
||||||
|
if (css === null) {
|
||||||
|
// The theme CSS names a system fallback after every family, so an empty
|
||||||
|
// stylesheet degrades to those. A short max-age lets the page pick the
|
||||||
|
// fonts up once Google answers again.
|
||||||
|
return new NextResponse('/* fonts unavailable, using system fallbacks */', {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/css; charset=utf-8',
|
||||||
|
'Cache-Control': 'public, max-age=300',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse(rewriteCss(css), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/css; charset=utf-8',
|
||||||
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
31
app/api/fonts/file/[name]/route.ts
Normal file
31
app/api/fonts/file/[name]/route.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { getFontFile } from '@/lib/fonts';
|
||||||
|
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One font file named by a stylesheet from /api/fonts/css (lib/fonts.ts).
|
||||||
|
* The name is a hash of the gstatic URL, so a file never changes under it.
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ name: string }> }) {
|
||||||
|
const ip = getClientIp(request);
|
||||||
|
const { success, resetAt } = checkRateLimit(`fonts:${ip}`, getConfig().rateLimitRpm);
|
||||||
|
if (!success) {
|
||||||
|
return new NextResponse(null, {
|
||||||
|
status: 429,
|
||||||
|
headers: { 'Retry-After': String(retryAfterSeconds(resetAt)), 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await getFontFile((await params).name);
|
||||||
|
if (!data) {
|
||||||
|
return new NextResponse(null, { status: 404, headers: { 'Cache-Control': 'no-store' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse(data as BodyInit, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'font/woff2',
|
||||||
|
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
112
app/contact/contact.css
Normal file
112
app/contact/contact.css
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
.contact-page {
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
min-height: 80vh;
|
||||||
|
}
|
||||||
|
.contact-page__header {
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.contact-page__title {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: clamp(2.5rem, 8vw, 4rem);
|
||||||
|
font-weight: 400;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.contact-page__subtitle {
|
||||||
|
font-family: var(--font-caption);
|
||||||
|
font-size: 1rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.contact-page__content {
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
.contact-form__field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.contact-form__label {
|
||||||
|
font-family: var(--font-caption);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.contact-form__input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
.contact-form__input:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
textarea.contact-form__input {
|
||||||
|
min-height: 10rem;
|
||||||
|
resize: vertical;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
/* The honeypot. Off-screen rather than display:none, which some bots skip. */
|
||||||
|
.contact-form__trap {
|
||||||
|
position: absolute;
|
||||||
|
left: -10000px;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.contact-form__privacy {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.contact-form__submit {
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 12px 28px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--on-accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
.contact-form__submit:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
.contact-form__error {
|
||||||
|
color: #e74c3c;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.contact-form__done {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.contact-form__done h2 {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.contact-form__done p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
43
app/contact/page.tsx
Normal file
43
app/contact/page.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Contact page (#702). The form posts to /api/contact, which stores the
|
||||||
|
* message on this server; see lib/contact.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Metadata } from 'next';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { BackLink } from '@/components/BackLink';
|
||||||
|
import ContactForm from '@/components/ContactForm';
|
||||||
|
import { getServerDictionary } from '@/lib/i18n/server';
|
||||||
|
import './contact.css';
|
||||||
|
|
||||||
|
export function generateMetadata(): Metadata {
|
||||||
|
// No title for a page that is switched off: it renders the 404 page.
|
||||||
|
if (!getConfig().contact.enabled) return {};
|
||||||
|
return { title: getServerDictionary().contact.title };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default function ContactPage() {
|
||||||
|
const { contact } = getConfig();
|
||||||
|
const t = getServerDictionary();
|
||||||
|
|
||||||
|
if (!contact.enabled) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="contact-page">
|
||||||
|
<header className="contact-page__header">
|
||||||
|
<BackLink href="/" label={t.common.home} />
|
||||||
|
<h1 className="contact-page__title">{t.contact.title}</h1>
|
||||||
|
<p className="contact-page__subtitle">{t.contact.subtitle}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="contact-page__content">
|
||||||
|
<ContactForm retentionDays={contact.retentionDays} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1645,7 +1645,7 @@
|
|||||||
.empty-state__button {
|
.empty-state__button {
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: var(--bg-primary);
|
color: var(--on-accent);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
.subpage-container {
|
.legal-page {
|
||||||
padding: 4rem 2rem;
|
padding: 4rem 2rem;
|
||||||
min-height: 80vh;
|
min-height: 80vh;
|
||||||
}
|
}
|
||||||
.subpage-header {
|
.legal-header {
|
||||||
margin-bottom: 4rem;
|
margin-bottom: 4rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.subpage-title {
|
.legal-title {
|
||||||
font-family: var(--font-serif);
|
font-family: var(--font-serif);
|
||||||
font-size: clamp(2.5rem, 8vw, 4rem);
|
font-size: clamp(2.5rem, 8vw, 4rem);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
.subpage-subtitle {
|
.legal-subtitle {
|
||||||
font-family: var(--font-caption);
|
font-family: var(--font-caption);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
an opacity on top of them is not. */
|
an opacity on top of them is not. */
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
.subpage-content {
|
.legal-content {
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
@@ -39,8 +39,14 @@
|
|||||||
.legal-section__text--pre {
|
.legal-section__text--pre {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
.legal-source {
|
/* tokens.css strips link underlines site-wide; here the links sit inside
|
||||||
margin-top: 4rem;
|
running text and would otherwise be indistinguishable from it. */
|
||||||
font-size: 0.85rem;
|
.legal-section__text a {
|
||||||
color: var(--text-muted);
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
text-decoration-color: var(--text-muted);
|
||||||
|
transition: text-decoration-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
.legal-section__text a:hover {
|
||||||
|
text-decoration-color: currentColor;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,14 @@ import { Metadata } from 'next';
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
import { BackLink } from '@/components/BackLink';
|
import { BackLink } from '@/components/BackLink';
|
||||||
|
import EmailLink from '@/components/EmailLink';
|
||||||
|
import { encodeEmail, spelledOutEmail } from '@/lib/emailObfuscation';
|
||||||
import { getServerDictionary } from '@/lib/i18n/server';
|
import { getServerDictionary } from '@/lib/i18n/server';
|
||||||
import './impressum.css';
|
import './impressum.css';
|
||||||
|
|
||||||
export function generateMetadata(): Metadata {
|
export function generateMetadata(): Metadata {
|
||||||
|
// No title for a page that is switched off: it renders the 404 page.
|
||||||
|
if (!getConfig().legal.enabled) return {};
|
||||||
return {
|
return {
|
||||||
title: getServerDictionary().legal.title,
|
title: getServerDictionary().legal.title,
|
||||||
robots: { index: false, follow: true }, // Usually no need to index legal pages
|
robots: { index: false, follow: true }, // Usually no need to index legal pages
|
||||||
@@ -19,23 +23,31 @@ export function generateMetadata(): Metadata {
|
|||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/** `tel:` takes digits and a leading +; the displayed number keeps its spacing. */
|
||||||
|
function telHref(phone: string): string {
|
||||||
|
return `tel:${phone.replace(/[^\d+]/g, '')}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ImpressumPage() {
|
export default function ImpressumPage() {
|
||||||
const { legal } = getConfig();
|
const { legal, contact } = getConfig();
|
||||||
const t = getServerDictionary();
|
const t = getServerDictionary();
|
||||||
|
// The built-in form stands in as the second contact channel unless an
|
||||||
|
// external one is configured.
|
||||||
|
const contactUrl = legal.contactUrl ?? (contact.enabled ? '/contact' : undefined);
|
||||||
|
|
||||||
if (!legal.enabled) {
|
if (!legal.enabled) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="subpage-container">
|
<div className="legal-page">
|
||||||
<header className="subpage-header">
|
<header className="legal-header">
|
||||||
<BackLink href="/" label={t.common.home} />
|
<BackLink href="/" label={t.common.backToGallery} />
|
||||||
<h1 className="subpage-title">{t.legal.title}</h1>
|
<h1 className="legal-title">{t.legal.title}</h1>
|
||||||
<p className="subpage-subtitle">{t.legal.subtitle}</p>
|
<p className="legal-subtitle">{legal.heading ?? t.legal.subtitle}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="subpage-content">
|
<div className="legal-content">
|
||||||
<section className="legal-section">
|
<section className="legal-section">
|
||||||
<h2 className="legal-section__title">{t.legal.address}</h2>
|
<h2 className="legal-section__title">{t.legal.address}</h2>
|
||||||
<p className="legal-section__text">
|
<p className="legal-section__text">
|
||||||
@@ -49,21 +61,29 @@ export default function ImpressumPage() {
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{(legal.email || legal.phone) && (
|
{(legal.email || legal.phone || contactUrl) && (
|
||||||
<section className="legal-section">
|
<section className="legal-section">
|
||||||
<h2 className="legal-section__title">{t.legal.contact}</h2>
|
<h2 className="legal-section__title">{t.legal.contact}</h2>
|
||||||
<p className="legal-section__text">
|
<p className="legal-section__text">
|
||||||
{legal.email && (
|
{legal.email && (
|
||||||
<>
|
<>
|
||||||
{t.legal.email}: {legal.email}
|
{t.legal.email}: <EmailLink encoded={encodeEmail(legal.email)} />
|
||||||
|
{/* § 5 DDG wants the address reachable without JavaScript too. */}
|
||||||
|
<noscript>{spelledOutEmail(legal.email)}</noscript>
|
||||||
<br />
|
<br />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{legal.phone && (
|
{legal.phone && (
|
||||||
<>
|
<>
|
||||||
{t.legal.phone}: {legal.phone}
|
{t.legal.phone}: <a href={telHref(legal.phone)}>{legal.phone}</a>
|
||||||
|
<br />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{contactUrl && (
|
||||||
|
<a href={contactUrl} rel="noopener">
|
||||||
|
{legal.contactLabel ?? t.legal.contactForm}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
@@ -93,11 +113,7 @@ export default function ImpressumPage() {
|
|||||||
<p className="legal-section__text legal-section__text--pre">{legal.extraInfo}</p>
|
<p className="legal-section__text legal-section__text--pre">{legal.extraInfo}</p>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
<section className="legal-source">
|
|
||||||
<p>{t.legal.source}</p>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { getConfig } from '@/lib/config';
|
|||||||
import { immich } from '@/lib/immich';
|
import { immich } from '@/lib/immich';
|
||||||
import {
|
import {
|
||||||
imageUrl,
|
imageUrl,
|
||||||
|
videoUrl,
|
||||||
exifUrl,
|
exifUrl,
|
||||||
assetPlaceholder,
|
assetPlaceholder,
|
||||||
assetAspectRatio,
|
assetAspectRatio,
|
||||||
@@ -291,6 +292,7 @@ export default async function JournalDetailPage({ params }: JournalDetailPagePro
|
|||||||
type: isVideo ? 'video' : 'image',
|
type: isVideo ? 'video' : 'image',
|
||||||
thumbUrl: imageUrl(a.id, 'preview'),
|
thumbUrl: imageUrl(a.id, 'preview'),
|
||||||
previewUrl: imageUrl(a.id, 'preview'),
|
previewUrl: imageUrl(a.id, 'preview'),
|
||||||
|
...(isVideo ? { videoUrl: videoUrl(a.id) } : {}),
|
||||||
exifUrl: exifUrl(a.id),
|
exifUrl: exifUrl(a.id),
|
||||||
...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}),
|
...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}),
|
||||||
...(exif ?? {}),
|
...(exif ?? {}),
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import { ThemeToggle } from '@/components/ThemeToggle';
|
|||||||
import { ScrollToTop } from '@/components/ScrollToTop';
|
import { ScrollToTop } from '@/components/ScrollToTop';
|
||||||
import { Footer } from '@/components/Footer';
|
import { Footer } from '@/components/Footer';
|
||||||
import { SetupScreen } from '@/components/SetupScreen';
|
import { SetupScreen } from '@/components/SetupScreen';
|
||||||
import { getConfigOrNull, getGoogleFontsUrl, AppConfig } from '@/lib/config';
|
import { getConfigOrNull, getThemeFontsUrl, AppConfig } from '@/lib/config';
|
||||||
|
import { accentForMode, onAccent } from '@/lib/config/theme';
|
||||||
import { isAdminPath } from '@/lib/admin/paths';
|
import { isAdminPath } from '@/lib/admin/paths';
|
||||||
import { isInstallPath } from '@/lib/install';
|
import { isInstallPath } from '@/lib/install';
|
||||||
import { isSiteLocked, isSiteUnlocked } from '@/lib/auth';
|
import { isSiteLocked, isSiteUnlocked } from '@/lib/auth';
|
||||||
@@ -95,11 +96,15 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { theme } = config;
|
const { theme } = config;
|
||||||
const fontsUrl = getGoogleFontsUrl(theme);
|
const fontsUrl = getThemeFontsUrl(theme);
|
||||||
|
|
||||||
const themeVars: Record<string, string> = {
|
const themeVars: Record<string, string> = {
|
||||||
'--accent': theme.accent,
|
// One accent per colour mode; tokens.css picks the active one as --accent,
|
||||||
'--accent-dim': `${theme.accent}1f`,
|
// so the mode toggle switches it without a server round trip.
|
||||||
|
'--accent-dark': accentForMode(theme, 'dark'),
|
||||||
|
'--accent-light': accentForMode(theme, 'light'),
|
||||||
|
'--on-accent-dark': onAccent(accentForMode(theme, 'dark')),
|
||||||
|
'--on-accent-light': onAccent(accentForMode(theme, 'light')),
|
||||||
'--font-serif': `'${theme.fonts.heading}', Georgia, 'Times New Roman', serif`,
|
'--font-serif': `'${theme.fonts.heading}', Georgia, 'Times New Roman', serif`,
|
||||||
'--font-sans': `'${theme.fonts.body}', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`,
|
'--font-sans': `'${theme.fonts.body}', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`,
|
||||||
'--font-caption': `'${theme.fonts.caption}', Georgia, serif`,
|
'--font-caption': `'${theme.fonts.caption}', Georgia, serif`,
|
||||||
@@ -157,8 +162,6 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
data-default-theme={config.colorMode}
|
data-default-theme={config.colorMode}
|
||||||
>
|
>
|
||||||
<head>
|
<head>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
|
||||||
<link rel="stylesheet" href={fontsUrl} />
|
<link rel="stylesheet" href={fontsUrl} />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -3,6 +3,19 @@
|
|||||||
page and inside a journal entry alike. Leaflet's own stylesheet is
|
page and inside a journal entry alike. Leaflet's own stylesheet is
|
||||||
emitted by components/LeafletMap.tsx. */
|
emitted by components/LeafletMap.tsx. */
|
||||||
|
|
||||||
|
/* ── Tiles ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* OpenStreetMap's tiles are bright and colourful. Photographs should carry
|
||||||
|
the colour, so the map is muted in both modes, and inverted into a dark
|
||||||
|
map in dark mode (the default; light mode sets data-theme='light'). */
|
||||||
|
.leaflet-tile-pane {
|
||||||
|
filter: invert(1) hue-rotate(180deg) grayscale(0.85) brightness(0.9) contrast(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] .leaflet-tile-pane {
|
||||||
|
filter: grayscale(0.85) contrast(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Popups ────────────────────────────────────────────────── */
|
/* ── Popups ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.leaflet-popup-content-wrapper {
|
.leaflet-popup-content-wrapper {
|
||||||
@@ -93,7 +106,7 @@
|
|||||||
height: 32px;
|
height: 32px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--accent, #e60012);
|
background: var(--accent, #e60012);
|
||||||
color: #fff;
|
color: var(--on-accent, #fff);
|
||||||
font-family: var(--font-caption);
|
font-family: var(--font-caption);
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import { MapView } from '@/components/MapView';
|
|||||||
import { BackLink } from '@/components/BackLink';
|
import { BackLink } from '@/components/BackLink';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { getServerDictionary } from '@/lib/i18n/server';
|
import { getServerDictionary } from '@/lib/i18n/server';
|
||||||
|
// Imported here as well as in LeafletMap: this page has the stylesheet in its
|
||||||
|
// server HTML, so the tiles never render before their CSS.
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
import './map.css';
|
import './map.css';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -33,15 +36,6 @@ export default function MapPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* LeafletMap emits this too, but only once /api/map has answered; on
|
|
||||||
this page the stylesheet should be in the server HTML so the tiles
|
|
||||||
never render before their CSS. Same href — the browser loads it once. */}
|
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
|
||||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
|
||||||
crossOrigin=""
|
|
||||||
/>
|
|
||||||
<div className="map-page">
|
<div className="map-page">
|
||||||
<div className="map-page__header">
|
<div className="map-page__header">
|
||||||
<div className="map-page__header-main">
|
<div className="map-page__header-main">
|
||||||
|
|||||||
28
app/page.tsx
28
app/page.tsx
@@ -7,7 +7,9 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { immich, type ImmichAsset } from '@/lib/immich';
|
import { immich, type ImmichAsset } from '@/lib/immich';
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig, type ExifDisplayConfig } from '@/lib/config';
|
||||||
|
import { assetLocationPrecision } from '@/lib/assetLocation';
|
||||||
|
import { placeLabel } from '@/lib/mapPrecision';
|
||||||
import { imageUrl, assetPlaceholder } from '@/lib/urls';
|
import { imageUrl, assetPlaceholder } from '@/lib/urls';
|
||||||
import { HeroCarousel } from '@/components/HeroCarousel';
|
import { HeroCarousel } from '@/components/HeroCarousel';
|
||||||
import { FadeIn } from '@/components/FadeIn';
|
import { FadeIn } from '@/components/FadeIn';
|
||||||
@@ -46,16 +48,26 @@ function heroNavEntries(subpages: HeroSubpage[], albums: HeroAlbum[], t: Diction
|
|||||||
/**
|
/**
|
||||||
* Camera line for the hero chip: "Q3 · 28MM · ƒ/5.6 · 1/250 · ISO 200".
|
* Camera line for the hero chip: "Q3 · 28MM · ƒ/5.6 · 1/250 · ISO 200".
|
||||||
* Returns undefined when the asset carries no usable EXIF.
|
* Returns undefined when the asset carries no usable EXIF.
|
||||||
|
*
|
||||||
|
* Honours the same `exif:` groups as the lightbox panel (`/api/exif`), and the
|
||||||
|
* city passes the album's `location:` precision too — the chip is the most
|
||||||
|
* visible EXIF on the site and must not publish what the owner switched off.
|
||||||
*/
|
*/
|
||||||
function heroExifLine(asset: ImmichAsset): string | undefined {
|
async function heroExifLine(
|
||||||
|
asset: ImmichAsset,
|
||||||
|
show: ExifDisplayConfig,
|
||||||
|
): Promise<string | undefined> {
|
||||||
const e = asset.exifInfo;
|
const e = asset.exifInfo;
|
||||||
if (!e) return undefined;
|
if (!e) return undefined;
|
||||||
|
const city = show.location
|
||||||
|
? placeLabel(await assetLocationPrecision(asset.id), { city: e.city ?? '', country: '' }).city
|
||||||
|
: '';
|
||||||
const parts = [
|
const parts = [
|
||||||
e.city || e.model || undefined,
|
city || (show.camera ? e.model : undefined) || undefined,
|
||||||
e.focalLength ? `${Math.round(e.focalLength)}mm` : undefined,
|
show.camera && e.focalLength ? `${Math.round(e.focalLength)}mm` : undefined,
|
||||||
e.fNumber ? `ƒ/${e.fNumber}` : undefined,
|
show.settings && e.fNumber ? `ƒ/${e.fNumber}` : undefined,
|
||||||
e.exposureTime ? `${e.exposureTime}s` : undefined,
|
show.settings && e.exposureTime ? `${e.exposureTime}s` : undefined,
|
||||||
e.iso ? `ISO ${e.iso}` : undefined,
|
show.settings && e.iso ? `ISO ${e.iso}` : undefined,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
return parts.length > 0 ? parts.join(' · ') : undefined;
|
return parts.length > 0 ? parts.join(' · ') : undefined;
|
||||||
}
|
}
|
||||||
@@ -129,7 +141,7 @@ export default async function HomePage() {
|
|||||||
config.heroImages.map(async (id) => {
|
config.heroImages.map(async (id) => {
|
||||||
const asset = await immich.getAssetInfo(id);
|
const asset = await immich.getAssetInfo(id);
|
||||||
const ph = asset ? assetPlaceholder(asset) : null;
|
const ph = asset ? assetPlaceholder(asset) : null;
|
||||||
const exif = asset ? heroExifLine(asset) : undefined;
|
const exif = asset ? await heroExifLine(asset, config.exif) : undefined;
|
||||||
return {
|
return {
|
||||||
src: imageUrl(id, 'preview'),
|
src: imageUrl(id, 'preview'),
|
||||||
...(ph ? { blurDataURL: ph.blurDataURL } : {}),
|
...(ph ? { blurDataURL: ph.blurDataURL } : {}),
|
||||||
|
|||||||
61
app/privacy/page.tsx
Normal file
61
app/privacy/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Privacy policy (#699): content/privacy.md, written by the owner in the
|
||||||
|
* admin panel. Folio ships no legal text of its own.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Metadata } from 'next';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { BackLink } from '@/components/BackLink';
|
||||||
|
import { getServerDictionary } from '@/lib/i18n/server';
|
||||||
|
import { parseMarkdownBlocks } from '@/lib/markdownBlocks';
|
||||||
|
import { privacyAvailable, readPrivacy } from '@/lib/privacy';
|
||||||
|
import './privacy.css';
|
||||||
|
|
||||||
|
export function generateMetadata(): Metadata {
|
||||||
|
if (!privacyAvailable(getConfig())) return {};
|
||||||
|
return {
|
||||||
|
title: getServerDictionary().privacy.title,
|
||||||
|
robots: { index: false, follow: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default function PrivacyPage() {
|
||||||
|
const t = getServerDictionary();
|
||||||
|
if (!privacyAvailable(getConfig())) notFound();
|
||||||
|
|
||||||
|
const blocks = parseMarkdownBlocks(readPrivacy());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="privacy-page">
|
||||||
|
<header className="privacy-page__header">
|
||||||
|
<BackLink href="/" label={t.common.home} />
|
||||||
|
<h1 className="privacy-page__title">{t.privacy.title}</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Every html string below comes from renderInlineMarkdown, which escapes
|
||||||
|
the author's text before adding its own tags. */}
|
||||||
|
<main className="privacy-page__content">
|
||||||
|
{blocks.map((block, i) => {
|
||||||
|
if (block.type === 'heading') {
|
||||||
|
const Tag = block.level === 2 ? 'h2' : 'h3';
|
||||||
|
return <Tag key={i} dangerouslySetInnerHTML={{ __html: block.html }} />;
|
||||||
|
}
|
||||||
|
if (block.type === 'list') {
|
||||||
|
const List = block.ordered ? 'ol' : 'ul';
|
||||||
|
return (
|
||||||
|
<List key={i}>
|
||||||
|
{block.items.map((item, j) => (
|
||||||
|
<li key={j} dangerouslySetInnerHTML={{ __html: item }} />
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <p key={i} dangerouslySetInnerHTML={{ __html: block.html }} />;
|
||||||
|
})}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
app/privacy/privacy.css
Normal file
43
app/privacy/privacy.css
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
.privacy-page {
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
min-height: 80vh;
|
||||||
|
}
|
||||||
|
.privacy-page__header {
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.privacy-page__title {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: clamp(2.2rem, 7vw, 3.5rem);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.privacy-page__content {
|
||||||
|
max-width: 680px;
|
||||||
|
margin: 0 auto;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.privacy-page__content h2 {
|
||||||
|
margin: 2.5rem 0 0.75rem;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.privacy-page__content h3 {
|
||||||
|
margin: 1.75rem 0 0.5rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.privacy-page__content p,
|
||||||
|
.privacy-page__content ul,
|
||||||
|
.privacy-page__content ol {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
.privacy-page__content ul,
|
||||||
|
.privacy-page__content ol {
|
||||||
|
padding-left: 1.4rem;
|
||||||
|
}
|
||||||
|
/* tokens.css strips link underlines site-wide; in running text they are needed. */
|
||||||
|
.privacy-page__content a {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
@@ -31,8 +31,11 @@
|
|||||||
--border-subtle: #2a2a28;
|
--border-subtle: #2a2a28;
|
||||||
--border-hover: #3a3a38;
|
--border-hover: #3a3a38;
|
||||||
|
|
||||||
--accent: #e60012;
|
/* The layout sets --accent-dark/--accent-light (and --on-accent-*) from the
|
||||||
--accent-dim: rgba(230, 0, 18, 0.12);
|
theme; the fallbacks only cover a page rendered without it. */
|
||||||
|
--accent: var(--accent-dark, #e60012);
|
||||||
|
--accent-dim: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||||
|
--on-accent: var(--on-accent-dark, #ffffff);
|
||||||
|
|
||||||
--font-sans: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
--font-sans: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
--font-serif: 'Playfair Display', Georgia, 'Times New Roman', serif;
|
--font-serif: 'Playfair Display', Georgia, 'Times New Roman', serif;
|
||||||
@@ -65,8 +68,9 @@
|
|||||||
--border-subtle: #ddddd8;
|
--border-subtle: #ddddd8;
|
||||||
--border-hover: #c5c5c0;
|
--border-hover: #c5c5c0;
|
||||||
|
|
||||||
--accent: #c8000f;
|
--accent: var(--accent-light, #c8000f);
|
||||||
--accent-dim: rgba(200, 0, 15, 0.08);
|
--accent-dim: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||||
|
--on-accent: var(--on-accent-light, #ffffff);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Studio Modern: Leica Precision Palette ──────── */
|
/* ── Studio Modern: Leica Precision Palette ──────── */
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { getServerDictionary } from '@/lib/i18n/server';
|
|
||||||
|
|
||||||
interface BackLinkProps {
|
interface BackLinkProps {
|
||||||
href: string;
|
href: string;
|
||||||
@@ -12,9 +11,10 @@ interface BackLinkProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function BackLink({ href, label }: BackLinkProps) {
|
export function BackLink({ href, label }: BackLinkProps) {
|
||||||
const t = getServerDictionary();
|
|
||||||
return (
|
return (
|
||||||
<Link href={href} className="album-header__back" aria-label={t.common.backTo(label)}>
|
// No aria-label: every caller's label already reads "Back to …", and an
|
||||||
|
// aria-label built from it announced "Back to Back to …".
|
||||||
|
<Link href={href} className="album-header__back">
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
|
|||||||
133
components/ContactForm.tsx
Normal file
133
components/ContactForm.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contact form (#702). Posts to /api/contact; see lib/contact.ts for what
|
||||||
|
* happens to the message.
|
||||||
|
*
|
||||||
|
* Spam protection without a captcha, which would be another third party: a
|
||||||
|
* honeypot field people never see, and `startedAt`, the time the form was
|
||||||
|
* rendered, which the server compares against the submission.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useDictionary } from './I18nProvider';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
retentionDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type State = 'idle' | 'sending' | 'done';
|
||||||
|
|
||||||
|
export default function ContactForm({ retentionDays }: Props) {
|
||||||
|
const t = useDictionary().contact;
|
||||||
|
// When the form became usable. A bot posting straight to the endpoint has
|
||||||
|
// none; one filling the form on load is faster than MIN_FILL_MS.
|
||||||
|
const [startedAt] = useState(() => Date.now());
|
||||||
|
const [state, setState] = useState<State>('idle');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
const data = new FormData(e.currentTarget);
|
||||||
|
setError('');
|
||||||
|
setState('sending');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/contact', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: data.get('name'),
|
||||||
|
email: data.get('email'),
|
||||||
|
message: data.get('message'),
|
||||||
|
website: data.get('website'),
|
||||||
|
startedAt,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setState('done');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(res.status === 400 ? t.invalid : res.status === 429 ? t.tooMany : t.failed);
|
||||||
|
} catch {
|
||||||
|
setError(t.failed);
|
||||||
|
}
|
||||||
|
setState('idle');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'done') {
|
||||||
|
return (
|
||||||
|
<div className="contact-form__done" role="status">
|
||||||
|
<h2>{t.successTitle}</h2>
|
||||||
|
<p>{t.successText}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sending = state === 'sending';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="contact-form" onSubmit={handleSubmit}>
|
||||||
|
<div className="contact-form__field">
|
||||||
|
<label className="contact-form__label" htmlFor="contact-name">
|
||||||
|
{t.name}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="contact-name"
|
||||||
|
name="name"
|
||||||
|
className="contact-form__input"
|
||||||
|
autoComplete="name"
|
||||||
|
maxLength={100}
|
||||||
|
required
|
||||||
|
disabled={sending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="contact-form__field">
|
||||||
|
<label className="contact-form__label" htmlFor="contact-email">
|
||||||
|
{t.email}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="contact-email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
className="contact-form__input"
|
||||||
|
autoComplete="email"
|
||||||
|
maxLength={200}
|
||||||
|
required
|
||||||
|
disabled={sending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="contact-form__field">
|
||||||
|
<label className="contact-form__label" htmlFor="contact-message">
|
||||||
|
{t.message}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="contact-message"
|
||||||
|
name="message"
|
||||||
|
className="contact-form__input"
|
||||||
|
maxLength={5000}
|
||||||
|
required
|
||||||
|
disabled={sending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="contact-form__trap" aria-hidden="true">
|
||||||
|
<label htmlFor="contact-website">Website</label>
|
||||||
|
<input id="contact-website" name="website" tabIndex={-1} autoComplete="off" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="contact-form__privacy">{t.privacy(retentionDays)}</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="contact-form__error" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="submit" className="contact-form__submit" disabled={sending}>
|
||||||
|
{sending ? t.sending : t.submit}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useLayoutEffect } from 'react';
|
import React, { useState, useEffect, useCallback, useLayoutEffect } from 'react';
|
||||||
import { IconPalette, IconX } from './Icons';
|
import { IconPalette, IconX } from './Icons';
|
||||||
|
import { getFontsCssUrl } from '@/lib/config/theme';
|
||||||
|
|
||||||
const PRESETS = ['studio', 'studio-modern', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
|
const PRESETS = ['studio', 'studio-modern', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
|
||||||
const FRAMES = ['none', 'passepartout', 'shadow'];
|
const FRAMES = ['none', 'passepartout', 'shadow'];
|
||||||
@@ -108,14 +109,8 @@ export function DevToolbar() {
|
|||||||
`'${fonts.body}', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`,
|
`'${fonts.body}', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`,
|
||||||
);
|
);
|
||||||
setVar('--font-caption', `'${fonts.caption}', Georgia, serif`);
|
setVar('--font-caption', `'${fonts.caption}', Georgia, serif`);
|
||||||
// Dynamically load Google Fonts if not already loaded
|
// Load the fonts through the same-origin proxy the layout uses
|
||||||
const families = [fonts.heading, fonts.body, fonts.caption]
|
const href = getFontsCssUrl([fonts.heading, fonts.body, fonts.caption]);
|
||||||
.filter((f, i, a) => a.indexOf(f) === i)
|
|
||||||
.map(
|
|
||||||
(f) => `family=${f.replace(/\s+/g, '+')}:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400`,
|
|
||||||
)
|
|
||||||
.join('&');
|
|
||||||
const href = `https://fonts.googleapis.com/css2?${families}&display=swap`;
|
|
||||||
// Check if already loaded
|
// Check if already loaded
|
||||||
const existing = document.querySelector(`link[href="${href}"]`);
|
const existing = document.querySelector(`link[href="${href}"]`);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
|
|||||||
46
components/EmailLink.tsx
Normal file
46
components/EmailLink.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A mailto: link whose address is only put together in the browser; see
|
||||||
|
* lib/emailObfuscation.ts.
|
||||||
|
*
|
||||||
|
* Until hydration there is no address and no href. A page that must keep the
|
||||||
|
* address reachable without JavaScript (the Impressum) renders its own
|
||||||
|
* <noscript> with spelledOutEmail(). That is not a prop here: props travel in
|
||||||
|
* the RSC payload, so even an unused fallback would land in every page.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useSyncExternalStore, type ReactNode } from 'react';
|
||||||
|
import { decodeEmail } from '@/lib/emailObfuscation';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** encodeEmail() of the address. */
|
||||||
|
encoded: string;
|
||||||
|
className?: string;
|
||||||
|
'aria-label'?: string;
|
||||||
|
/** Link content, e.g. an icon. Without it the address itself is shown. */
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribe = () => () => {};
|
||||||
|
|
||||||
|
export default function EmailLink({ encoded, className, children, ...rest }: Props) {
|
||||||
|
// False on the server and during hydration, true afterwards: the decoded
|
||||||
|
// address never appears in server HTML and never causes a mismatch.
|
||||||
|
const inBrowser = useSyncExternalStore(
|
||||||
|
subscribe,
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
const email = inBrowser ? decodeEmail(encoded) : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={email ? `mailto:${email}` : undefined}
|
||||||
|
className={className}
|
||||||
|
aria-label={rest['aria-label']}
|
||||||
|
>
|
||||||
|
{children ?? email}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,15 +6,21 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
import { getServerDictionary } from '@/lib/i18n/server';
|
import { getServerDictionary } from '@/lib/i18n/server';
|
||||||
|
import { privacyAvailable } from '@/lib/privacy';
|
||||||
|
import EmailLink from './EmailLink';
|
||||||
|
import { encodeEmail } from '@/lib/emailObfuscation';
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
const { footer, legal } = config;
|
const { footer, legal, contact } = config;
|
||||||
const t = getServerDictionary();
|
const t = getServerDictionary();
|
||||||
|
const privacy = privacyAvailable(config);
|
||||||
|
|
||||||
// Don't render if no footer config AND no legal config
|
// Don't render if there is no footer config and no legal or contact page to link
|
||||||
if (
|
if (
|
||||||
!legal.enabled &&
|
!legal.enabled &&
|
||||||
|
!contact.enabled &&
|
||||||
|
!privacy &&
|
||||||
(!footer || (!footer.name && !footer.instagram && !footer.email && !footer.website))
|
(!footer || (!footer.name && !footer.instagram && !footer.email && !footer.website))
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
@@ -30,6 +36,16 @@ export function Footer() {
|
|||||||
{t.legal.navLabel}
|
{t.legal.navLabel}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
{privacy && (
|
||||||
|
<Link href="/privacy" className="footer__legal-link">
|
||||||
|
{t.privacy.navLabel}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{contact.enabled && (
|
||||||
|
<Link href="/contact" className="footer__legal-link">
|
||||||
|
{t.contact.navLabel}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="footer__links">
|
<div className="footer__links">
|
||||||
@@ -59,7 +75,11 @@ export function Footer() {
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{footer?.email && (
|
{footer?.email && (
|
||||||
<a href={`mailto:${footer.email}`} className="footer__link" aria-label={t.common.email}>
|
<EmailLink
|
||||||
|
encoded={encodeEmail(footer.email)}
|
||||||
|
className="footer__link"
|
||||||
|
aria-label={t.common.email}
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
width="16"
|
width="16"
|
||||||
@@ -74,7 +94,7 @@ export function Footer() {
|
|||||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
|
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
|
||||||
<polyline points="22,6 12,13 2,6" />
|
<polyline points="22,6 12,13 2,6" />
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</EmailLink>
|
||||||
)}
|
)}
|
||||||
{footer?.website && (
|
{footer?.website && (
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -5,12 +5,14 @@
|
|||||||
* blocks: tiles, markers from props, an optional line through them, fit to
|
* blocks: tiles, markers from props, an optional line through them, fit to
|
||||||
* bounds. It knows nothing about /api/map; MapView fetches and hands over.
|
* bounds. It knows nothing about /api/map; MapView fetches and hands over.
|
||||||
*
|
*
|
||||||
* The Leaflet stylesheet is emitted here rather than by each page, so a map
|
* The Leaflet stylesheet is imported here rather than by each page, so a map
|
||||||
* anywhere brings its own CSS. Marker and popup styling lives in
|
* anywhere brings its own CSS. It comes from the npm package, bundled, not
|
||||||
* app/leaflet.css, loaded globally.
|
* from a CDN: no visitor request leaves this origin for it (#699). Marker
|
||||||
|
* and popup styling lives in app/leaflet.css, loaded globally.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
|
||||||
export interface LeafletMarker {
|
export interface LeafletMarker {
|
||||||
lat: number;
|
lat: number;
|
||||||
@@ -41,9 +43,6 @@ export function escapeHtml(str: string): string {
|
|||||||
.replace(/'/g, ''');
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
const LEAFLET_CSS_HREF = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
|
||||||
const LEAFLET_CSS_INTEGRITY = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=';
|
|
||||||
|
|
||||||
export function LeafletMap({
|
export function LeafletMap({
|
||||||
markers,
|
markers,
|
||||||
line = false,
|
line = false,
|
||||||
@@ -75,11 +74,13 @@ export function LeafletMap({
|
|||||||
const map = L.map(containerRef.current, { zoomControl: true, attributionControl: true });
|
const map = L.map(containerRef.current, { zoomControl: true, attributionControl: true });
|
||||||
mapRef.current = map;
|
mapRef.current = map;
|
||||||
|
|
||||||
// Dark-themed tiles (CartoDB Dark Matter)
|
// OpenStreetMap's own tiles: free to use with attribution and a Referer,
|
||||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
// which the Referrer-Policy sends. CARTO's Dark Matter, used before,
|
||||||
|
// started answering every tile with "API KEY REQUIRED". The dark look
|
||||||
|
// comes from a filter in app/leaflet.css, so it follows the colour mode.
|
||||||
|
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
attribution:
|
attribution:
|
||||||
'© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||||
subdomains: 'abcd',
|
|
||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
|
|
||||||
@@ -136,15 +137,5 @@ export function LeafletMap({
|
|||||||
};
|
};
|
||||||
}, [markersKey, line, fitMaxZoom]);
|
}, [markersKey, line, fitMaxZoom]);
|
||||||
|
|
||||||
return (
|
return <div ref={containerRef} className={className} />;
|
||||||
<>
|
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href={LEAFLET_CSS_HREF}
|
|
||||||
integrity={LEAFLET_CSS_INTEGRITY}
|
|
||||||
crossOrigin=""
|
|
||||||
/>
|
|
||||||
<div ref={containerRef} className={className} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user