Compare commits
10 Commits
89a8fac564
...
416fae33a2
| Author | SHA1 | Date | |
|---|---|---|---|
| 416fae33a2 | |||
| 15dcbf904e | |||
|
|
c172d170ae | ||
|
|
9270546c9d | ||
|
|
5f831f719a | ||
|
|
81ce886b01 | ||
|
|
ad8b09700b | ||
|
|
5f251923f0 | ||
|
|
3193c500e4 | ||
|
|
eab3b9c257 |
@@ -30,3 +30,14 @@ eslint.config.mjs
|
|||||||
|
|
||||||
# OS metadata
|
# OS metadata
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Site content — gallery.yaml, settings.yaml, pages, journal entries,
|
||||||
|
# credentials (install.json, .setup-token), contact messages, proofing links,
|
||||||
|
# analytics and backups. The container mounts content/ as a volume, so none of
|
||||||
|
# it belongs in the build context: a local build from a checkout with a live
|
||||||
|
# content/ would otherwise bake all of it into the image layers, where the
|
||||||
|
# mounted volume hides it but `docker save` or a push hands it out. Only the
|
||||||
|
# tracked templates are let through; the Dockerfile copies them by name.
|
||||||
|
content
|
||||||
|
!content/*.example
|
||||||
|
!content/journal/*.example
|
||||||
|
|||||||
@@ -34,6 +34,19 @@ 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
|
||||||
|
|
||||||
|
# Client proofing links: POST a JSON notification here when a client submits
|
||||||
|
# a selection (Discord, Slack, Gotify, n8n, ...). See
|
||||||
|
# docs/gallery-config.md#client-proofing-links.
|
||||||
|
# PROOFING_WEBHOOK_URL=https://discord.com/api/webhooks/...
|
||||||
|
|
||||||
|
# CDN mode: serve photos and videos through a pull CDN (default: off).
|
||||||
|
# Point a CDN (Cloudflare, Bunny, CloudFront, ...) at this server as its origin,
|
||||||
|
# cache /api/image/* and /api/video/* including the query string, and put its
|
||||||
|
# URL here. Pages, EXIF, the map and downloads keep coming from this server.
|
||||||
|
# Ignored while a site password is set, so the password keeps covering photos.
|
||||||
|
# Also set TRUSTED_PROXY_HOPS to count the CDN, see docs/deployment.md#cdn-mode.
|
||||||
|
# CDN_URL=https://cdn.example.com
|
||||||
|
|
||||||
# How long to wait for a response from Immich, in milliseconds
|
# 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
|
||||||
|
|||||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
|||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '22'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -115,7 +115,7 @@ jobs:
|
|||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '22'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|||||||
9
.gitignore
vendored
9
.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.
|
||||||
@@ -81,11 +82,19 @@ content/.backups/
|
|||||||
# gets its own .backups/ — the top-level pattern above does not reach it.
|
# gets its own .backups/ — the top-level pattern above does not reach it.
|
||||||
content/**/.backups/
|
content/**/.backups/
|
||||||
content/analytics.json
|
content/analytics.json
|
||||||
|
content/proofing.json
|
||||||
content/*.screenshot-bak
|
content/*.screenshot-bak
|
||||||
# Credentials written by the /install wizard (never committed)
|
# Credentials written by the /install wizard (never committed)
|
||||||
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/
|
||||||
|
# Content pages (lib/admin/pages-service.ts) and the uploaded favicon
|
||||||
|
content/pages/
|
||||||
|
content/favicon.svg
|
||||||
|
|
||||||
# ── Internal Dev Docs ────────────────────────────────
|
# ── Internal Dev Docs ────────────────────────────────
|
||||||
docs/dev/
|
docs/dev/
|
||||||
|
|||||||
1075
CHANGELOG.md
1075
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -75,6 +75,12 @@ cheaper to test and stays tested when the markup around it changes. Reach for a
|
|||||||
component test when the behaviour _is_ the rendering: a state that hides a form,
|
component test when the behaviour _is_ the rendering: a state that hides a form,
|
||||||
a control that must stay disabled, a keyboard shortcut.
|
a control that must stay disabled, a keyboard shortcut.
|
||||||
|
|
||||||
|
**Never write into the checkout's `content/`.** On a self-hosted install that
|
||||||
|
directory is the live site. Services resolve it from `process.cwd()`, so a test
|
||||||
|
that saves or deletes anything points `cwd` at a temp directory first and
|
||||||
|
re-imports the service — `lib/__tests__/journal-backup.test.ts` shows how.
|
||||||
|
`vitest.content-guard.ts` fails any test that writes there anyway.
|
||||||
|
|
||||||
## Code style
|
## Code style
|
||||||
|
|
||||||
- **TypeScript** — strict mode, no `any` without justification
|
- **TypeScript** — strict mode, no `any` without justification
|
||||||
|
|||||||
32
Dockerfile
32
Dockerfile
@@ -24,6 +24,13 @@ ENV AUTH_SECRET="build-placeholder"
|
|||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
# The standalone trace copies content/ along: proxy.ts and instrumentation.ts
|
||||||
|
# read it through runtime paths, which the tracer resolves to the whole
|
||||||
|
# directory. .dockerignore already keeps a checkout's content out of this
|
||||||
|
# stage; dropping the traced copy makes sure the runner below gets content/
|
||||||
|
# from nowhere but the templates it copies by name.
|
||||||
|
RUN rm -rf .next/standalone/content
|
||||||
|
|
||||||
# ── Runtime ───────────────────────────────────────
|
# ── Runtime ───────────────────────────────────────
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -38,7 +45,16 @@ RUN adduser --system --uid 1001 nextjs
|
|||||||
RUN apk add --no-cache su-exec
|
RUN apk add --no-cache su-exec
|
||||||
|
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder /app/content ./content
|
|
||||||
|
# content/ ships with the templates only. The site's own files come from the
|
||||||
|
# volume mounted at /app/content; with an empty volume the app shows the setup
|
||||||
|
# screen and the wizard writes into it. Copying the templates by name rather
|
||||||
|
# than the whole directory keeps a checkout's real content (credentials,
|
||||||
|
# contact messages, proofing links, backups) out of the image even if a build
|
||||||
|
# context ever includes it — .dockerignore is the first line of that.
|
||||||
|
RUN mkdir -p content/journal
|
||||||
|
COPY --from=builder /app/content/*.example ./content/
|
||||||
|
COPY --from=builder /app/content/journal/*.example ./content/journal/
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
COPY --chmod=755 docker-entrypoint.sh ./docker-entrypoint.sh
|
COPY --chmod=755 docker-entrypoint.sh ./docker-entrypoint.sh
|
||||||
@@ -46,13 +62,19 @@ COPY --chmod=755 docker-entrypoint.sh ./docker-entrypoint.sh
|
|||||||
# `npm run doctor` — the config doctor for the terminal (#521). It is the tool
|
# `npm run doctor` — the config doctor for the terminal (#521). It is the tool
|
||||||
# for the case where the app will not start, so it has to be in the runtime
|
# for the case where the app will not start, so it has to be in the runtime
|
||||||
# image rather than only in a checkout. Node 22.18+ strips the types itself,
|
# image rather than only in a checkout. Node 22.18+ strips the types itself,
|
||||||
# so this is the whole of it: the entry point, the pure checks it imports, and
|
# so this is the whole of it: the entry point, every module it imports at
|
||||||
# js-yaml, which the standalone bundle inlines rather than leaving on disk.
|
# runtime, and js-yaml, which the standalone bundle inlines rather than leaving
|
||||||
# The standalone build copies package.json verbatim, so the script entry is
|
# on disk. The standalone build copies package.json verbatim, so the script
|
||||||
# already there.
|
# entry is already there. scripts/__tests__/doctor-image.test.ts walks the
|
||||||
|
# import graph and fails when a module of it is missing from this list.
|
||||||
COPY --from=builder /app/scripts/doctor.mjs ./scripts/doctor.mjs
|
COPY --from=builder /app/scripts/doctor.mjs ./scripts/doctor.mjs
|
||||||
COPY --from=builder /app/scripts/doctor.mts ./scripts/doctor.mts
|
COPY --from=builder /app/scripts/doctor.mts ./scripts/doctor.mts
|
||||||
COPY --from=builder /app/lib/admin/doctor.ts ./lib/admin/doctor.ts
|
COPY --from=builder /app/lib/admin/doctor.ts ./lib/admin/doctor.ts
|
||||||
|
COPY --from=builder /app/lib/config/settingValues.ts ./lib/config/settingValues.ts
|
||||||
|
COPY --from=builder /app/lib/config/schema.ts ./lib/config/schema.ts
|
||||||
|
COPY --from=builder /app/lib/config/theme.ts ./lib/config/theme.ts
|
||||||
|
COPY --from=builder /app/lib/config/presets.ts ./lib/config/presets.ts
|
||||||
|
COPY --from=builder /app/lib/siteUrl.ts ./lib/siteUrl.ts
|
||||||
COPY --from=deps /app/node_modules/js-yaml ./node_modules/js-yaml
|
COPY --from=deps /app/node_modules/js-yaml ./node_modules/js-yaml
|
||||||
|
|
||||||
# Content dir must be writable for admin saves (ownership fixed at runtime)
|
# Content dir must be writable for admin saves (ownership fixed at runtime)
|
||||||
|
|||||||
76
README.md
76
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.20.1** — a single click zooms the lightbox in, and the setup guide now names the `asset.download` permission that zoom and downloads need. → [What's New](#whats-new)
|
||||||
|
|
||||||
## Contents
|
## Contents
|
||||||
|
|
||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
@@ -38,7 +40,7 @@ A self-hosted portfolio powered by [Immich](https://immich.app). It acts as a **
|
|||||||
- **Justified rows** _(experimental)_ — every row fills the width at one shared height, aspect ratios intact, nothing cropped
|
- **Justified rows** _(experimental)_ — every row fills the width at one shared height, aspect ratios intact, nothing cropped
|
||||||
- **Per-subpage grid overrides** — each subpage can define its own columns, gap, aspect ratio, and layout mode, and individual albums can override that again _(experimental)_
|
- **Per-subpage grid overrides** — each subpage can define its own columns, gap, aspect ratio, and layout mode, and individual albums can override that again _(experimental)_
|
||||||
- **Cover focal points** _(experimental)_ — decide which part of a cover survives the crop
|
- **Cover focal points** _(experimental)_ — decide which part of a cover survives the crop
|
||||||
- **Fullscreen lightbox** — keyboard and swipe navigation, EXIF panel, adjacent image preloading
|
- **Fullscreen lightbox** — keyboard and swipe navigation, EXIF panel, adjacent image preloading, and opt-in 1:1 zoom to full resolution for checking sharpness ([guide](docs/gallery-config.md#photo-zoom))
|
||||||
- **EXIF metadata on hover** — camera body, lens, focal length, aperture, shutter speed, ISO shown directly on the grid
|
- **EXIF metadata on hover** — camera body, lens, focal length, aperture, shutter speed, ISO shown directly on the grid
|
||||||
- **ThumbHash placeholders** — instant blurred previews while full images load
|
- **ThumbHash placeholders** — instant blurred previews while full images load
|
||||||
|
|
||||||
@@ -59,14 +61,16 @@ A self-hosted portfolio powered by [Immich](https://immich.app). It acts as a **
|
|||||||
- **Auto-generated slugs** — URL slugs derived from album names automatically
|
- **Auto-generated slugs** — URL slugs derived from album names automatically
|
||||||
- **YAML gallery config** — all gallery structure defined in a single `content/gallery.yaml` file
|
- **YAML gallery config** — all gallery structure defined in a single `content/gallery.yaml` file
|
||||||
- **Markdown about page** — `content/about.md` with frontmatter for portrait, name, location, and gear list, editable from the admin panel
|
- **Markdown about page** — `content/about.md` with frontmatter for portrait, name, location, and gear list, editable from the admin panel
|
||||||
- **Journal** — photo essays and travel stories at `/journal`, with drafts, per-entry passwords, cover images and reading times
|
- **Journal** — photo essays and travel stories at `/journal`, with drafts, per-entry passwords, cover images and reading times; new entries can start from a template (wedding, hiking, travel, …)
|
||||||
- **Photo Essay mode** — long-form storytelling pages alternating text with fullbleed, paired and grid image layouts, facts lists and a map of the entry's geotagged photos
|
- **Photo Essay mode** — long-form storytelling pages alternating text with fullbleed, paired and grid image layouts, facts lists, a slice of an album, and a map with the pins you choose — typed places, or photos placed by their GPS
|
||||||
- **Unlisted subpages** _(experimental)_ — reachable by direct link, absent from the navigation
|
- **Unlisted subpages** _(experimental)_ — reachable by direct link, absent from the navigation
|
||||||
- **Subpage on/off toggle** — take a page offline without deleting it
|
- **Subpage on/off toggle** — take a page offline without deleting it
|
||||||
- **External navigation links** _(experimental)_ — point the header at a shop, a blog, or a social profile
|
- **External navigation links** _(experimental)_ — point the header at a shop, a blog, or a social profile
|
||||||
- **Client proofing** — clients favorite photos and export the selection; picks are encoded in the URL, nothing is stored server-side
|
- **Client proofing** — clients favorite photos and export the selection; picks stay in the visitor's browser and the URL, with nothing stored server-side (proofing links, below, do save them)
|
||||||
- **Originals delivery** — per-album opt-in lets visitors download a whole album or their proofing selection as a ZIP of the originals
|
- **Client proofing links** — a private link per client and album, picks saved server-side, a submit that locks the selection and pings you via webhook, expiry dates, download limits, and a Lightroom-ready export of the chosen file names ([guide](docs/gallery-config.md#client-proofing-links))
|
||||||
|
- **Originals delivery** — per-album opt-in lets visitors download a whole album or their proofing selection as a ZIP of the originals, with GPS coordinates removed from JPEG, HEIC and AVIF files
|
||||||
- **Lightbox watermark** — configurable overlay on fullscreen images
|
- **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
|
||||||
|
|
||||||
@@ -87,8 +91,10 @@ A self-hosted portfolio powered by [Immich](https://immich.app). It acts as a **
|
|||||||
- **Album picker** — browse all shared Immich albums with search, see photo counts, and add them with one click
|
- **Album picker** — browse all shared Immich albums with search, see photo counts, and add them with one click
|
||||||
- **Settings editor** — configure theme, grid layout, footer, legal/impressum, SEO, image protection and the about page from a visual UI
|
- **Settings editor** — configure theme, grid layout, footer, legal/impressum, SEO, image protection and the about page from a visual UI
|
||||||
- **Visual previews everywhere** — grid layout, theme, photo frame, hero style and the Google search snippet are picked from preview cards instead of text fields
|
- **Visual previews everywhere** — grid layout, theme, photo frame, hero style and the Google search snippet are picked from preview cards instead of text fields
|
||||||
- **Journal Studio** — split-screen block editor with a live preview of the real page
|
- **Journal Studio** — split-screen block editor with a live preview of the real page; start from a template, drag blocks into order
|
||||||
- **Essay block editor** — assemble photo essays block by block without touching Markdown
|
- **Essay block editor** — assemble photo essays block by block without touching Markdown
|
||||||
|
- **Unsaved edits survive** — leave the page builder or the journal editor mid-edit, and your changes are waiting when you come back
|
||||||
|
- **Diagnostics** — a page that checks the Immich connection, config and security settings, and links every finding to its fix
|
||||||
- **Photo order editor** — drag & drop a hand-picked opening sequence for any album
|
- **Photo order editor** — drag & drop a hand-picked opening sequence for any album
|
||||||
- **Favicon upload** — give the site its own icon in the browser tab
|
- **Favicon upload** — give the site its own icon in the browser tab
|
||||||
- **Backup manager** — every save is backed up automatically; restore any of them with one click
|
- **Backup manager** — every save is backed up automatically; restore any of them with one click
|
||||||
@@ -112,6 +118,7 @@ A self-hosted portfolio powered by [Immich](https://immich.app). It acts as a **
|
|||||||
|
|
||||||
- Health check endpoint at `GET /api/health`
|
- 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)
|
||||||
|
|
||||||
@@ -217,6 +224,9 @@ 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
|
||||||
|
PROOFING_WEBHOOK_URL=https://… # notify on submitted client proofing selections
|
||||||
```
|
```
|
||||||
|
|
||||||
> Login and setup endpoints have their own, much lower limits that `RATE_LIMIT_RPM` does not raise — see [Rate Limiting](docs/gallery-config.md#rate-limiting).
|
> Login and setup endpoints have their own, much lower limits that `RATE_LIMIT_RPM` does not raise — see [Rate Limiting](docs/gallery-config.md#rate-limiting).
|
||||||
@@ -227,11 +237,14 @@ WEBHOOK_SECRET=long-random-string # enables POST /api/webhook cache invalida
|
|||||||
|
|
||||||
Create a dedicated API key in Immich under **Account Settings → API Keys**. Immich Folio only needs **read access** — it never modifies your library.
|
Create a dedicated API key in Immich under **Account Settings → API Keys**. Immich Folio only needs **read access** — it never modifies your library.
|
||||||
|
|
||||||
| Permission | Required | Used for |
|
| Permission | Required | Used for |
|
||||||
| ------------ | -------- | -------------------------------------------------------------------- |
|
| ---------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `album.read` | ✅ Yes | List and fetch album metadata & photo lists |
|
| `album.read` | ✅ Yes | List and fetch album metadata & photo lists |
|
||||||
| `asset.read` | ✅ Yes | Fetch asset metadata, EXIF data, thumbnails, previews, and originals |
|
| `asset.read` | ✅ Yes | Fetch asset metadata, EXIF data and search results |
|
||||||
| `asset.view` | ✅ Yes | Stream image/video files (thumbnail, preview, video playback) |
|
| `asset.view` | ✅ Yes | Stream image/video files (thumbnail, preview, video playback) |
|
||||||
|
| `asset.download` | Only for zoom and downloads | Stream originals: [photo zoom](docs/gallery-config.md#photo-zoom) on JPEG/AVIF and [originals download](docs/gallery-config.md#originals-download) |
|
||||||
|
|
||||||
|
> **Zoom and downloads need `asset.download`.** Without it Immich answers `403` for the original file, so the zoom button reports that full resolution is unavailable and downloads fail. Leave it off if you use neither feature.
|
||||||
|
|
||||||
> **No write permissions needed.** `album.create`, `asset.upload`, `asset.delete`, etc. can all be left **off**.
|
> **No write permissions needed.** `album.create`, `asset.upload`, `asset.delete`, etc. can all be left **off**.
|
||||||
|
|
||||||
@@ -251,7 +264,7 @@ Long-form storytelling with fullbleed photos, side-by-side pairs and grids, quot
|
|||||||
|
|
||||||
### Theming
|
### Theming
|
||||||
|
|
||||||
Seven built-in presets with distinct visual identities — or mix and match with fine-grained control over colors, fonts, corners, photo frames, hero layout, and grid style.
|
Twelve built-in presets with distinct visual identities — or mix and match with fine-grained control over colors, fonts, corners, photo frames, hero layout, and grid style.
|
||||||
|
|
||||||
**Studio Modern** is the default: Leica precision rebuilt around the Archivo grotesque, with IBM Plex Mono for every piece of photographic metadata, hairline rules, zero radius, and red as signal only.
|
**Studio Modern** is the default: Leica precision rebuilt around the Archivo grotesque, with IBM Plex Mono for every piece of photographic metadata, hairline rules, zero radius, and red as signal only.
|
||||||
|
|
||||||
@@ -262,7 +275,8 @@ Seven built-in presets with distinct visual identities — or mix and match with
|
|||||||
<p align="center"><em>Studio Modern, dark and light — every preset ships both, and visitors switch with a toggle in the navigation bar.</em></p>
|
<p align="center"><em>Studio Modern, dark and light — every preset ships both, and visitors switch with a toggle in the navigation bar.</em></p>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
theme: studio-modern # or: studio, minimal, editorial, classic, noir, monograph
|
theme: studio-modern # or: studio, minimal, editorial, classic, noir, monograph,
|
||||||
|
# kunsthalle, ma, cyanotype, salon, birch
|
||||||
```
|
```
|
||||||
|
|
||||||
→ **[View all Themes & Configuration Guide](docs/theming.md)**
|
→ **[View all Themes & Configuration Guide](docs/theming.md)**
|
||||||
@@ -322,11 +336,11 @@ own password, separate from any album passwords, and writes straight to
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/screenshots/admin-login.png" width="49%" alt="Admin panel login screen" />
|
<img src="docs/screenshots/admin-login.png" width="49%" alt="Admin panel login screen" />
|
||||||
<img src="docs/screenshots/admin-page-builder.png" width="49%" alt="Visual page builder with hero images, standalone albums and subpages" />
|
<img src="docs/screenshots/admin-page-builder.png" width="49%" alt="Page builder: the site structure on the left, the selected page with its albums on the right" />
|
||||||
</p>
|
</p>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/screenshots/admin-album-picker.png" width="49%" alt="Album picker listing shared Immich albums with photo counts" />
|
<img src="docs/screenshots/admin-album-picker.png" width="49%" alt="Album picker listing shared Immich albums with photo counts" />
|
||||||
<img src="docs/screenshots/admin-settings.png" width="49%" alt="Settings editor with site identity and feature toggles" />
|
<img src="docs/screenshots/admin-settings.png" width="49%" alt="Settings editor: sections on the left, site identity and feature switches grouped by meaning" />
|
||||||
</p>
|
</p>
|
||||||
<p align="center"><em>Login · page builder · album picker · settings editor</em></p>
|
<p align="center"><em>Login · page builder · album picker · settings editor</em></p>
|
||||||
|
|
||||||
@@ -334,17 +348,20 @@ own password, separate from any album passwords, and writes straight to
|
|||||||
|
|
||||||
## What's New
|
## What's New
|
||||||
|
|
||||||
v0.11.0 — a first-run wizard, a journal, and a set of experimental portfolio
|
v0.20.1 — zoom by a single click, and the API key permission it needs:
|
||||||
features:
|
|
||||||
|
|
||||||
- **First-run setup wizard at `/install`** — configure a fresh deployment from the browser instead of writing config files → [First-Run Setup](#first-run-setup)
|
- **Single-click zoom** — a click on the lightbox photo zooms in by 1.5×, a second click goes back; the magnifier button and `+` still go to 1:1 ([guide](docs/gallery-config.md#photo-zoom))
|
||||||
- **Journal** — photo essays and travel stories at `/journal`, with a split-screen block editor, drafts, per-entry passwords and cover images → [Journal guide](docs/journal.md)
|
- **`asset.download` for zoom and downloads** — zoom into JPEG/AVIF photos and original downloads stream the original from Immich, which needs this permission on the API key ([permissions](#immich-api-key-permissions))
|
||||||
- **About page editor** — portrait, name, location, gear and bio edited in the admin panel, plus a toggle to take the page offline
|
|
||||||
- **Custom favicon upload** — SVG, PNG, ICO or JPEG, stored in the content volume
|
|
||||||
- **Experimental portfolio features** — justified grid layout, `cover` hero splash screen, unlisted subpages, per-album grid overrides, cover focal points, external navigation links
|
|
||||||
- **Admin panel** — every area has its own URL, a floating save bar, and a drag & drop photo-order editor
|
|
||||||
|
|
||||||
No migration and no configuration change are required to upgrade.
|
v0.20.0 — zoom, five new presets and photos as you edited them:
|
||||||
|
|
||||||
|
- **1:1 zoom in the lightbox** — click, pinch, Ctrl + scroll or press `+` to see a photo at full resolution and judge its sharpness; off by default, switched on for the site, a page or a single album ([guide](docs/gallery-config.md#photo-zoom))
|
||||||
|
- **Five new theme presets** — Kunsthalle (a grey gallery wall with numbered wall labels), Ma, Cyanotype, Salon and Birch, each in dark and light; the passepartout mat of Studio, Classic and Noir is finally visible too
|
||||||
|
- **Edits made in Immich show up** — crops and rotations from Immich's editor appear in the grid, the lightbox, share images and downloads
|
||||||
|
- **No GPS in downloads** — original downloads and ZIPs keep camera data and colour profile but lose the coordinates and sub-city place names ([advisory](https://github.com/ralksta/immich-folio/security/advisories/GHSA-g4xf-4285-4cv4))
|
||||||
|
- **Legal pages on a locked site** — with a site password set, the Impressum, the privacy page and the contact form stay reachable, and unknown pages answer a real 404
|
||||||
|
|
||||||
|
**Upgrade note:** zoom needs `zoom: true`, the `asset.download` permission on the Immich API key, and for HEIC/RAW photos Immich's _Full-size image_ setting in JPEG format. If you build the Docker image yourself, keep `content/` mounted as a volume; if you ever pushed a self-built image, see the [changelog](CHANGELOG.md) for what to rotate.
|
||||||
|
|
||||||
Security fixes ship in normal releases, so **running the latest release is the
|
Security fixes ship in normal releases, so **running the latest release is the
|
||||||
recommended baseline**.
|
recommended baseline**.
|
||||||
@@ -373,7 +390,16 @@ 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. In v0.19.0, gave the proofing dialog's
|
||||||
|
_Email to photographer_ an actual recipient.
|
||||||
|
- **[@RichKidsDev](https://github.com/RichKidsDev)** — noticed that the
|
||||||
|
Impressum still cited the TMG, which the DDG replaced in 2024. That report
|
||||||
|
turned into a proper overhaul in v0.18.0: the right law by default, a heading
|
||||||
|
you can change for other countries, clickable contacts and a contact form as
|
||||||
|
a second channel. In v0.19.0, reported that _Email to photographer_ opened a
|
||||||
|
mail with nobody in the _To:_ line.
|
||||||
- **[@ImScheinox](https://github.com/ImScheinox)** — found and fixed portrait
|
- **[@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
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Instant shell for the homepage.
|
* Instant shell for the homepage.
|
||||||
|
*
|
||||||
|
* It lives in the (home) route group, not in app/, on purpose. A loading.tsx
|
||||||
|
* in app/ wraps every route below the root layout in a Suspense boundary, and
|
||||||
|
* a page inside one streams its 200 before it can call notFound() — which made
|
||||||
|
* every unknown slug the catch-all answers a soft 404. See
|
||||||
|
* app/[...path]/page.tsx.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import styles from './loading.module.css';
|
import styles from '../loading.module.css';
|
||||||
import { getServerDictionary } from '@/lib/i18n/server';
|
import { getServerDictionary } from '@/lib/i18n/server';
|
||||||
|
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
@@ -7,73 +7,63 @@
|
|||||||
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';
|
||||||
import { getServerDictionary } from '@/lib/i18n/server';
|
import { getServerDictionary } from '@/lib/i18n/server';
|
||||||
import type { Dictionary } from '@/lib/i18n';
|
import { loadSiteNav } from '@/lib/siteNav.server';
|
||||||
|
import type { SiteNavLink } from '@/lib/siteNav';
|
||||||
|
|
||||||
// Render at request time — requires live Immich connection
|
// Render at request time — requires live Immich connection
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
type HeroSubpage = { slug: string; name: string; albumCount: number };
|
|
||||||
type HeroAlbum = { id: string; slug: string; albumName: string; assetCount: number };
|
|
||||||
|
|
||||||
const pad2 = (n: number) => String(n).padStart(2, '0');
|
const pad2 = (n: number) => String(n).padStart(2, '0');
|
||||||
|
|
||||||
/**
|
|
||||||
* Flatten subpages + standalone albums into one indexed nav list.
|
|
||||||
* Every entry carries an index and a count; presets decide what to show.
|
|
||||||
*/
|
|
||||||
function heroNavEntries(subpages: HeroSubpage[], albums: HeroAlbum[], t: Dictionary) {
|
|
||||||
return [
|
|
||||||
...subpages.map((sp) => ({
|
|
||||||
key: `sp-${sp.slug}`,
|
|
||||||
href: `/${sp.slug}`,
|
|
||||||
label: sp.name,
|
|
||||||
count: t.common.albums(sp.albumCount),
|
|
||||||
})),
|
|
||||||
...albums.map((a) => ({
|
|
||||||
key: `al-${a.id}`,
|
|
||||||
href: `/${a.slug}`,
|
|
||||||
label: a.albumName,
|
|
||||||
count: t.common.photos(a.assetCount),
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Camera line for the hero chip: "Q3 · 28MM · ƒ/5.6 · 1/250 · ISO 200".
|
* 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shared nav links for hero sections. */
|
/** Shared nav links for hero sections — same entries and order as the header. */
|
||||||
function HeroNavLinks({ subpages, albums }: { subpages: HeroSubpage[]; albums: HeroAlbum[] }) {
|
function HeroNavLinks({ links }: { links: SiteNavLink[] }) {
|
||||||
const t = getServerDictionary();
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{heroNavEntries(subpages, albums, t).map((entry, i) => (
|
{links.map((entry, i) => (
|
||||||
<Link key={entry.key} href={entry.href} className="hero__nav-link">
|
<Link key={entry.key} href={entry.href} className="hero__nav-link">
|
||||||
<span className="hero__nav-index" aria-hidden="true">
|
<span className="hero__nav-index" aria-hidden="true">
|
||||||
{pad2(i + 1)}
|
{pad2(i + 1)}
|
||||||
</span>
|
</span>
|
||||||
<span className="hero__nav-label">{entry.label}</span>
|
<span className="hero__nav-label">{entry.label}</span>
|
||||||
<span className="hero__nav-count" aria-hidden="true">
|
{entry.count && (
|
||||||
{entry.count}
|
<span className="hero__nav-count" aria-hidden="true">
|
||||||
</span>
|
{entry.count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
@@ -84,13 +74,13 @@ function HeroNavLinks({ subpages, albums }: { subpages: HeroSubpage[]; albums: H
|
|||||||
function HeroTextContent({
|
function HeroTextContent({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
subpages,
|
links,
|
||||||
albums,
|
navLabel,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
subpages: HeroSubpage[];
|
links: SiteNavLink[];
|
||||||
albums: HeroAlbum[];
|
navLabel: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -103,8 +93,8 @@ function HeroTextContent({
|
|||||||
</FadeIn>
|
</FadeIn>
|
||||||
)}
|
)}
|
||||||
<FadeIn delay={200}>
|
<FadeIn delay={200}>
|
||||||
<nav className="hero__nav">
|
<nav className="hero__nav" aria-label={navLabel}>
|
||||||
<HeroNavLinks subpages={subpages} albums={albums} />
|
<HeroNavLinks links={links} />
|
||||||
</nav>
|
</nav>
|
||||||
</FadeIn>
|
</FadeIn>
|
||||||
</>
|
</>
|
||||||
@@ -119,19 +109,16 @@ export default async function HomePage() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [subpages, albums] = await Promise.all([
|
const links = await loadSiteNav();
|
||||||
immich.getSubpages(),
|
|
||||||
immich.getStandaloneAlbums(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Fetch ThumbHash + camera line for all hero images
|
// Fetch ThumbHash + camera line for all hero images
|
||||||
const heroData = await Promise.all(
|
const heroData = await Promise.all(
|
||||||
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(asset ?? id, 'preview'),
|
||||||
...(ph ? { blurDataURL: ph.blurDataURL } : {}),
|
...(ph ? { blurDataURL: ph.blurDataURL } : {}),
|
||||||
...(exif ? { exif } : {}),
|
...(exif ? { exif } : {}),
|
||||||
};
|
};
|
||||||
@@ -144,8 +131,8 @@ export default async function HomePage() {
|
|||||||
// Welcome-page splash: one fullbleed image, the site
|
// Welcome-page splash: one fullbleed image, the site
|
||||||
// title, and one link into the portfolio (the first nav entry).
|
// title, and one link into the portfolio (the first nav entry).
|
||||||
if (heroStyle === 'cover') {
|
if (heroStyle === 'cover') {
|
||||||
const entries = heroNavEntries(subpages, albums, t);
|
// No fallback: /about used to be hardcoded here and 404ed with About off.
|
||||||
const enterHref = entries[0]?.href ?? '/about';
|
const enterHref = links[0]?.href;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="hero hero--cover">
|
<div className="hero hero--cover">
|
||||||
@@ -159,14 +146,16 @@ export default async function HomePage() {
|
|||||||
<p className="hero__subtitle">{config.siteSubtitle}</p>
|
<p className="hero__subtitle">{config.siteSubtitle}</p>
|
||||||
</FadeIn>
|
</FadeIn>
|
||||||
)}
|
)}
|
||||||
<FadeIn delay={250}>
|
{enterHref && (
|
||||||
<Link href={enterHref} className="hero__cover-enter">
|
<FadeIn delay={250}>
|
||||||
{t.home.enter}
|
<Link href={enterHref} className="hero__cover-enter">
|
||||||
<span className="hero__cover-enter-arrow" aria-hidden="true">
|
{t.home.enter}
|
||||||
→
|
<span className="hero__cover-enter-arrow" aria-hidden="true">
|
||||||
</span>
|
→
|
||||||
</Link>
|
</span>
|
||||||
</FadeIn>
|
</Link>
|
||||||
|
</FadeIn>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -189,8 +178,8 @@ export default async function HomePage() {
|
|||||||
<div className="hero__divider" />
|
<div className="hero__divider" />
|
||||||
</FadeIn>
|
</FadeIn>
|
||||||
<FadeIn delay={300}>
|
<FadeIn delay={300}>
|
||||||
<nav className="hero__nav hero__nav--indexed">
|
<nav className="hero__nav hero__nav--indexed" aria-label={t.nav.heroNavAria}>
|
||||||
<HeroNavLinks subpages={subpages} albums={albums} />
|
<HeroNavLinks links={links} />
|
||||||
</nav>
|
</nav>
|
||||||
</FadeIn>
|
</FadeIn>
|
||||||
</div>
|
</div>
|
||||||
@@ -200,8 +189,6 @@ export default async function HomePage() {
|
|||||||
|
|
||||||
// ── Stacked: fullbleed image + text at bottom + thumbnail strip ─
|
// ── Stacked: fullbleed image + text at bottom + thumbnail strip ─
|
||||||
if (heroStyle === 'stacked') {
|
if (heroStyle === 'stacked') {
|
||||||
const allEntries = heroNavEntries(subpages, albums, t);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="hero hero--stacked">
|
<div className="hero hero--stacked">
|
||||||
<div className="hero__stacked-image">
|
<div className="hero__stacked-image">
|
||||||
@@ -218,8 +205,8 @@ export default async function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<FadeIn delay={200}>
|
<FadeIn delay={200}>
|
||||||
<nav className="hero__thumbnail-strip">
|
<nav className="hero__thumbnail-strip" aria-label={t.nav.heroNavAria}>
|
||||||
{allEntries.map((entry, i) => (
|
{links.map((entry, i) => (
|
||||||
<Link key={entry.key} href={entry.href} className="hero__thumbnail-item">
|
<Link key={entry.key} href={entry.href} className="hero__thumbnail-item">
|
||||||
<span className="hero__thumbnail-index" aria-hidden="true">
|
<span className="hero__thumbnail-index" aria-hidden="true">
|
||||||
{pad2(i + 1)}
|
{pad2(i + 1)}
|
||||||
@@ -257,8 +244,8 @@ export default async function HomePage() {
|
|||||||
<HeroTextContent
|
<HeroTextContent
|
||||||
title={config.siteTitle}
|
title={config.siteTitle}
|
||||||
subtitle={config.siteSubtitle}
|
subtitle={config.siteSubtitle}
|
||||||
subpages={subpages}
|
links={links}
|
||||||
albums={albums}
|
navLabel={t.nav.heroNavAria}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -273,8 +260,8 @@ export default async function HomePage() {
|
|||||||
<HeroTextContent
|
<HeroTextContent
|
||||||
title={config.siteTitle}
|
title={config.siteTitle}
|
||||||
subtitle={config.siteSubtitle}
|
subtitle={config.siteSubtitle}
|
||||||
subpages={subpages}
|
links={links}
|
||||||
albums={albums}
|
navLabel={t.nav.heroNavAria}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -290,8 +277,8 @@ export default async function HomePage() {
|
|||||||
<HeroTextContent
|
<HeroTextContent
|
||||||
title={config.siteTitle}
|
title={config.siteTitle}
|
||||||
subtitle={config.siteSubtitle}
|
subtitle={config.siteSubtitle}
|
||||||
subpages={subpages}
|
links={links}
|
||||||
albums={albums}
|
navLabel={t.nav.heroNavAria}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -307,15 +294,15 @@ export default async function HomePage() {
|
|||||||
<HeroTextContent
|
<HeroTextContent
|
||||||
title={config.siteTitle}
|
title={config.siteTitle}
|
||||||
subtitle={config.siteSubtitle}
|
subtitle={config.siteSubtitle}
|
||||||
subpages={subpages}
|
links={links}
|
||||||
albums={albums}
|
navLabel={t.nav.heroNavAria}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Right Panel (Hero Carousel) ─────────────── */}
|
{/* ── Right Panel (Hero Carousel) ─────────────── */}
|
||||||
<div className="hero__right">
|
<div className="hero__right">
|
||||||
<HeroCarousel images={heroData} />
|
<HeroCarousel images={heroData} sizes="(max-width: 640px) 100vw, 50vw" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -11,14 +11,16 @@ import { albumMetaDetail } from '@/lib/albumMeta';
|
|||||||
import { AlbumNav } from '@/components/AlbumNav';
|
import { AlbumNav } from '@/components/AlbumNav';
|
||||||
import { StructuredData } from '@/components/StructuredData';
|
import { StructuredData } from '@/components/StructuredData';
|
||||||
import type { AlbumNavPair } from '@/lib/albumNav';
|
import type { AlbumNavPair } from '@/lib/albumNav';
|
||||||
|
import type { ProofSessionInit } from '@/components/ProofingContext';
|
||||||
|
|
||||||
interface AlbumDetailViewProps {
|
interface AlbumDetailViewProps {
|
||||||
album: ImmichAlbum;
|
album: ImmichAlbum;
|
||||||
images: PhotoItem[];
|
images: PhotoItem[];
|
||||||
layout: GridConfig['layout'];
|
layout: GridConfig['layout'];
|
||||||
gridStyle: React.CSSProperties;
|
gridStyle: React.CSSProperties;
|
||||||
backLinkHref: string;
|
/** Omitted on a client proofing link, which has nowhere to go back to. */
|
||||||
backLinkLabel: string;
|
backLinkHref?: string;
|
||||||
|
backLinkLabel?: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
heroImageUrl?: string;
|
heroImageUrl?: string;
|
||||||
heroBlurDataURL?: string;
|
heroBlurDataURL?: string;
|
||||||
@@ -31,6 +33,12 @@ interface AlbumDetailViewProps {
|
|||||||
proofing?: boolean;
|
proofing?: boolean;
|
||||||
/** Offer the "send by email" button in the proofing modal. */
|
/** Offer the "send by email" button in the proofing modal. */
|
||||||
allowMailto?: boolean;
|
allowMailto?: boolean;
|
||||||
|
/**
|
||||||
|
* `encodeEmail()` of where the proofing dialog's "email to photographer"
|
||||||
|
* goes, when one is configured (#736). Encoded, like every address the site
|
||||||
|
* renders, so the plain form stays out of the HTML and the RSC payload.
|
||||||
|
*/
|
||||||
|
encodedMailto?: string;
|
||||||
/**
|
/**
|
||||||
* The ZIP endpoint for this album, when it offers downloads (#475). Renders a
|
* The ZIP endpoint for this album, when it offers downloads (#475). Renders a
|
||||||
* "download album" link in the header and lets the proofing modal offer a
|
* "download album" link in the header and lets the proofing modal offer a
|
||||||
@@ -41,6 +49,10 @@ interface AlbumDetailViewProps {
|
|||||||
nav?: AlbumNavPair;
|
nav?: AlbumNavPair;
|
||||||
/** JSON-LD for this album, or null when no site URL is configured (#472). */
|
/** JSON-LD for this album, or null when no site URL is configured (#472). */
|
||||||
structuredData?: Record<string, unknown> | null;
|
structuredData?: Record<string, unknown> | null;
|
||||||
|
/** A client proofing link's session; see PhotoGrid. */
|
||||||
|
proofSession?: ProofSessionInit;
|
||||||
|
/** Rendered under the title — the proofing link's greeting and notes. */
|
||||||
|
intro?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AlbumDetailView({
|
export function AlbumDetailView({
|
||||||
@@ -58,9 +70,12 @@ export function AlbumDetailView({
|
|||||||
showGear = true,
|
showGear = true,
|
||||||
proofing,
|
proofing,
|
||||||
allowMailto,
|
allowMailto,
|
||||||
|
encodedMailto,
|
||||||
downloadArchiveUrl,
|
downloadArchiveUrl,
|
||||||
nav,
|
nav,
|
||||||
structuredData,
|
structuredData,
|
||||||
|
proofSession,
|
||||||
|
intro,
|
||||||
}: AlbumDetailViewProps) {
|
}: AlbumDetailViewProps) {
|
||||||
const metaDetail = albumMetaDetail(album, showGear);
|
const metaDetail = albumMetaDetail(album, showGear);
|
||||||
const t = getServerDictionary();
|
const t = getServerDictionary();
|
||||||
@@ -86,7 +101,7 @@ export function AlbumDetailView({
|
|||||||
)}
|
)}
|
||||||
<div className={`album-header${heroImageUrl ? ' album-header--has-hero' : ''}`}>
|
<div className={`album-header${heroImageUrl ? ' album-header--has-hero' : ''}`}>
|
||||||
<div className="album-header__main">
|
<div className="album-header__main">
|
||||||
<BackLink href={backLinkHref} label={backLinkLabel} />
|
{backLinkHref && <BackLink href={backLinkHref} label={backLinkLabel ?? ''} />}
|
||||||
<h1 className="album-header__title">{album.albumName}</h1>
|
<h1 className="album-header__title">{album.albumName}</h1>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<p className="subpage-subtitle" style={{ textAlign: 'left', marginLeft: 0 }}>
|
<p className="subpage-subtitle" style={{ textAlign: 'left', marginLeft: 0 }}>
|
||||||
@@ -94,6 +109,7 @@ export function AlbumDetailView({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{album.description && <p className="album-header__description">{album.description}</p>}
|
{album.description && <p className="album-header__description">{album.description}</p>}
|
||||||
|
{intro}
|
||||||
{downloadArchiveUrl && (
|
{downloadArchiveUrl && (
|
||||||
<a className="album-header__download" href={downloadArchiveUrl}>
|
<a className="album-header__download" href={downloadArchiveUrl}>
|
||||||
<svg
|
<svg
|
||||||
@@ -127,8 +143,10 @@ export function AlbumDetailView({
|
|||||||
showExifPanel={showExifPanel}
|
showExifPanel={showExifPanel}
|
||||||
proofing={proofing}
|
proofing={proofing}
|
||||||
allowMailto={allowMailto}
|
allowMailto={allowMailto}
|
||||||
|
encodedMailto={encodedMailto}
|
||||||
downloadArchiveUrl={downloadArchiveUrl}
|
downloadArchiveUrl={downloadArchiveUrl}
|
||||||
albumName={album.albumName}
|
albumName={album.albumName}
|
||||||
|
proofSession={proofSession}
|
||||||
/>
|
/>
|
||||||
{nav && <AlbumNav {...nav} />}
|
{nav && <AlbumNav {...nav} />}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,16 +2,32 @@
|
|||||||
|
|
||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
import { Lightbox, type LightboxWatermark } from '@/components/Lightbox';
|
import { Lightbox, type LightboxWatermark } from '@/components/Lightbox';
|
||||||
import { ProofingProvider, useProofing } from '@/components/ProofingContext';
|
import { useProofing } from '@/components/useProofing';
|
||||||
import { ProofingModal } from '@/components/ProofingModal';
|
import { ProofingProvider, ProofingModal } from '@/components/ProofingLazy';
|
||||||
import { FadeIn } from '@/components/FadeIn';
|
import { FadeIn } from '@/components/FadeIn';
|
||||||
import { LeafletMap, escapeHtml } from '@/components/LeafletMap';
|
import { escapeHtml } from '@/lib/escapeHtml';
|
||||||
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';
|
||||||
|
import { formatJournalDate, journalDateTime } from '@/lib/journalDate';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The map block is the only thing in an essay that needs Leaflet, and most
|
||||||
|
* essays have none. Imported statically, LeafletMap put its wrapper and
|
||||||
|
* Leaflet's stylesheet (render-blocking CSS) on every album, subpage and
|
||||||
|
* journal page. The map only ever drew on the client — Leaflet itself was
|
||||||
|
* already imported inside an effect — so the server renders the same empty
|
||||||
|
* container it rendered before, and the wrapper arrives with the map.
|
||||||
|
*/
|
||||||
|
const LeafletMap = dynamic(() => import('@/components/LeafletMap').then((m) => m.LeafletMap), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <div className="essay-map" />,
|
||||||
|
});
|
||||||
|
|
||||||
interface EssayViewProps {
|
interface EssayViewProps {
|
||||||
essay: ParsedEssay;
|
essay: ParsedEssay;
|
||||||
@@ -27,6 +43,8 @@ interface EssayViewProps {
|
|||||||
proofing?: boolean;
|
proofing?: boolean;
|
||||||
/** Offer the "send by email" button in the proofing modal. */
|
/** Offer the "send by email" button in the proofing modal. */
|
||||||
allowMailto?: boolean;
|
allowMailto?: boolean;
|
||||||
|
/** `encodeEmail()` of the proofing recipient, when one is configured (#736). */
|
||||||
|
encodedMailto?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function EssayViewContent({
|
function EssayViewContent({
|
||||||
@@ -60,19 +78,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,
|
||||||
@@ -111,7 +145,7 @@ function EssayViewContent({
|
|||||||
background: 'none',
|
background: 'none',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
color: isFav ? '#ff4d4f' : 'rgba(255,255,255,0.85)',
|
color: isFav ? 'var(--lightbox-fav)' : 'rgba(255,255,255,0.85)',
|
||||||
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.6))',
|
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.6))',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -166,7 +200,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 +208,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}` } : {}),
|
||||||
@@ -219,7 +253,7 @@ function EssayViewContent({
|
|||||||
background: 'none',
|
background: 'none',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
color: isFav ? '#ff4d4f' : 'rgba(255,255,255,0.85)',
|
color: isFav ? 'var(--lightbox-fav)' : 'rgba(255,255,255,0.85)',
|
||||||
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.6))',
|
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.6))',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -372,14 +406,22 @@ function EssayViewContent({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The story's photos that are selected, not the whole stored set (see PhotoGrid).
|
||||||
|
const selectedCount = proofing ? proofing.getSelectedTokens().length : 0;
|
||||||
|
|
||||||
|
const dateText = formatJournalDate(essay.frontmatter.date, t.dateLocale);
|
||||||
|
const dateTime = journalDateTime(essay.frontmatter.date);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="essay-container">
|
<div className="essay-container">
|
||||||
<header className="essay-header">
|
<header className="essay-header">
|
||||||
{displayTitle && <h1 className="essay-header__title">{displayTitle}</h1>}
|
{displayTitle && <h1 className="essay-header__title">{displayTitle}</h1>}
|
||||||
{displaySubtitle && <p className="essay-header__subtitle">{displaySubtitle}</p>}
|
{displaySubtitle && <p className="essay-header__subtitle">{displaySubtitle}</p>}
|
||||||
{(essay.frontmatter.author || essay.frontmatter.date) && (
|
{(essay.frontmatter.author || dateText) && (
|
||||||
<div className="essay-header__meta">
|
<div className="essay-header__meta">
|
||||||
{[essay.frontmatter.author, essay.frontmatter.date].filter(Boolean).join(' • ')}
|
{essay.frontmatter.author}
|
||||||
|
{essay.frontmatter.author && dateText && ' • '}
|
||||||
|
{dateText && (dateTime ? <time dateTime={dateTime}>{dateText}</time> : dateText)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
@@ -388,7 +430,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 }
|
||||||
@@ -412,9 +454,9 @@ function EssayViewContent({
|
|||||||
</figure>
|
</figure>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<main>{essay.blocks.map(renderBlock)}</main>
|
<div>{essay.blocks.map(renderBlock)}</div>
|
||||||
|
|
||||||
{proofing && proofing.favorites.size > 0 && (
|
{proofing && selectedCount > 0 && (
|
||||||
<div
|
<div
|
||||||
className="proofing-sticky-bar"
|
className="proofing-sticky-bar"
|
||||||
style={{
|
style={{
|
||||||
@@ -426,7 +468,7 @@ function EssayViewContent({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '12px',
|
gap: '12px',
|
||||||
padding: '8px 16px',
|
padding: '8px 16px',
|
||||||
borderRadius: '30px',
|
borderRadius: 'var(--radius-lg)',
|
||||||
background: 'var(--bg-card, #1e1e1e)',
|
background: 'var(--bg-card, #1e1e1e)',
|
||||||
color: 'var(--text-primary, #ffffff)',
|
color: 'var(--text-primary, #ffffff)',
|
||||||
border: '1px solid var(--border-subtle, rgba(255,255,255,0.15))',
|
border: '1px solid var(--border-subtle, rgba(255,255,255,0.15))',
|
||||||
@@ -434,26 +476,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(selectedCount)}
|
||||||
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 +503,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}
|
||||||
@@ -511,6 +538,7 @@ export function EssayView(props: EssayViewProps) {
|
|||||||
// default key with every album (and story) on the site.
|
// default key with every album (and story) on the site.
|
||||||
albumName={props.title || props.essay.frontmatter.title}
|
albumName={props.title || props.essay.frontmatter.title}
|
||||||
allowMailto={props.allowMailto ?? true}
|
allowMailto={props.allowMailto ?? true}
|
||||||
|
encodedMailto={props.encodedMailto}
|
||||||
>
|
>
|
||||||
<EssayViewContent {...props} />
|
<EssayViewContent {...props} />
|
||||||
</ProofingProvider>
|
</ProofingProvider>
|
||||||
|
|||||||
28
app/[...path]/PathSkeleton.tsx
Normal file
28
app/[...path]/PathSkeleton.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Placeholder for album and subpage content while Immich answers. These routes
|
||||||
|
* are force-dynamic and wait on a live Immich round-trip — up to
|
||||||
|
* IMMICH_TIMEOUT_MS when the server is struggling — so without this the viewer
|
||||||
|
* stares at a blank page.
|
||||||
|
*
|
||||||
|
* This used to be app/[...path]/loading.tsx. It is a Suspense fallback inside
|
||||||
|
* the page now, placed after the page has decided whether the path exists: a
|
||||||
|
* loading.tsx wraps the whole page, so the skeleton went out with a 200 before
|
||||||
|
* the page could call notFound().
|
||||||
|
*/
|
||||||
|
|
||||||
|
import styles from '../loading.module.css';
|
||||||
|
import { getServerDictionary } from '@/lib/i18n/server';
|
||||||
|
|
||||||
|
export function PathSkeleton() {
|
||||||
|
const t = getServerDictionary();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.heading} aria-hidden="true" />
|
||||||
|
<div className={styles.grid} role="status" aria-label={t.common.loadingPhotos}>
|
||||||
|
{Array.from({ length: 9 }).map((_, i) => (
|
||||||
|
<div key={i} className={styles.tile} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,10 +15,14 @@ import { useState, useCallback, useEffect, useMemo } from 'react';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { Lightbox, type LightboxWatermark } from '@/components/Lightbox';
|
import { Lightbox, type LightboxWatermark } from '@/components/Lightbox';
|
||||||
import { FadeIn } from '@/components/FadeIn';
|
import { FadeIn } from '@/components/FadeIn';
|
||||||
import { ProofingProvider, useProofing } from '@/components/ProofingContext';
|
import type { ProofSessionInit } from '@/components/ProofingContext';
|
||||||
import { ProofingModal } from '@/components/ProofingModal';
|
import { useProofing } from '@/components/useProofing';
|
||||||
|
import { ProofingProvider, ProofingModal, ProofSessionControls } from '@/components/ProofingLazy';
|
||||||
import { useDictionary } from '@/components/I18nProvider';
|
import { useDictionary } from '@/components/I18nProvider';
|
||||||
import { parsePhotoHash, parsePhotoQuery, buildPhotoQuery } from '@/lib/photoHash';
|
import { parsePhotoHash, parsePhotoQuery, buildPhotoQuery } from '@/lib/photoHash';
|
||||||
|
import { justifiedTileStyle } from '@/lib/justifiedRow';
|
||||||
|
|
||||||
|
const pad2 = (n: number) => String(n).padStart(2, '0');
|
||||||
|
|
||||||
export interface PhotoItem {
|
export interface PhotoItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -39,6 +43,14 @@ export interface PhotoItem {
|
|||||||
caption?: string;
|
caption?: string;
|
||||||
/** Present only when the surrounding album offers its originals (#475). */
|
/** Present only when the surrounding album offers its originals (#475). */
|
||||||
downloadUrl?: string;
|
downloadUrl?: string;
|
||||||
|
/**
|
||||||
|
* The full-resolution image for the lightbox zoom, fetched only when the
|
||||||
|
* visitor zooms (#467). Present only where zoom resolves on and the photo
|
||||||
|
* has a zoomable source; the size is the image's pixel size, upright.
|
||||||
|
*/
|
||||||
|
zoomUrl?: string;
|
||||||
|
zoomWidth?: number;
|
||||||
|
zoomHeight?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PhotoGridProps {
|
interface PhotoGridProps {
|
||||||
@@ -60,6 +72,12 @@ interface PhotoGridProps {
|
|||||||
proofing?: boolean;
|
proofing?: boolean;
|
||||||
/** Offer the "send by email" button in the proofing modal. */
|
/** Offer the "send by email" button in the proofing modal. */
|
||||||
allowMailto?: boolean;
|
allowMailto?: boolean;
|
||||||
|
/**
|
||||||
|
* `encodeEmail()` of where the proofing dialog's "email to photographer"
|
||||||
|
* goes, when one is configured (#736). Lets the modal address the compose;
|
||||||
|
* without it the button is hidden.
|
||||||
|
*/
|
||||||
|
encodedMailto?: string;
|
||||||
/**
|
/**
|
||||||
* The ZIP endpoint for this album, when it offers downloads. Lets the
|
* The ZIP endpoint for this album, when it offers downloads. Lets the
|
||||||
* proofing modal offer a "download selected" action (#475).
|
* proofing modal offer a "download selected" action (#475).
|
||||||
@@ -71,6 +89,11 @@ interface PhotoGridProps {
|
|||||||
* selection made in one album leaks into the next.
|
* selection made in one album leaks into the next.
|
||||||
*/
|
*/
|
||||||
albumName?: string;
|
albumName?: string;
|
||||||
|
/**
|
||||||
|
* A client proofing link's session (/proof/<token>). Turns proofing on and
|
||||||
|
* keeps the selection on the server instead of in the browser.
|
||||||
|
*/
|
||||||
|
proofSession?: ProofSessionInit;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PhotoGridInner({
|
function PhotoGridInner({
|
||||||
@@ -102,11 +125,25 @@ function PhotoGridInner({
|
|||||||
setLightboxIndex(byToken);
|
setLightboxIndex(byToken);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// A `photo` that names no photo in this album — mistyped, truncated by a
|
||||||
|
// messenger, or a position like `?photo=5` (that was never a format) —
|
||||||
|
// opens nothing, so it should not stay in the address bar to be copied
|
||||||
|
// and shared on. Checked against the whole album, not `displayedAssets`:
|
||||||
|
// a real photo that the "selected only" filter hides is still a valid link.
|
||||||
|
if (token !== null && !assets.some((a) => a.id === token)) {
|
||||||
|
window.history.replaceState(
|
||||||
|
null,
|
||||||
|
'',
|
||||||
|
window.location.pathname +
|
||||||
|
buildPhotoQuery(window.location.search, null) +
|
||||||
|
window.location.hash,
|
||||||
|
);
|
||||||
|
}
|
||||||
const idx = parsePhotoHash(window.location.hash);
|
const idx = parsePhotoHash(window.location.hash);
|
||||||
if (idx !== null && idx < displayedAssets.length) {
|
if (idx !== null && idx < displayedAssets.length) {
|
||||||
setLightboxIndex(idx);
|
setLightboxIndex(idx);
|
||||||
}
|
}
|
||||||
}, [displayedAssets]);
|
}, [assets, displayedAssets]);
|
||||||
|
|
||||||
// ── Sync the URL's `photo` param when the lightbox state changes ──
|
// ── Sync the URL's `photo` param when the lightbox state changes ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -124,6 +161,33 @@ function PhotoGridInner({
|
|||||||
}
|
}
|
||||||
}, [lightboxIndex, displayedAssets]);
|
}, [lightboxIndex, displayedAssets]);
|
||||||
|
|
||||||
|
// ── The photo on screen can leave the list ─────────────────────
|
||||||
|
// With "selected only" on, un-hearting the photo in the viewer drops it from
|
||||||
|
// `displayedAssets`. The viewer keeps its position, which now shows the next
|
||||||
|
// favourite; past the end it steps back to the last one, and with nothing
|
||||||
|
// left it closes. `shownIndex` keeps this render valid (it used to hand the
|
||||||
|
// viewer an index past the end, and the page crashed); the effect settles
|
||||||
|
// the state and the URL.
|
||||||
|
const shownIndex =
|
||||||
|
lightboxIndex === null || displayedAssets.length === 0
|
||||||
|
? null
|
||||||
|
: Math.min(lightboxIndex, displayedAssets.length - 1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (lightboxIndex === null || lightboxIndex < displayedAssets.length) return;
|
||||||
|
if (displayedAssets.length > 0) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setLightboxIndex(displayedAssets.length - 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLightboxIndex(null);
|
||||||
|
window.history.replaceState(
|
||||||
|
null,
|
||||||
|
'',
|
||||||
|
window.location.pathname + buildPhotoQuery(window.location.search, null),
|
||||||
|
);
|
||||||
|
}, [lightboxIndex, displayedAssets.length]);
|
||||||
|
|
||||||
// ── Listen for browser back/forward ────────────────────────────
|
// ── Listen for browser back/forward ────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePopState = () => {
|
const handlePopState = () => {
|
||||||
@@ -172,25 +236,31 @@ function PhotoGridInner({
|
|||||||
|
|
||||||
// Keyboard navigation and the scroll lock live in <Lightbox/>.
|
// Keyboard navigation and the scroll lock live in <Lightbox/>.
|
||||||
|
|
||||||
|
// A work's catalogue number on the wall label is its place in the album,
|
||||||
|
// not in the current view: the "selected only" filter must not renumber it.
|
||||||
|
const albumPosition = useMemo(() => new Map(assets.map((a, i) => [a.id, i + 1])), [assets]);
|
||||||
|
|
||||||
const gridItems = useMemo(() => {
|
const gridItems = useMemo(() => {
|
||||||
return displayedAssets.map((asset, index) => {
|
return displayedAssets.map((asset, index) => {
|
||||||
const isFav = proofing ? proofing.isFavorite(asset.id) : false;
|
const isFav = proofing ? proofing.isFavorite(asset.id) : false;
|
||||||
|
// A focal length alone is not a label, as before.
|
||||||
|
const exifLine =
|
||||||
|
asset.camera || asset.lens
|
||||||
|
? [asset.camera, asset.lens, asset.focalLength].filter(Boolean).join(' · ')
|
||||||
|
: '';
|
||||||
|
|
||||||
// Justified rows: the flex sizing must sit on the outermost grid child,
|
// Justified rows: the flex sizing must sit on the outermost grid child,
|
||||||
// which is the FadeIn wrapper — not the .photo-grid__item inside it.
|
// which is the FadeIn wrapper — not the .photo-grid__item inside it.
|
||||||
// Aspect ratio drives both grow factor and basis; ~3:2 fallback for
|
|
||||||
// assets without dimensions (e.g. videos) keeps the row math sane.
|
|
||||||
const justifiedAr = asset.aspectRatio || 1.5;
|
|
||||||
const justifiedStyle: React.CSSProperties | undefined =
|
const justifiedStyle: React.CSSProperties | undefined =
|
||||||
layout === 'justified'
|
layout === 'justified' ? justifiedTileStyle(asset.aspectRatio) : undefined;
|
||||||
? {
|
|
||||||
flexGrow: justifiedAr,
|
|
||||||
flexBasis: `calc(var(--grid-row-height, 300px) * ${justifiedAr})`,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FadeIn key={asset.id} delay={index < 12 ? index * 50 : 0} style={justifiedStyle}>
|
<FadeIn
|
||||||
|
key={asset.id}
|
||||||
|
as="figure"
|
||||||
|
delay={index < 12 ? index * 50 : 0}
|
||||||
|
style={justifiedStyle}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={`photo-grid__item${
|
className={`photo-grid__item${
|
||||||
layout === 'showcase' && index === 0 ? ' photo-grid__featured' : ''
|
layout === 'showcase' && index === 0 ? ' photo-grid__featured' : ''
|
||||||
@@ -204,26 +274,34 @@ function PhotoGridInner({
|
|||||||
}}
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
data-lightbox-index={index}
|
||||||
aria-label={t.lightbox.openPhoto(index + 1)}
|
aria-label={t.lightbox.openPhoto(index + 1)}
|
||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
style={{
|
style={{
|
||||||
...(asset.dominantColor ? { backgroundColor: asset.dominantColor } : {}),
|
|
||||||
...((layout === 'masonry' || layout === 'showcase') && asset.aspectRatio
|
...((layout === 'masonry' || layout === 'showcase') && asset.aspectRatio
|
||||||
? { aspectRatio: `${asset.aspectRatio}` }
|
? { aspectRatio: `${asset.aspectRatio}` }
|
||||||
: {}),
|
: {}),
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
{/* The photo's own box. Without a frame it covers the whole tile;
|
||||||
src={asset.thumbUrl}
|
under passepartout globals.css insets it by the mat, so `fill`
|
||||||
alt={asset.caption ?? ''}
|
and the placeholder colour stay on the photo, not on the mat. */}
|
||||||
fill
|
<div
|
||||||
sizes="(max-width: 600px) 50vw, (max-width: 1000px) 33vw, 25vw"
|
className="photo-grid__media"
|
||||||
loading={index < 6 ? 'eager' : 'lazy'}
|
style={asset.dominantColor ? { backgroundColor: asset.dominantColor } : undefined}
|
||||||
{...(asset.blurDataURL
|
>
|
||||||
? { placeholder: 'blur' as const, blurDataURL: asset.blurDataURL }
|
<Image
|
||||||
: {})}
|
src={asset.thumbUrl}
|
||||||
/>
|
alt={asset.caption ?? ''}
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 600px) 50vw, (max-width: 1000px) 33vw, 25vw"
|
||||||
|
loading={index < 6 ? 'eager' : 'lazy'}
|
||||||
|
{...(asset.blurDataURL
|
||||||
|
? { placeholder: 'blur' as const, blurDataURL: asset.blurDataURL }
|
||||||
|
: {})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{proofing && (
|
{proofing && (
|
||||||
<button
|
<button
|
||||||
@@ -248,7 +326,7 @@ function PhotoGridInner({
|
|||||||
background: 'none',
|
background: 'none',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
color: isFav ? '#ff4d4f' : 'rgba(255,255,255,0.85)',
|
color: isFav ? 'var(--lightbox-fav)' : 'rgba(255,255,255,0.85)',
|
||||||
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.6))',
|
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.6))',
|
||||||
padding: 0,
|
padding: 0,
|
||||||
}}
|
}}
|
||||||
@@ -274,24 +352,68 @@ function PhotoGridInner({
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(asset.camera || asset.lens) && (
|
{exifLine && (
|
||||||
<div className="photo-grid__item-exif" aria-hidden="true">
|
<div className="photo-grid__item-exif" aria-hidden="true">
|
||||||
{[asset.camera, asset.lens, asset.focalLength].filter(Boolean).join(' · ')}
|
{exifLine}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* The wall label under the photo, outside the tile and its clip.
|
||||||
|
globals.css hides it; a preset that hangs its photos as works
|
||||||
|
(kunsthalle) shows it. It carries only what the site already
|
||||||
|
publishes for the photo: its number, the caption that is the
|
||||||
|
image's alt text (absent unless the `caption` EXIF group is on),
|
||||||
|
and the EXIF line. The number and EXIF repeat the tile's own
|
||||||
|
label and the lightbox, so they stay out of the figure's name. */}
|
||||||
|
<figcaption className="photo-grid__label">
|
||||||
|
<span className="photo-grid__label-no" aria-hidden="true">
|
||||||
|
{pad2(albumPosition.get(asset.id) ?? index + 1)}
|
||||||
|
</span>
|
||||||
|
{asset.caption && <span className="photo-grid__label-title">{asset.caption}</span>}
|
||||||
|
{exifLine && (
|
||||||
|
<span className="photo-grid__label-exif" aria-hidden="true">
|
||||||
|
{exifLine}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</figcaption>
|
||||||
</FadeIn>
|
</FadeIn>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}, [displayedAssets, layout, openLightbox, proofing, t]);
|
}, [albumPosition, displayedAssets, layout, openLightbox, proofing, t]);
|
||||||
|
|
||||||
|
// Photos of this album that are selected. Not `favorites.size`: the stored set
|
||||||
|
// can also hold a photo since removed, or a favourite from another album whose
|
||||||
|
// name slugs the same way (they share `folio_fav_<name>`).
|
||||||
|
const selectedCount = proofing ? proofing.getSelectedTokens().length : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={`photo-grid photo-grid--${layout}`} style={gridStyle}>
|
{proofing?.isFilterActive && displayedAssets.length === 0 ? (
|
||||||
{gridItems}
|
// Un-hearting the last favourite with the filter on left a blank page
|
||||||
</div>
|
// between the header and the footer. Say why, and offer the way back
|
||||||
|
// here as well as in the bar in the corner.
|
||||||
|
<div className="empty-state" role="status">
|
||||||
|
<p className="empty-state__text">{t.proofing.filterEmpty}</p>
|
||||||
|
<div className="empty-state__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="empty-state__button"
|
||||||
|
onClick={() => proofing.setIsFilterActive(false)}
|
||||||
|
>
|
||||||
|
{t.proofing.showAll}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={`photo-grid photo-grid--${layout}`} style={gridStyle} data-lightbox-group>
|
||||||
|
{gridItems}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{proofing && proofing.favorites.size > 0 && (
|
{/* Kept while the filter is on: un-hearting the last favourite there
|
||||||
|
empties the grid, and the bar holds the only way back to all photos. */}
|
||||||
|
{proofing && !proofing.session && (selectedCount > 0 || proofing.isFilterActive) && (
|
||||||
<div
|
<div
|
||||||
className="proofing-sticky-bar"
|
className="proofing-sticky-bar"
|
||||||
style={{
|
style={{
|
||||||
@@ -303,7 +425,7 @@ function PhotoGridInner({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '12px',
|
gap: '12px',
|
||||||
padding: '8px 16px',
|
padding: '8px 16px',
|
||||||
borderRadius: '30px',
|
borderRadius: 'var(--radius-lg)',
|
||||||
background: 'var(--bg-card, #1e1e1e)',
|
background: 'var(--bg-card, #1e1e1e)',
|
||||||
color: 'var(--text-primary, #ffffff)',
|
color: 'var(--text-primary, #ffffff)',
|
||||||
border: '1px solid var(--border-subtle, rgba(255,255,255,0.15))',
|
border: '1px solid var(--border-subtle, rgba(255,255,255,0.15))',
|
||||||
@@ -318,18 +440,16 @@ 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: 'var(--radius-md)',
|
||||||
fontSize: '0.85rem',
|
fontSize: '0.85rem',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{proofing.isFilterActive
|
{proofing.isFilterActive ? t.proofing.showAll : t.proofing.selected(selectedCount)}
|
||||||
? t.proofing.showAll
|
|
||||||
: t.proofing.selected(proofing.favorites.size)}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -349,12 +469,13 @@ function PhotoGridInner({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{proofing && <ProofingModal />}
|
{proofing && !proofing.session && <ProofingModal />}
|
||||||
|
{proofing?.session && <ProofSessionControls />}
|
||||||
|
|
||||||
{lightboxIndex !== null && (
|
{shownIndex !== null && (
|
||||||
<Lightbox
|
<Lightbox
|
||||||
assets={displayedAssets}
|
assets={displayedAssets}
|
||||||
currentIndex={lightboxIndex}
|
currentIndex={shownIndex}
|
||||||
onClose={closeLightbox}
|
onClose={closeLightbox}
|
||||||
onNext={goNext}
|
onNext={goNext}
|
||||||
onPrev={goPrev}
|
onPrev={goPrev}
|
||||||
@@ -372,7 +493,7 @@ export function PhotoGrid(props: PhotoGridProps) {
|
|||||||
// Without the provider useProofing() returns null, and every proofing control
|
// Without the provider useProofing() returns null, and every proofing control
|
||||||
// (hearts in the grid and in the lightbox, selection bar, modal) drops out on
|
// (hearts in the grid and in the lightbox, selection bar, modal) drops out on
|
||||||
// its own.
|
// its own.
|
||||||
if (!props.proofing) {
|
if (!props.proofing && !props.proofSession) {
|
||||||
return <PhotoGridInner {...props} />;
|
return <PhotoGridInner {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,7 +502,9 @@ export function PhotoGrid(props: PhotoGridProps) {
|
|||||||
albumTokens={albumTokens}
|
albumTokens={albumTokens}
|
||||||
albumName={props.albumName}
|
albumName={props.albumName}
|
||||||
allowMailto={props.allowMailto ?? true}
|
allowMailto={props.allowMailto ?? true}
|
||||||
|
encodedMailto={props.encodedMailto}
|
||||||
downloadArchiveUrl={props.downloadArchiveUrl}
|
downloadArchiveUrl={props.downloadArchiveUrl}
|
||||||
|
session={props.proofSession}
|
||||||
>
|
>
|
||||||
<PhotoGridInner {...props} />
|
<PhotoGridInner {...props} />
|
||||||
</ProofingProvider>
|
</ProofingProvider>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { imageUrl } from '@/lib/urls';
|
import { imageUrl, type ImageRef } from '@/lib/urls';
|
||||||
import { SubpageSectionConfig } from '@/lib/config';
|
import { SubpageSectionConfig } from '@/lib/config';
|
||||||
import { getServerDictionary } from '@/lib/i18n/server';
|
import { getServerDictionary } from '@/lib/i18n/server';
|
||||||
import { BackLink } from '@/components/BackLink';
|
import { BackLink } from '@/components/BackLink';
|
||||||
@@ -11,6 +11,8 @@ interface SubpageAlbum {
|
|||||||
albumName: string;
|
albumName: string;
|
||||||
assetCount: number;
|
assetCount: number;
|
||||||
albumThumbnailAssetId: string | null;
|
albumThumbnailAssetId: string | null;
|
||||||
|
/** The cover asset as Immich has it, so an edited cover gets its edit marker (#831). */
|
||||||
|
cover?: ImageRef;
|
||||||
/** EXPERIMENTAL: focal point for the cover crop (CSS object-position) */
|
/** EXPERIMENTAL: focal point for the cover crop (CSS object-position) */
|
||||||
coverPosition?: string;
|
coverPosition?: string;
|
||||||
}
|
}
|
||||||
@@ -73,7 +75,7 @@ function AlbumGrid({
|
|||||||
>
|
>
|
||||||
{album.albumThumbnailAssetId ? (
|
{album.albumThumbnailAssetId ? (
|
||||||
<Image
|
<Image
|
||||||
src={imageUrl(album.albumThumbnailAssetId, 'preview')}
|
src={imageUrl(album.cover ?? album.albumThumbnailAssetId, 'preview')}
|
||||||
alt=""
|
alt=""
|
||||||
fill
|
fill
|
||||||
sizes="(max-width: 600px) 100vw, (max-width: 1000px) 50vw, 33vw"
|
sizes="(max-width: 600px) 100vw, (max-width: 1000px) 50vw, 33vw"
|
||||||
|
|||||||
85
app/[...path]/__tests__/lightbox-filtered-unheart.test.tsx
Normal file
85
app/[...path]/__tests__/lightbox-filtered-unheart.test.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { render, screen, cleanup, fireEvent, within } from '@testing-library/react';
|
||||||
|
import { PhotoGrid, type PhotoItem } from '../PhotoGrid';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* With "show selected" on, the lightbox walks the favourites only. Un-hearting
|
||||||
|
* the photo on screen takes it out of that list while the viewer is open: on
|
||||||
|
* the last favourite the index pointed past the end and the viewer crashed the
|
||||||
|
* page (`current` undefined), and on the only one the list was empty.
|
||||||
|
*/
|
||||||
|
|
||||||
|
vi.mock('next/image', () => ({
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
default: ({ alt }: { alt?: string }) => <img alt={alt ?? ''} />,
|
||||||
|
}));
|
||||||
|
vi.mock('@/components/FadeIn', () => ({
|
||||||
|
FadeIn: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const photo = (id: string): PhotoItem => ({
|
||||||
|
id,
|
||||||
|
type: 'image',
|
||||||
|
thumbUrl: `/t/${id}`,
|
||||||
|
previewUrl: `/p/${id}`,
|
||||||
|
exifUrl: `/e/${id}`,
|
||||||
|
aspectRatio: 1.5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ASSETS = [photo('a1'), photo('a2'), photo('a3')];
|
||||||
|
|
||||||
|
let store: Map<string, string>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Node 26 defines its own global localStorage (undefined without
|
||||||
|
// --localstorage-file), which shadows jsdom's; give the test a working one.
|
||||||
|
store = new Map<string, string>();
|
||||||
|
vi.stubGlobal('localStorage', {
|
||||||
|
getItem: (k: string) => store.get(k) ?? null,
|
||||||
|
setItem: (k: string, v: string) => void store.set(k, String(v)),
|
||||||
|
removeItem: (k: string) => void store.delete(k),
|
||||||
|
clear: () => store.clear(),
|
||||||
|
});
|
||||||
|
window.history.replaceState(null, '', '/wedding');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openFilteredLightboxOn(n: number) {
|
||||||
|
render(<PhotoGrid assets={ASSETS} proofing albumName="Wedding" />);
|
||||||
|
fireEvent.click(await screen.findByText(/❤️ \d Selected/));
|
||||||
|
fireEvent.click(screen.getByLabelText(`View photo ${n}`));
|
||||||
|
return screen.getByRole('dialog');
|
||||||
|
}
|
||||||
|
|
||||||
|
const shownPhoto = (dialog: HTMLElement) =>
|
||||||
|
dialog.querySelector('img')?.getAttribute('src')?.split('?')[0];
|
||||||
|
|
||||||
|
describe('un-hearting inside the filtered lightbox', () => {
|
||||||
|
it('moves to the previous favourite when the last one is removed', async () => {
|
||||||
|
store.set('folio_fav_wedding', JSON.stringify(['a1', 'a3']));
|
||||||
|
const dialog = await openFilteredLightboxOn(2);
|
||||||
|
expect(shownPhoto(dialog)).toBe('/p/a3');
|
||||||
|
|
||||||
|
fireEvent.click(within(dialog).getByLabelText('Remove from favorites'));
|
||||||
|
|
||||||
|
const after = screen.getByRole('dialog');
|
||||||
|
expect(shownPhoto(after)).toBe('/p/a1');
|
||||||
|
expect(within(after).getByText('1 / 1')).toBeTruthy();
|
||||||
|
expect(new URLSearchParams(window.location.search).get('photo')).toBe('a1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes the viewer when the only favourite is removed', async () => {
|
||||||
|
store.set('folio_fav_wedding', JSON.stringify(['a2']));
|
||||||
|
const dialog = await openFilteredLightboxOn(1);
|
||||||
|
|
||||||
|
fireEvent.click(within(dialog).getByLabelText('Remove from favorites'));
|
||||||
|
|
||||||
|
expect(screen.queryByRole('dialog')).toBeNull();
|
||||||
|
expect(new URLSearchParams(window.location.search).get('photo')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
61
app/[...path]/__tests__/loadAlbumBlocks.test.ts
Normal file
61
app/[...path]/__tests__/loadAlbumBlocks.test.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import type { JournalBlock } from '@/lib/journal';
|
||||||
|
|
||||||
|
const getAlbumAssetsRaw = vi.fn();
|
||||||
|
vi.mock('@/lib/immich', () => ({
|
||||||
|
immich: {
|
||||||
|
get getAlbumAssetsRaw() {
|
||||||
|
return getAlbumAssetsRaw;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { loadAlbumBlocks } from '../essayPayload';
|
||||||
|
|
||||||
|
const album = (albumId: string) => ({ type: 'album', albumId, line: 0 }) as unknown as JournalBlock;
|
||||||
|
const photo = { type: 'photo', line: 0 } as unknown as JournalBlock;
|
||||||
|
|
||||||
|
describe('loadAlbumBlocks', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
getAlbumAssetsRaw.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
// It used to await each album before asking for the next, so a page with
|
||||||
|
// three album blocks waited for three Immich round trips in a row.
|
||||||
|
it('asks for every album before any answer arrives', async () => {
|
||||||
|
const pending: Array<() => void> = [];
|
||||||
|
getAlbumAssetsRaw.mockImplementation(
|
||||||
|
(id: string) =>
|
||||||
|
new Promise((resolve) => pending.push(() => resolve([{ id: `${id}-asset` }]))),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = loadAlbumBlocks([album('a'), photo, album('b'), album('c')], () => {});
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(getAlbumAssetsRaw).toHaveBeenCalledTimes(3);
|
||||||
|
pending.forEach((resolve) => resolve());
|
||||||
|
expect([...(await result).keys()]).toEqual(['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads an album named by two blocks once', async () => {
|
||||||
|
getAlbumAssetsRaw.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await loadAlbumBlocks([album('a'), album('a')], () => {});
|
||||||
|
|
||||||
|
expect(getAlbumAssetsRaw).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves out an album that fails and reports it, keeping the rest', async () => {
|
||||||
|
const failure = new Error('immich down');
|
||||||
|
getAlbumAssetsRaw.mockImplementation(async (id: string) => {
|
||||||
|
if (id === 'b') throw failure;
|
||||||
|
return [{ id: `${id}-asset` }];
|
||||||
|
});
|
||||||
|
const onError = vi.fn();
|
||||||
|
|
||||||
|
const result = await loadAlbumBlocks([album('a'), album('b'), album('c')], onError);
|
||||||
|
|
||||||
|
expect([...result.keys()]).toEqual(['a', 'c']);
|
||||||
|
expect(onError).toHaveBeenCalledWith('b', failure);
|
||||||
|
});
|
||||||
|
});
|
||||||
161
app/[...path]/__tests__/metadata-title.test.ts
Normal file
161
app/[...path]/__tests__/metadata-title.test.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* generateMetadata runs for any URL a visitor types. It used to start from
|
||||||
|
* `title = slug` and keep that whenever nothing matched, so an unknown path,
|
||||||
|
* an offline subpage or a locked album put the requested slug in the
|
||||||
|
* `<title>` and in a validly signed share-card URL. Every branch that finds
|
||||||
|
* nothing — or finds it locked — now answers with a dictionary title and no
|
||||||
|
* card of its own.
|
||||||
|
*/
|
||||||
|
|
||||||
|
vi.mock('next/navigation', () => ({
|
||||||
|
notFound: () => {
|
||||||
|
throw new Error('NEXT_NOT_FOUND');
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock('next/headers', () => ({
|
||||||
|
cookies: async () => ({ get: () => undefined }),
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/admin/auth', () => ({ isAdminAuthenticated: async () => false }));
|
||||||
|
vi.mock('@/lib/i18n/server', async () => {
|
||||||
|
const { getDictionary } = await import('@/lib/i18n');
|
||||||
|
return { getServerDictionary: () => getDictionary('en') };
|
||||||
|
});
|
||||||
|
vi.mock('@/lib/ogImage', () => ({
|
||||||
|
ogImageUrl: (title: string, subtitle = '') =>
|
||||||
|
`/api/og?title=${encodeURIComponent(title)}&subtitle=${encodeURIComponent(subtitle)}&sig=x`,
|
||||||
|
}));
|
||||||
|
vi.mock('../contentPage', () => ({
|
||||||
|
contentPageMetadata: vi.fn(async () => null),
|
||||||
|
renderContentPage: vi.fn(async () => null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const locked = vi.hoisted(() => ({ keys: new Set<string>() }));
|
||||||
|
vi.mock('@/lib/auth', () => ({
|
||||||
|
isProtected: (key: string) => locked.keys.has(key),
|
||||||
|
isAuthenticated: () => false,
|
||||||
|
withoutLockedAlbums: <T>(albums: T[]) => albums,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const immich = vi.hoisted(() => ({
|
||||||
|
isSubpageSlug: vi.fn((slug: string) => slug === 'travel' || slug === 'solo'),
|
||||||
|
getSubpageAlbums: vi.fn(),
|
||||||
|
getAlbumBySlug: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/immich', () => ({ immich }));
|
||||||
|
|
||||||
|
const ICELAND = {
|
||||||
|
id: 'album-1',
|
||||||
|
albumName: 'Iceland',
|
||||||
|
slug: 'iceland',
|
||||||
|
assets: [{ id: 'a1', type: 'IMAGE' }],
|
||||||
|
albumThumbnailAssetId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
import { generateMetadata } from '../page';
|
||||||
|
import { getDictionary } from '@/lib/i18n';
|
||||||
|
|
||||||
|
const en = getDictionary('en');
|
||||||
|
|
||||||
|
const meta = (path: string[]) =>
|
||||||
|
generateMetadata({ params: Promise.resolve({ path }), searchParams: Promise.resolve({}) });
|
||||||
|
|
||||||
|
/** Every string the metadata would put in the page head or a share URL. */
|
||||||
|
function emitted(m: Metadata): string {
|
||||||
|
return JSON.stringify(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
locked.keys.clear();
|
||||||
|
immich.getAlbumBySlug.mockImplementation(async (slug: string) =>
|
||||||
|
slug === 'iceland' ? ICELAND : null,
|
||||||
|
);
|
||||||
|
immich.getSubpageAlbums.mockImplementation(async (slug: string) => {
|
||||||
|
if (slug === 'travel') {
|
||||||
|
return {
|
||||||
|
subpage: { slug: 'travel', name: 'travel', title: 'Travel', albumIds: ['album-1', 'b'] },
|
||||||
|
albums: [ICELAND, { ...ICELAND, id: 'b', slug: 'norway', albumName: 'Norway' }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (slug === 'solo') {
|
||||||
|
return { subpage: { slug: 'solo', name: 'solo', albumIds: ['album-1'] }, albums: [ICELAND] };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('page metadata never echoes the requested path', () => {
|
||||||
|
it.each([
|
||||||
|
['an unknown slug', ['unknown-visitor-text']],
|
||||||
|
['an unknown album under a real subpage', ['travel', 'nope']],
|
||||||
|
['a three-segment path', ['iceland', 'x', 'y']],
|
||||||
|
])('gives %s the generic not-found title and no card', async (_, path) => {
|
||||||
|
const m = await meta(path);
|
||||||
|
|
||||||
|
expect(m.title).toBe(en.error.notFoundTitle);
|
||||||
|
expect(m.openGraph).toBeUndefined();
|
||||||
|
expect(m.twitter).toBeUndefined();
|
||||||
|
for (const segment of path) expect(emitted(m)).not.toContain(encodeURIComponent(segment));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the layout robots tag on a not-found path, leaving only Next’s noindex', async () => {
|
||||||
|
const m = await meta(['nope']);
|
||||||
|
expect(m.robots).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an offline subpage like any unknown slug', async () => {
|
||||||
|
// isSubpageSlug is false for `enabled: false`, and no album has the slug.
|
||||||
|
const m = await meta(['dubai']);
|
||||||
|
expect(m.title).toBe(en.error.notFoundTitle);
|
||||||
|
expect(emitted(m)).not.toContain('dubai');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives a locked standalone album the generic protected title', async () => {
|
||||||
|
locked.keys.add('album-1');
|
||||||
|
const m = await meta(['iceland']);
|
||||||
|
|
||||||
|
expect(m.title).toBe(en.password.protectedPage);
|
||||||
|
expect(emitted(m)).not.toContain('iceland');
|
||||||
|
expect(emitted(m)).not.toContain('Iceland');
|
||||||
|
expect(m.openGraph).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives a locked multi-album subpage the protected title, not its slug', async () => {
|
||||||
|
locked.keys.add('travel');
|
||||||
|
const m = await meta(['travel']);
|
||||||
|
|
||||||
|
expect(m.title).toBe(en.password.protectedPage);
|
||||||
|
expect(emitted(m)).not.toContain('travel');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives an album under a locked subpage the protected title', async () => {
|
||||||
|
locked.keys.add('travel');
|
||||||
|
const m = await meta(['travel', 'iceland']);
|
||||||
|
|
||||||
|
expect(m.title).toBe(en.password.protectedPage);
|
||||||
|
expect(emitted(m)).not.toContain('Iceland');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives a locked album on a single-album subpage the protected title', async () => {
|
||||||
|
locked.keys.add('album-1');
|
||||||
|
const m = await meta(['solo']);
|
||||||
|
|
||||||
|
expect(m.title).toBe(en.password.protectedPage);
|
||||||
|
expect(emitted(m)).not.toContain('solo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still describes published content by its own name', async () => {
|
||||||
|
const album = await meta(['iceland']);
|
||||||
|
expect(album.title).toBe('Iceland');
|
||||||
|
expect(emitted(album)).toContain(encodeURIComponent('Iceland'));
|
||||||
|
|
||||||
|
const subpage = await meta(['travel']);
|
||||||
|
expect(subpage.title).toBe('Travel');
|
||||||
|
|
||||||
|
const nested = await meta(['travel', 'iceland']);
|
||||||
|
expect(nested.title).toBe('Iceland');
|
||||||
|
});
|
||||||
|
});
|
||||||
181
app/[...path]/__tests__/not-found-status.test.ts
Normal file
181
app/[...path]/__tests__/not-found-status.test.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { Suspense, isValidElement, type ReactElement } from 'react';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Next sends the status with the first byte, and the first byte goes out as
|
||||||
|
* soon as a Suspense fallback renders. The catch-all used to sit inside
|
||||||
|
* loading.tsx boundaries (app/ and app/[...path]/), so its skeleton streamed
|
||||||
|
* as a 200 before the page could call notFound(): `/nope`, an offline subpage,
|
||||||
|
* a draft content page and `/<subpage>/<unknown>` were all soft 404s — the
|
||||||
|
* not-found page with a 200.
|
||||||
|
*
|
||||||
|
* The page now decides whether the path exists before it renders a boundary,
|
||||||
|
* and only then streams the skeleton around the part that fetches photos.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const NOT_FOUND = new Error('NEXT_NOT_FOUND');
|
||||||
|
|
||||||
|
vi.mock('next/navigation', () => ({
|
||||||
|
notFound: () => {
|
||||||
|
throw NOT_FOUND;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock('next/headers', () => ({
|
||||||
|
cookies: async () => ({ get: () => undefined }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const admin = vi.hoisted(() => ({ signedIn: false }));
|
||||||
|
vi.mock('@/lib/admin/auth', () => ({ isAdminAuthenticated: async () => admin.signedIn }));
|
||||||
|
vi.mock('@/lib/i18n/server', async () => {
|
||||||
|
const { getDictionary } = await import('@/lib/i18n');
|
||||||
|
return { getServerDictionary: () => getDictionary('en') };
|
||||||
|
});
|
||||||
|
|
||||||
|
const locked = vi.hoisted(() => ({ keys: new Set<string>() }));
|
||||||
|
vi.mock('@/lib/auth', () => ({
|
||||||
|
isProtected: (key: string) => locked.keys.has(key),
|
||||||
|
isAuthenticated: () => false,
|
||||||
|
withoutLockedAlbums: <T>(albums: T[]) => albums,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pages = vi.hoisted(() => ({
|
||||||
|
access: new Map<string, 'open' | 'draft' | 'locked'>(),
|
||||||
|
}));
|
||||||
|
vi.mock('../contentPage', () => ({
|
||||||
|
contentPageAccess: vi.fn(async (slug: string) => pages.access.get(slug) ?? null),
|
||||||
|
contentPageMetadata: vi.fn(async () => null),
|
||||||
|
renderContentPage: vi.fn(async () => null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const ICELAND = {
|
||||||
|
id: 'album-1',
|
||||||
|
albumName: 'Iceland',
|
||||||
|
slug: 'iceland',
|
||||||
|
assets: [],
|
||||||
|
albumThumbnailAssetId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const immich = vi.hoisted(() => ({
|
||||||
|
isSubpageSlug: vi.fn(),
|
||||||
|
getSubpageAlbums: vi.fn(),
|
||||||
|
findAlbumBySlug: vi.fn(),
|
||||||
|
getAlbumBySlug: vi.fn(),
|
||||||
|
getStandaloneAlbums: vi.fn(async () => []),
|
||||||
|
getSubpages: vi.fn(async () => []),
|
||||||
|
getAssetInfo: vi.fn(async () => null),
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/immich', () => ({ immich }));
|
||||||
|
|
||||||
|
import PathPage from '../page';
|
||||||
|
import { PathSkeleton } from '../PathSkeleton';
|
||||||
|
|
||||||
|
const call = (segments: string[]) => ({
|
||||||
|
params: Promise.resolve({ path: segments }),
|
||||||
|
searchParams: Promise.resolve({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
admin.signedIn = false;
|
||||||
|
locked.keys.clear();
|
||||||
|
pages.access = new Map([
|
||||||
|
['about-us', 'open'],
|
||||||
|
['pricing', 'draft'],
|
||||||
|
['members', 'locked'],
|
||||||
|
]);
|
||||||
|
// `dubai` is `enabled: false`: isSubpageSlug() and every lookup skip it.
|
||||||
|
immich.isSubpageSlug.mockImplementation((slug: string) =>
|
||||||
|
['travel', 'vault', 'empty'].includes(slug),
|
||||||
|
);
|
||||||
|
immich.getSubpageAlbums.mockImplementation(async (slug: string) => {
|
||||||
|
if (slug === 'empty') return { subpage: { slug, name: 'Empty', albumIds: [] }, albums: [] };
|
||||||
|
if (slug === 'travel' || slug === 'vault') {
|
||||||
|
return { subpage: { slug, name: slug, albumIds: ['album-1'] }, albums: [ICELAND] };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
immich.findAlbumBySlug.mockImplementation(async (slug: string, subpage?: string) =>
|
||||||
|
slug === 'iceland' && (subpage === undefined || subpage === 'travel' || subpage === 'vault')
|
||||||
|
? ICELAND
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
immich.getAlbumBySlug.mockResolvedValue(ICELAND);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('catch-all not-found status', () => {
|
||||||
|
it.each([
|
||||||
|
['an unknown slug', ['nope']],
|
||||||
|
['a long made-up slug', ['this-is-a-long-made-up-slug-that-names-nothing-at-all-0123456789']],
|
||||||
|
['a disabled subpage', ['dubai']],
|
||||||
|
['a draft content page', ['pricing']],
|
||||||
|
['a subpage without published albums', ['empty']],
|
||||||
|
['an unknown album under a real subpage', ['travel', 'nope']],
|
||||||
|
['an album under a disabled subpage', ['dubai', 'iceland']],
|
||||||
|
['a three-segment path', ['x', 'y', 'z']],
|
||||||
|
])('calls notFound() for %s before anything can stream', async (_, segments) => {
|
||||||
|
await expect(PathPage(call(segments))).rejects.toBe(NOT_FOUND);
|
||||||
|
// No photo was fetched: the decision came from the album list alone.
|
||||||
|
expect(immich.getAlbumBySlug).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['a subpage', ['travel']],
|
||||||
|
['an album under a subpage', ['travel', 'iceland']],
|
||||||
|
['a standalone album', ['iceland']],
|
||||||
|
['a content page', ['about-us']],
|
||||||
|
['a locked content page (its gate renders)', ['members']],
|
||||||
|
])('streams %s behind the skeleton, deciding first', async (_, segments) => {
|
||||||
|
const element = (await PathPage(call(segments))) as ReactElement<{ fallback: ReactElement }>;
|
||||||
|
|
||||||
|
expect(isValidElement(element)).toBe(true);
|
||||||
|
expect(element.type).toBe(Suspense);
|
||||||
|
expect(element.props.fallback.type).toBe(PathSkeleton);
|
||||||
|
// The asset fetch — the slow part — happens inside the boundary.
|
||||||
|
expect(immich.getAlbumBySlug).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not tell a visitor without the password which albums a locked subpage holds', async () => {
|
||||||
|
locked.keys.add('vault');
|
||||||
|
|
||||||
|
const known = await PathPage(call(['vault', 'iceland']));
|
||||||
|
const unknown = await PathPage(call(['vault', 'no-such-album']));
|
||||||
|
|
||||||
|
expect((known as ReactElement).type).toBe(Suspense);
|
||||||
|
expect((unknown as ReactElement).type).toBe(Suspense);
|
||||||
|
expect(immich.findAlbumBySlug).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a locked subpage show its gate even before its albums are known', async () => {
|
||||||
|
locked.keys.add('empty');
|
||||||
|
const element = await PathPage(call(['empty']));
|
||||||
|
expect((element as ReactElement).type).toBe(Suspense);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a signed-in admin the diagnostic banner instead of the 404', async () => {
|
||||||
|
admin.signedIn = true;
|
||||||
|
const element = await PathPage(call(['nope']));
|
||||||
|
expect((element as ReactElement).type).toBe(Suspense);
|
||||||
|
// ...but never for a path deeper than the catch-all serves.
|
||||||
|
await expect(PathPage(call(['x', 'y', 'z']))).rejects.toBe(NOT_FOUND);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces an Immich outage as an error, never as a missing album', async () => {
|
||||||
|
const outage = new Error('Immich unavailable');
|
||||||
|
immich.findAlbumBySlug.mockRejectedValue(outage);
|
||||||
|
|
||||||
|
await expect(PathPage(call(['iceland']))).rejects.toBe(outage);
|
||||||
|
await expect(PathPage(call(['travel', 'iceland']))).rejects.toBe(outage);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no loading.tsx above it, which would stream the 200 first', () => {
|
||||||
|
// A loading file wraps every route below its folder in a Suspense
|
||||||
|
// boundary, and nothing the page does inside one can change the status.
|
||||||
|
const appDir = path.join(process.cwd(), 'app');
|
||||||
|
for (const dir of [appDir, path.join(appDir, '[...path]')]) {
|
||||||
|
const loading = fs.readdirSync(dir).filter((name) => /^loading\.(t|j)sx?$/.test(name));
|
||||||
|
expect(loading, dir).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
88
app/[...path]/__tests__/path-depth.test.ts
Normal file
88
app/[...path]/__tests__/path-depth.test.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The catch-all answers one segment (subpage, content page, standalone album)
|
||||||
|
* and two (subpage/album). A third segment used to fall through to the
|
||||||
|
* one-segment branch, which only reads `path[0]`: `/travel/iceland/anything`
|
||||||
|
* rendered the Travel subpage and `/iceland/x/y` the Iceland album, each with
|
||||||
|
* a 200, under an endless supply of URLs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const NOT_FOUND = new Error('NEXT_NOT_FOUND');
|
||||||
|
|
||||||
|
vi.mock('next/navigation', () => ({
|
||||||
|
notFound: () => {
|
||||||
|
throw NOT_FOUND;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock('next/headers', () => ({
|
||||||
|
cookies: async () => ({ get: () => undefined }),
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/admin/auth', () => ({ isAdminAuthenticated: async () => false }));
|
||||||
|
|
||||||
|
const immich = vi.hoisted(() => ({
|
||||||
|
isSubpageSlug: vi.fn((slug: string) => slug === 'travel'),
|
||||||
|
getSubpageAlbums: vi.fn(),
|
||||||
|
getAlbumBySlug: vi.fn(),
|
||||||
|
findAlbumBySlug: vi.fn(),
|
||||||
|
getStandaloneAlbums: vi.fn(async () => []),
|
||||||
|
getSubpages: vi.fn(async () => []),
|
||||||
|
getAssetInfo: vi.fn(async () => null),
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/immich', () => ({ immich }));
|
||||||
|
|
||||||
|
const ALBUM = {
|
||||||
|
id: 'album-1',
|
||||||
|
albumName: 'Iceland',
|
||||||
|
slug: 'iceland',
|
||||||
|
assets: [],
|
||||||
|
albumThumbnailAssetId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
import PathPage, { generateMetadata } from '../page';
|
||||||
|
import { getServerDictionary } from '@/lib/i18n/server';
|
||||||
|
|
||||||
|
const call = (path: string[]) => ({
|
||||||
|
params: Promise.resolve({ path }),
|
||||||
|
searchParams: Promise.resolve({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
immich.getAlbumBySlug.mockResolvedValue(ALBUM);
|
||||||
|
immich.findAlbumBySlug.mockResolvedValue(ALBUM);
|
||||||
|
immich.getSubpageAlbums.mockResolvedValue({
|
||||||
|
subpage: { slug: 'travel', name: 'Travel', albumIds: ['album-1', 'album-2'] },
|
||||||
|
albums: [ALBUM, { ...ALBUM, id: 'album-2', slug: 'norway', albumName: 'Norway' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('catch-all path depth', () => {
|
||||||
|
it.each([[['iceland', 'x', 'y']], [['travel', 'iceland', 'extra']], [['travel', 'a', 'b', 'c']]])(
|
||||||
|
'answers %j with a 404',
|
||||||
|
async (path) => {
|
||||||
|
await expect(PathPage(call(path))).rejects.toBe(NOT_FOUND);
|
||||||
|
expect(immich.getAlbumBySlug).not.toHaveBeenCalled();
|
||||||
|
expect(immich.findAlbumBySlug).not.toHaveBeenCalled();
|
||||||
|
expect(immich.getSubpageAlbums).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('gives a three-segment path no album metadata, only the not-found title', async () => {
|
||||||
|
const meta = await generateMetadata(call(['iceland', 'x', 'y']));
|
||||||
|
expect(meta.title).toBe(getServerDictionary().error.notFoundTitle);
|
||||||
|
expect(immich.getAlbumBySlug).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still renders one and two segments', async () => {
|
||||||
|
for (const path of [['iceland'], ['travel', 'iceland']]) {
|
||||||
|
// The page decides, then hands the rendering to the content inside its
|
||||||
|
// Suspense boundary; run that too.
|
||||||
|
const shell = (await PathPage(call(path))) as ReactElement<{ children: ReactElement }>;
|
||||||
|
const content = shell.props.children as ReactElement<object>;
|
||||||
|
const render = content.type as (props: object) => Promise<unknown>;
|
||||||
|
await expect(render(content.props)).resolves.toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
73
app/[...path]/__tests__/photo-deeplink-invalid.test.tsx
Normal file
73
app/[...path]/__tests__/photo-deeplink-invalid.test.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { render, screen, cleanup } from '@testing-library/react';
|
||||||
|
import { PhotoGrid, type PhotoItem } from '../PhotoGrid';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `?photo=` that names no photo in the album opened nothing and stayed in
|
||||||
|
* the address bar, to be copied and shared on (P-16). It is removed now; a
|
||||||
|
* valid deep link still opens its photo.
|
||||||
|
*/
|
||||||
|
|
||||||
|
vi.mock('next/image', () => ({
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
default: ({ alt }: { alt?: string }) => <img alt={alt ?? ''} />,
|
||||||
|
}));
|
||||||
|
vi.mock('@/components/FadeIn', () => ({
|
||||||
|
FadeIn: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const photo = (id: string): PhotoItem => ({
|
||||||
|
id,
|
||||||
|
type: 'image',
|
||||||
|
thumbUrl: `/t/${id}`,
|
||||||
|
previewUrl: `/p/${id}`,
|
||||||
|
exifUrl: `/e/${id}`,
|
||||||
|
aspectRatio: 1.5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ASSETS = [photo('a1'), photo('a2'), photo('a3')];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
vi.stubGlobal('localStorage', {
|
||||||
|
getItem: (k: string) => store.get(k) ?? null,
|
||||||
|
setItem: (k: string, v: string) => void store.set(k, String(v)),
|
||||||
|
removeItem: (k: string) => void store.delete(k),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('?photo= on load', () => {
|
||||||
|
it('opens the photo a valid token names', () => {
|
||||||
|
window.history.replaceState(null, '', '/wedding?photo=a2');
|
||||||
|
render(<PhotoGrid assets={ASSETS} albumName="Wedding" />);
|
||||||
|
expect(screen.getByRole('dialog')).toBeTruthy();
|
||||||
|
expect(window.location.search).toBe('?photo=a2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a token that names no photo, keeping other parameters', () => {
|
||||||
|
window.history.replaceState(null, '', '/wedding?preview=true&photo=bogus');
|
||||||
|
render(<PhotoGrid assets={ASSETS} albumName="Wedding" />);
|
||||||
|
expect(screen.queryByRole('dialog')).toBeNull();
|
||||||
|
expect(window.location.pathname + window.location.search).toBe('/wedding?preview=true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a position-style ?photo=5, which was never a format', () => {
|
||||||
|
window.history.replaceState(null, '', '/wedding?photo=5');
|
||||||
|
render(<PhotoGrid assets={ASSETS} albumName="Wedding" />);
|
||||||
|
expect(screen.queryByRole('dialog')).toBeNull();
|
||||||
|
expect(window.location.search).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still honours a legacy #photo-N hash next to a bad token', () => {
|
||||||
|
window.history.replaceState(null, '', '/wedding?photo=bogus#photo-3');
|
||||||
|
render(<PhotoGrid assets={ASSETS} albumName="Wedding" />);
|
||||||
|
expect(screen.getByRole('dialog')).toBeTruthy();
|
||||||
|
expect(new URLSearchParams(window.location.search).get('photo')).toBe('a3');
|
||||||
|
});
|
||||||
|
});
|
||||||
126
app/[...path]/__tests__/photo-wall-label.test.tsx
Normal file
126
app/[...path]/__tests__/photo-wall-label.test.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
|
||||||
|
import { PhotoGrid, type PhotoItem } from '../PhotoGrid';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kunsthalle's wall label was the EXIF overlay with a CSS-counter number in
|
||||||
|
* front of it, so a photo without camera or lens got no label and no number.
|
||||||
|
* Each photo is now a <figure> with a figcaption that always carries its
|
||||||
|
* number; globals.css hides it outside the presets that show it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
vi.mock('next/image', () => ({
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
default: ({ alt }: { alt?: string }) => <img alt={alt ?? ''} />,
|
||||||
|
}));
|
||||||
|
vi.mock('@/components/FadeIn', () => ({
|
||||||
|
FadeIn: ({
|
||||||
|
children,
|
||||||
|
as: Wrapper = 'div',
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
as?: 'div' | 'figure';
|
||||||
|
}) => <Wrapper>{children}</Wrapper>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const photo = (id: string, extra: Partial<PhotoItem> = {}): PhotoItem => ({
|
||||||
|
id,
|
||||||
|
type: 'image',
|
||||||
|
thumbUrl: `/t/${id}`,
|
||||||
|
previewUrl: `/p/${id}`,
|
||||||
|
exifUrl: `/e/${id}`,
|
||||||
|
aspectRatio: 1.5,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ASSETS = [
|
||||||
|
photo('a1', { camera: 'Leica M11', lens: 'Summicron 35', focalLength: '35mm' }),
|
||||||
|
photo('a2'),
|
||||||
|
photo('a3', { caption: 'Cloister at dusk' }),
|
||||||
|
photo('a4', { focalLength: '50mm' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
let store: Map<string, string>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = new Map<string, string>();
|
||||||
|
vi.stubGlobal('localStorage', {
|
||||||
|
getItem: (k: string) => store.get(k) ?? null,
|
||||||
|
setItem: (k: string, v: string) => void store.set(k, String(v)),
|
||||||
|
removeItem: (k: string) => void store.delete(k),
|
||||||
|
clear: () => store.clear(),
|
||||||
|
});
|
||||||
|
window.history.replaceState(null, '', '/kloster');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
const labels = (container: HTMLElement) =>
|
||||||
|
[...container.querySelectorAll('figure > figcaption.photo-grid__label')] as HTMLElement[];
|
||||||
|
|
||||||
|
describe('photo wall label', () => {
|
||||||
|
it('numbers every photo, with or without EXIF', () => {
|
||||||
|
const { container } = render(<PhotoGrid assets={ASSETS} />);
|
||||||
|
const numbers = labels(container).map(
|
||||||
|
(l) => l.querySelector('.photo-grid__label-no')?.textContent,
|
||||||
|
);
|
||||||
|
expect(numbers).toEqual(['01', '02', '03', '04']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sits next to the tile, not inside its clipped box', () => {
|
||||||
|
const { container } = render(<PhotoGrid assets={ASSETS} />);
|
||||||
|
for (const label of labels(container)) {
|
||||||
|
expect(label.closest('.photo-grid__item')).toBeNull();
|
||||||
|
expect(label.previousElementSibling?.classList.contains('photo-grid__item')).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries the caption only where one is published, and names the figure with it', () => {
|
||||||
|
const { container } = render(<PhotoGrid assets={ASSETS} />);
|
||||||
|
const titles = labels(container).map(
|
||||||
|
(l) => l.querySelector('.photo-grid__label-title')?.textContent ?? null,
|
||||||
|
);
|
||||||
|
expect(titles).toEqual([null, null, 'Cloister at dusk', null]);
|
||||||
|
// The same text the photo already publishes as its alt text.
|
||||||
|
expect(container.querySelector('img[alt="Cloister at dusk"]')).not.toBeNull();
|
||||||
|
// What assistive tech reads off the label (the figure's name, by HTML-AAM)
|
||||||
|
// is the caption alone.
|
||||||
|
const spoken = (l: HTMLElement) =>
|
||||||
|
[...l.children]
|
||||||
|
.filter((c) => c.getAttribute('aria-hidden') !== 'true')
|
||||||
|
.map((c) => c.textContent)
|
||||||
|
.join('');
|
||||||
|
expect(labels(container).map(spoken)).toEqual(['', '', 'Cloister at dusk', '']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prints the EXIF line only for a camera or lens, as the tile does', () => {
|
||||||
|
const { container } = render(<PhotoGrid assets={ASSETS} />);
|
||||||
|
const exif = labels(container).map(
|
||||||
|
(l) => l.querySelector('.photo-grid__label-exif')?.textContent ?? null,
|
||||||
|
);
|
||||||
|
expect(exif).toEqual(['Leica M11 · Summicron 35 · 35mm', null, null, null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the number and EXIF, which repeat the tile and lightbox, out of the figure name', () => {
|
||||||
|
const { container } = render(<PhotoGrid assets={ASSETS} />);
|
||||||
|
const [first] = labels(container);
|
||||||
|
expect(first.querySelector('.photo-grid__label-no')?.getAttribute('aria-hidden')).toBe('true');
|
||||||
|
expect(first.querySelector('.photo-grid__label-exif')?.getAttribute('aria-hidden')).toBe(
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a photo’s album number when the favourites filter hides the others', async () => {
|
||||||
|
store.set('folio_fav_kloster', JSON.stringify(['a3']));
|
||||||
|
const { container } = render(<PhotoGrid assets={ASSETS} proofing albumName="Kloster" />);
|
||||||
|
fireEvent.click(await screen.findByText('❤️ 1 Selected'));
|
||||||
|
const numbers = labels(container).map(
|
||||||
|
(l) => l.querySelector('.photo-grid__label-no')?.textContent,
|
||||||
|
);
|
||||||
|
expect(numbers).toEqual(['03']);
|
||||||
|
});
|
||||||
|
});
|
||||||
65
app/[...path]/__tests__/photoItems-edited.test.ts
Normal file
65
app/[...path]/__tests__/photoItems-edited.test.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import type { ImmichAsset } from '@/lib/immich';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #831: a photo edited in Immich (crop, rotate) reaches the grid, the
|
||||||
|
* lightbox and the zoom as edited — an image URL with the edit marker, the
|
||||||
|
* edited proportions, and the edited full-size rendition behind the zoom.
|
||||||
|
*/
|
||||||
|
|
||||||
|
vi.mock('@/lib/config', () => ({
|
||||||
|
getConfig: () => ({ authSecret: 'test-auth-secret-32-chars-long-min' }),
|
||||||
|
getConfigOrNull: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { toPhotoItems } = await import('../photoItems');
|
||||||
|
|
||||||
|
const exif = (w: number, h: number, orientation: string | null = null) =>
|
||||||
|
({ exifImageWidth: w, exifImageHeight: h, orientation }) as ImmichAsset['exifInfo'];
|
||||||
|
|
||||||
|
const asset = (over: Partial<ImmichAsset>): ImmichAsset => ({
|
||||||
|
id: '11111111-1111-1111-1111-111111111111',
|
||||||
|
type: 'IMAGE',
|
||||||
|
originalFileName: 'scan.jpg',
|
||||||
|
originalMimeType: 'image/jpeg',
|
||||||
|
thumbhash: null,
|
||||||
|
fileCreatedAt: '2020-07-24T11:13:27.000Z',
|
||||||
|
isTrashed: false,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ALBUM = '22222222-2222-2222-2222-222222222222';
|
||||||
|
|
||||||
|
describe('toPhotoItems for photos edited in Immich (#831)', () => {
|
||||||
|
// Measured on Immich 3.2: a scan stored 1076×723, rotated 270° in Immich.
|
||||||
|
const rotated = asset({
|
||||||
|
isEdited: true,
|
||||||
|
updatedAt: '2026-09-04T19:22:09.220Z',
|
||||||
|
width: 723,
|
||||||
|
height: 1076,
|
||||||
|
exifInfo: exif(1076, 723),
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the edited photo’s URLs and gives it the edited proportions', () => {
|
||||||
|
const [item] = toPhotoItems([rotated], false, false);
|
||||||
|
expect(item.thumbUrl).toMatch(/[?&]e=[0-9a-z]+(&|$)/);
|
||||||
|
expect(item.previewUrl).toBe(item.thumbUrl);
|
||||||
|
expect(item.aspectRatio).toBeCloseTo(723 / 1076);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a photo that is not edited exactly as it was', () => {
|
||||||
|
const [item] = toPhotoItems(
|
||||||
|
[asset({ width: 1076, height: 723, exifInfo: exif(1076, 723) })],
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(item.thumbUrl).toMatch(/\?size=preview$/);
|
||||||
|
expect(item.aspectRatio).toBeCloseTo(1076 / 723);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers zoom at the edited size', () => {
|
||||||
|
const [item] = toPhotoItems([rotated], false, false, undefined, ALBUM);
|
||||||
|
expect(item.zoomUrl).toMatch(/^\/api\/zoom\//);
|
||||||
|
expect([item.zoomWidth, item.zoomHeight]).toEqual([723, 1076]);
|
||||||
|
});
|
||||||
|
});
|
||||||
121
app/[...path]/__tests__/proofing-count.test.tsx
Normal file
121
app/[...path]/__tests__/proofing-count.test.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { render, screen, cleanup, fireEvent, within } from '@testing-library/react';
|
||||||
|
import { PhotoGrid, type PhotoItem } from '../PhotoGrid';
|
||||||
|
import { EssayView } from '../EssayView';
|
||||||
|
import { parseEssayMarkdown } from '@/lib/essay';
|
||||||
|
import { encodeEmail } from '@/lib/emailObfuscation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The anonymous proofing selection lives under `folio_fav_<albumName>`, so it
|
||||||
|
* can hold tokens this grid does not show: a photo since removed from the
|
||||||
|
* album, or a favourite from another album whose name slugs the same way.
|
||||||
|
* Every count the visitor sees must be the photos of *this* album that are
|
||||||
|
* selected — the modal's download button already counted that way, while the
|
||||||
|
* bar, the title and the email subject counted the whole stored set.
|
||||||
|
*/
|
||||||
|
|
||||||
|
vi.mock('next/image', () => ({
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
default: ({ alt }: { alt?: string }) => <img alt={alt ?? ''} />,
|
||||||
|
}));
|
||||||
|
vi.mock('@/components/Lightbox', () => ({ Lightbox: () => null }));
|
||||||
|
vi.mock('@/components/LeafletMap', () => ({
|
||||||
|
LeafletMap: () => null,
|
||||||
|
escapeHtml: (s: string) => s,
|
||||||
|
}));
|
||||||
|
vi.mock('@/components/FadeIn', () => ({
|
||||||
|
FadeIn: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const photo = (id: string): PhotoItem => ({
|
||||||
|
id,
|
||||||
|
type: 'image',
|
||||||
|
thumbUrl: `/t/${id}`,
|
||||||
|
previewUrl: `/p/${id}`,
|
||||||
|
exifUrl: `/e/${id}`,
|
||||||
|
aspectRatio: 1.5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ASSETS = [photo('a1'), photo('a2'), photo('a3')];
|
||||||
|
|
||||||
|
let store: Map<string, string>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Node 26 defines its own global localStorage (undefined without
|
||||||
|
// --localstorage-file), which shadows jsdom's; give the test a working one.
|
||||||
|
store = new Map<string, string>();
|
||||||
|
vi.stubGlobal('localStorage', {
|
||||||
|
getItem: (k: string) => store.get(k) ?? null,
|
||||||
|
setItem: (k: string, v: string) => void store.set(k, String(v)),
|
||||||
|
removeItem: (k: string) => void store.delete(k),
|
||||||
|
clear: () => store.clear(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('proofing counts in the album grid', () => {
|
||||||
|
it('counts only this album’s photos, not stale or foreign favourites', async () => {
|
||||||
|
// One photo of this album, one removed photo, one from a same-named album.
|
||||||
|
store.set('folio_fav_wedding', JSON.stringify(['a2', 'gone', 'other-album']));
|
||||||
|
render(<PhotoGrid assets={ASSETS} proofing albumName="Wedding" />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('❤️ 1 Selected')).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Share & Export'));
|
||||||
|
expect(screen.getByText('❤️ Selection (1)')).toBeTruthy();
|
||||||
|
expect(screen.getByText(/You have selected 1 photo\./)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no selection bar when only foreign favourites are stored', () => {
|
||||||
|
store.set('folio_fav_wedding', JSON.stringify(['gone', 'other-album']));
|
||||||
|
render(<PhotoGrid assets={ASSETS} proofing albumName="Wedding" />);
|
||||||
|
// `render` flushes the hydration effect, so the stored set is loaded here.
|
||||||
|
expect(screen.queryByText(/Selected/)).toBeNull();
|
||||||
|
expect(screen.queryByText('Share & Export')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps "Show All" reachable after the last favourite is removed in the filter', async () => {
|
||||||
|
store.set('folio_fav_wedding', JSON.stringify(['a1']));
|
||||||
|
render(<PhotoGrid assets={ASSETS} proofing albumName="Wedding" />);
|
||||||
|
fireEvent.click(await screen.findByText('❤️ 1 Selected'));
|
||||||
|
// Filtered to the one favourite; un-heart it and the grid is empty.
|
||||||
|
fireEvent.click(screen.getByLabelText('Remove favorite'));
|
||||||
|
expect(screen.queryByLabelText('Add favorite')).toBeNull();
|
||||||
|
// The empty state offers "Show All" too; this is about the bar's own.
|
||||||
|
const bar = document.querySelector<HTMLElement>('.proofing-sticky-bar')!;
|
||||||
|
fireEvent.click(within(bar).getByText('Show All'));
|
||||||
|
expect(screen.getAllByLabelText('Add favorite')).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts the album count into the email subject', async () => {
|
||||||
|
store.set('folio_fav_wedding', JSON.stringify(['a1', 'gone']));
|
||||||
|
render(
|
||||||
|
<PhotoGrid
|
||||||
|
assets={ASSETS}
|
||||||
|
proofing
|
||||||
|
albumName="Wedding"
|
||||||
|
allowMailto
|
||||||
|
encodedMailto={encodeEmail('hello@example.com')}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(await screen.findByText('Share & Export'));
|
||||||
|
const location = { href: 'http://localhost/wedding' };
|
||||||
|
vi.stubGlobal('location', location);
|
||||||
|
fireEvent.click(screen.getByText('✉️ Send Email to Photographer'));
|
||||||
|
expect(decodeURIComponent(location.href)).toMatch(/Photo Selection \(1 items?\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('proofing counts in an essay', () => {
|
||||||
|
it('counts only the essay’s photos', async () => {
|
||||||
|
store.set('folio_fav_story', JSON.stringify(['a3', 'gone']));
|
||||||
|
const essay = parseEssayMarkdown('---\ntitle: Story\n---\n\n\n');
|
||||||
|
render(<EssayView essay={essay} assets={ASSETS} title="Story" proofing />);
|
||||||
|
expect(await screen.findByText('❤️ 1 Selected')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
77
app/[...path]/__tests__/proofing-filter-empty.test.tsx
Normal file
77
app/[...path]/__tests__/proofing-filter-empty.test.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { render, screen, cleanup, fireEvent, within } from '@testing-library/react';
|
||||||
|
import { PhotoGrid, type PhotoItem } from '../PhotoGrid';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* With "show selected" on, un-hearting the last favourite emptied the grid and
|
||||||
|
* left nothing between the album header and the footer — no word on why, and
|
||||||
|
* the only way back a small pill in the corner.
|
||||||
|
*/
|
||||||
|
|
||||||
|
vi.mock('next/image', () => ({
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
default: ({ alt }: { alt?: string }) => <img alt={alt ?? ''} />,
|
||||||
|
}));
|
||||||
|
vi.mock('@/components/FadeIn', () => ({
|
||||||
|
FadeIn: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const photo = (id: string): PhotoItem => ({
|
||||||
|
id,
|
||||||
|
type: 'image',
|
||||||
|
thumbUrl: `/t/${id}`,
|
||||||
|
previewUrl: `/p/${id}`,
|
||||||
|
exifUrl: `/e/${id}`,
|
||||||
|
aspectRatio: 1.5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ASSETS = [photo('a1'), photo('a2'), photo('a3')];
|
||||||
|
|
||||||
|
let store: Map<string, string>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Node 26 defines its own global localStorage (undefined without
|
||||||
|
// --localstorage-file), which shadows jsdom's; give the test a working one.
|
||||||
|
store = new Map<string, string>();
|
||||||
|
vi.stubGlobal('localStorage', {
|
||||||
|
getItem: (k: string) => store.get(k) ?? null,
|
||||||
|
setItem: (k: string, v: string) => void store.set(k, String(v)),
|
||||||
|
removeItem: (k: string) => void store.delete(k),
|
||||||
|
clear: () => store.clear(),
|
||||||
|
});
|
||||||
|
window.history.replaceState(null, '', '/wedding');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('favourites filter with nothing left', () => {
|
||||||
|
it('says nothing is selected and offers the way back to all photos', async () => {
|
||||||
|
store.set('folio_fav_wedding', JSON.stringify(['a2']));
|
||||||
|
render(<PhotoGrid assets={ASSETS} proofing albumName="Wedding" />);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByText('❤️ 1 Selected'));
|
||||||
|
expect(screen.getAllByLabelText(/^View photo/)).toHaveLength(1);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText('Remove favorite'));
|
||||||
|
|
||||||
|
const empty = screen.getByRole('status');
|
||||||
|
expect(within(empty).getByText('No photos selected.')).toBeTruthy();
|
||||||
|
expect(screen.queryAllByLabelText(/^View photo/)).toHaveLength(0);
|
||||||
|
|
||||||
|
fireEvent.click(within(empty).getByRole('button', { name: 'Show All' }));
|
||||||
|
expect(screen.getAllByLabelText(/^View photo/)).toHaveLength(3);
|
||||||
|
expect(screen.queryByText('No photos selected.')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no empty state while the filter still has photos', async () => {
|
||||||
|
store.set('folio_fav_wedding', JSON.stringify(['a1', 'a2']));
|
||||||
|
render(<PhotoGrid assets={ASSETS} proofing albumName="Wedding" />);
|
||||||
|
fireEvent.click(await screen.findByText('❤️ 2 Selected'));
|
||||||
|
expect(screen.getAllByLabelText(/^View photo/)).toHaveLength(2);
|
||||||
|
expect(screen.queryByText('No photos selected.')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
126
app/[...path]/__tests__/proofing-lazy.test.tsx
Normal file
126
app/[...path]/__tests__/proofing-lazy.test.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
|
||||||
|
import { renderToReadableStream } from 'react-dom/server';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { PhotoGrid, type PhotoItem } from '../PhotoGrid';
|
||||||
|
import { EssayView } from '../EssayView';
|
||||||
|
import { parseEssayMarkdown } from '@/lib/essay';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proofing is off on most album pages, but its provider, modal and session
|
||||||
|
* controls used to be imported statically by the grid and the essay, so every
|
||||||
|
* album, essay and journal page shipped them (~5 KB gzip). They now load
|
||||||
|
* through `next/dynamic` only where proofing is on.
|
||||||
|
*
|
||||||
|
* The mocks below pass every module through unchanged and only note that it
|
||||||
|
* was loaded: a page without proofing must not load any of them, and a page
|
||||||
|
* with proofing must still render the hearts — on the server too, so they are
|
||||||
|
* in the HTML before the chunk arrives.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const loaded = vi.hoisted(() => new Set<string>());
|
||||||
|
|
||||||
|
vi.mock('@/components/ProofingContext', async (importOriginal) => {
|
||||||
|
loaded.add('ProofingContext');
|
||||||
|
return importOriginal();
|
||||||
|
});
|
||||||
|
vi.mock('@/components/ProofingModal', async (importOriginal) => {
|
||||||
|
loaded.add('ProofingModal');
|
||||||
|
return importOriginal();
|
||||||
|
});
|
||||||
|
vi.mock('@/components/ProofSessionControls', async (importOriginal) => {
|
||||||
|
loaded.add('ProofSessionControls');
|
||||||
|
return importOriginal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The build aliases `next/dynamic` to the App Router implementation in app/;
|
||||||
|
// the bare package resolves to the Pages Router one.
|
||||||
|
vi.mock('next/dynamic', async () => ({
|
||||||
|
default: (await import('next/dist/shared/lib/app-dynamic')).default,
|
||||||
|
}));
|
||||||
|
vi.mock('next/image', () => ({
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
default: ({ alt }: { alt?: string }) => <img alt={alt ?? ''} />,
|
||||||
|
}));
|
||||||
|
vi.mock('@/components/LeafletMap', () => ({
|
||||||
|
LeafletMap: () => null,
|
||||||
|
escapeHtml: (s: string) => s,
|
||||||
|
}));
|
||||||
|
vi.mock('@/components/FadeIn', () => ({
|
||||||
|
FadeIn: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const photo = (id: string): PhotoItem => ({
|
||||||
|
id,
|
||||||
|
type: 'image',
|
||||||
|
thumbUrl: `/t/${id}`,
|
||||||
|
previewUrl: `/p/${id}`,
|
||||||
|
exifUrl: `/e/${id}`,
|
||||||
|
aspectRatio: 1.5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ASSETS = [photo('a1'), photo('a2'), photo('a3')];
|
||||||
|
const ESSAY = parseEssayMarkdown('---\ntitle: Story\n---\n\n\n');
|
||||||
|
|
||||||
|
async function serverHtml(node: ReactNode): Promise<string> {
|
||||||
|
const stream = await renderToReadableStream(node);
|
||||||
|
await stream.allReady;
|
||||||
|
return new Response(stream).text();
|
||||||
|
}
|
||||||
|
|
||||||
|
const hearts = (html: string) => html.match(/aria-label="Add favorite"/g)?.length ?? 0;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Node defines its own global localStorage (undefined without
|
||||||
|
// --localstorage-file), which shadows jsdom's; give the test a working one.
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
vi.stubGlobal('localStorage', {
|
||||||
|
getItem: (k: string) => store.get(k) ?? null,
|
||||||
|
setItem: (k: string, v: string) => void store.set(k, String(v)),
|
||||||
|
removeItem: (k: string) => void store.delete(k),
|
||||||
|
clear: () => store.clear(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Runs first: the set records loads for the whole file.
|
||||||
|
describe('proofing off', () => {
|
||||||
|
it('loads neither the provider nor the modal nor the session controls', async () => {
|
||||||
|
const grid = render(<PhotoGrid assets={ASSETS} albumName="Wedding" />);
|
||||||
|
const essay = render(<EssayView essay={ESSAY} assets={ASSETS} title="Story" />);
|
||||||
|
expect(grid.container.querySelectorAll('img')).toHaveLength(3);
|
||||||
|
expect(essay.container.querySelectorAll('img')).toHaveLength(2);
|
||||||
|
expect(screen.queryByLabelText('Add favorite')).toBeNull();
|
||||||
|
|
||||||
|
await serverHtml(<PhotoGrid assets={ASSETS} albumName="Wedding" />);
|
||||||
|
expect([...loaded]).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('proofing on', () => {
|
||||||
|
it('renders the hearts on the server', async () => {
|
||||||
|
const html = await serverHtml(<PhotoGrid assets={ASSETS} proofing albumName="Wedding" />);
|
||||||
|
expect(hearts(html)).toBe(3);
|
||||||
|
const essay = await serverHtml(
|
||||||
|
<EssayView essay={ESSAY} assets={ASSETS} title="Story" proofing />,
|
||||||
|
);
|
||||||
|
expect(hearts(essay)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shares one context between the grid and the lazily loaded provider and modal', async () => {
|
||||||
|
render(<PhotoGrid assets={ASSETS} proofing albumName="Wedding" />);
|
||||||
|
const [first] = await screen.findAllByLabelText('Add favorite');
|
||||||
|
fireEvent.click(first);
|
||||||
|
// The heart, the selection bar and the modal all read the same state.
|
||||||
|
expect(screen.getAllByLabelText('Remove favorite')).toHaveLength(1);
|
||||||
|
fireEvent.click(await screen.findByText('Share & Export'));
|
||||||
|
expect(await screen.findByText(/You have selected 1 photo\./)).toBeTruthy();
|
||||||
|
expect(loaded).toContain('ProofingContext');
|
||||||
|
expect(loaded).toContain('ProofingModal');
|
||||||
|
});
|
||||||
|
});
|
||||||
31
app/[...path]/__tests__/proofing-mailto-wiring.test.ts
Normal file
31
app/[...path]/__tests__/proofing-mailto-wiring.test.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page renders albums and essays from several branches — subpage album,
|
||||||
|
* essay, single-album subpage, standalone album — and each one passes the
|
||||||
|
* proofing props by hand. The dialog hides its email button without a
|
||||||
|
* recipient (#736), so a branch that forwards `allowMailto` but forgets
|
||||||
|
* `encodedMailto` silently loses the button on that kind of page. #746 missed
|
||||||
|
* two of the four; this keeps the next branch from doing the same.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const source = fs.readFileSync(path.join(__dirname, '..', 'page.tsx'), 'utf8');
|
||||||
|
|
||||||
|
/** Every `<AlbumDetailView … />` and `<EssayView … />` element in the page. */
|
||||||
|
const elements = source.match(/<(AlbumDetailView|EssayView)\b[\s\S]*?\/>/g) ?? [];
|
||||||
|
|
||||||
|
describe('proofing email wiring in the catch-all page', () => {
|
||||||
|
it('finds the album and essay views', () => {
|
||||||
|
expect(elements.length).toBeGreaterThanOrEqual(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the recipient wherever it passes the email switch', () => {
|
||||||
|
const missing = elements
|
||||||
|
.filter((el) => el.includes('allowMailto='))
|
||||||
|
.filter((el) => !el.includes('encodedMailto='))
|
||||||
|
.map((el) => el.split('\n').slice(0, 3).join(' ').trim());
|
||||||
|
expect(missing).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
123
app/[...path]/__tests__/subpage-metadata.test.ts
Normal file
123
app/[...path]/__tests__/subpage-metadata.test.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two gaps in a subpage's `<head>`:
|
||||||
|
*
|
||||||
|
* - A `hidden: true` subpage is reachable by link only — off the nav, the
|
||||||
|
* home page and the sitemap — yet it and its albums inherited the layout's
|
||||||
|
* `index, follow`, so a crawler that found the link indexed it anyway.
|
||||||
|
* - A subpage with more than one album had no description of its own and fell
|
||||||
|
* back to the site-wide one; an album page describes itself by its photo
|
||||||
|
* count.
|
||||||
|
*/
|
||||||
|
|
||||||
|
vi.mock('next/headers', () => ({
|
||||||
|
cookies: async () => ({ get: () => undefined }),
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/admin/auth', () => ({ isAdminAuthenticated: async () => false }));
|
||||||
|
vi.mock('@/lib/i18n/server', async () => {
|
||||||
|
const { getDictionary } = await import('@/lib/i18n');
|
||||||
|
return { getServerDictionary: () => getDictionary('en') };
|
||||||
|
});
|
||||||
|
vi.mock('@/lib/ogImage', () => ({ ogImageUrl: () => '/api/og?sig=x' }));
|
||||||
|
vi.mock('../contentPage', () => ({
|
||||||
|
contentPageAccess: vi.fn(async () => null),
|
||||||
|
contentPageMetadata: vi.fn(async () => null),
|
||||||
|
renderContentPage: vi.fn(async () => null),
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/auth', () => ({
|
||||||
|
isProtected: () => false,
|
||||||
|
isAuthenticated: () => false,
|
||||||
|
withoutLockedAlbums: <T>(albums: T[]) => albums,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const config = vi.hoisted(() => ({
|
||||||
|
subpages: [] as { slug: string; hidden?: boolean }[],
|
||||||
|
seo: { noFollow: false },
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/config', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/lib/config')>();
|
||||||
|
return { ...actual, getConfig: () => config };
|
||||||
|
});
|
||||||
|
|
||||||
|
const album = (id: string, name: string) => ({
|
||||||
|
id,
|
||||||
|
albumName: name,
|
||||||
|
slug: name.toLowerCase(),
|
||||||
|
assets: [{ id: `${id}-a`, type: 'IMAGE' }],
|
||||||
|
albumThumbnailAssetId: null,
|
||||||
|
});
|
||||||
|
const ICELAND = album('album-1', 'Iceland');
|
||||||
|
const NORWAY = album('album-2', 'Norway');
|
||||||
|
|
||||||
|
const subpages: Record<string, { subtitle?: string; albums: ReturnType<typeof album>[] }> = {
|
||||||
|
travel: { albums: [ICELAND, NORWAY] },
|
||||||
|
trips: { subtitle: 'Where the road went', albums: [ICELAND, NORWAY] },
|
||||||
|
private: { albums: [ICELAND, NORWAY] },
|
||||||
|
handover: { albums: [ICELAND] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const immich = vi.hoisted(() => ({
|
||||||
|
isSubpageSlug: vi.fn(),
|
||||||
|
getSubpageAlbums: vi.fn(),
|
||||||
|
getAlbumBySlug: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/immich', () => ({ immich }));
|
||||||
|
|
||||||
|
import { generateMetadata } from '../page';
|
||||||
|
|
||||||
|
const meta = (path: string[]) =>
|
||||||
|
generateMetadata({ params: Promise.resolve({ path }), searchParams: Promise.resolve({}) });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
config.subpages = [
|
||||||
|
{ slug: 'travel' },
|
||||||
|
{ slug: 'trips' },
|
||||||
|
{ slug: 'private', hidden: true },
|
||||||
|
{ slug: 'handover', hidden: true },
|
||||||
|
];
|
||||||
|
config.seo.noFollow = false;
|
||||||
|
immich.isSubpageSlug.mockImplementation((slug: string) => slug in subpages);
|
||||||
|
immich.getSubpageAlbums.mockImplementation(async (slug: string) => {
|
||||||
|
const sp = subpages[slug];
|
||||||
|
return sp ? { subpage: { slug, name: slug, subtitle: sp.subtitle }, albums: sp.albums } : null;
|
||||||
|
});
|
||||||
|
immich.getAlbumBySlug.mockImplementation(
|
||||||
|
async (slug: string) => [ICELAND, NORWAY].find((a) => a.slug === slug) ?? null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hidden subpages stay out of search results', () => {
|
||||||
|
it.each([
|
||||||
|
['a hidden multi-album subpage', ['private']],
|
||||||
|
['a hidden single-album subpage', ['handover']],
|
||||||
|
['an album below a hidden subpage', ['private', 'iceland']],
|
||||||
|
])('marks %s noindex', async (_, path) => {
|
||||||
|
const m = await meta(path);
|
||||||
|
expect(m.robots).toEqual({ index: false, follow: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the site-wide nofollow on a hidden subpage', async () => {
|
||||||
|
config.seo.noFollow = true;
|
||||||
|
expect((await meta(['private'])).robots).toEqual({ index: false, follow: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a visible subpage, its albums and standalone albums to the layout', async () => {
|
||||||
|
expect((await meta(['travel'])).robots).toBeUndefined();
|
||||||
|
expect((await meta(['travel', 'iceland'])).robots).toBeUndefined();
|
||||||
|
expect((await meta(['iceland'])).robots).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('multi-album subpage description', () => {
|
||||||
|
it('describes the subpage by its album count when it has no subtitle', async () => {
|
||||||
|
const m = await meta(['travel']);
|
||||||
|
expect(m.description).toBe('travel — 2 albums');
|
||||||
|
expect(m.openGraph?.description).toBe('travel — 2 albums');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers the subtitle when there is one', async () => {
|
||||||
|
const m = await meta(['trips']);
|
||||||
|
expect(m.description).toBe('Where the road went');
|
||||||
|
});
|
||||||
|
});
|
||||||
118
app/[...path]/contentPage.tsx
Normal file
118
app/[...path]/contentPage.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* Content pages at `/<slug>` (#722): content/pages/<slug>.md rendered through
|
||||||
|
* the journal's EssayView, without a date, reading time or journal index.
|
||||||
|
*
|
||||||
|
* Draft and password behave as on a journal entry: a draft is a 404 for
|
||||||
|
* everyone but a signed-in admin, and a password shows the gate until the
|
||||||
|
* `lb_auth_page_<slug>` cookie is set. The site password is enforced before
|
||||||
|
* any of this runs, in proxy.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { readPage, type PageRecord } from '@/lib/admin/pages-service';
|
||||||
|
import { isAdminAuthenticated } from '@/lib/admin/auth';
|
||||||
|
import { isAuthenticated } from '@/lib/auth';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { getServerDictionary } from '@/lib/i18n/server';
|
||||||
|
import { ogImageUrl } from '@/lib/ogImage';
|
||||||
|
import PasswordGate from '@/components/PasswordGate';
|
||||||
|
import { EssayView } from './EssayView';
|
||||||
|
import { buildEssayPayload } from './essayPayload';
|
||||||
|
|
||||||
|
export type Access = 'open' | 'draft' | 'locked';
|
||||||
|
|
||||||
|
async function accessFor(page: PageRecord): Promise<Access> {
|
||||||
|
const { draft, password } = page.parsed.frontmatter;
|
||||||
|
if (draft && !(await isAdminAuthenticated())) return 'draft';
|
||||||
|
if (password) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
if (!isAuthenticated(page.slug, (name) => cookieStore.get(name)?.value, 'page')) {
|
||||||
|
return 'locked';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'open';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How this request may see the page `slug`, or null when there is none. Read
|
||||||
|
* by routeExists() before the catch-all streams: a draft is a 404 to everyone
|
||||||
|
* but an admin, and that has to be known while the status can still say so.
|
||||||
|
*/
|
||||||
|
export async function contentPageAccess(slug: string): Promise<Access | null> {
|
||||||
|
const page = await readPage(slug).catch(() => null);
|
||||||
|
return page ? accessFor(page) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata for `/<slug>` when it is a page, or null when it is not.
|
||||||
|
*
|
||||||
|
* A draft is described as not found, and a locked page by a generic
|
||||||
|
* title only: generateMetadata runs ahead of the page's own gate, so the real
|
||||||
|
* title and description must not reach the `<head>` (GHSA-fvgv-97g3-wjr7).
|
||||||
|
*/
|
||||||
|
export async function contentPageMetadata(slug: string): Promise<Metadata | null> {
|
||||||
|
const page = await readPage(slug).catch(() => null);
|
||||||
|
if (!page) return null;
|
||||||
|
const access = await accessFor(page);
|
||||||
|
const t = getServerDictionary();
|
||||||
|
// A draft answers 404 to everyone but an admin, so it gets the not-found
|
||||||
|
// title, and no robots tag that would contradict Next's own noindex.
|
||||||
|
if (access === 'draft') return { title: t.error.notFoundTitle, robots: null };
|
||||||
|
if (access === 'locked') {
|
||||||
|
return { title: t.password.protectedPage, robots: { index: false } };
|
||||||
|
}
|
||||||
|
const { title: rawTitle, description } = page.parsed.frontmatter;
|
||||||
|
const title = rawTitle || slug;
|
||||||
|
const ogImage = ogImageUrl(title);
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
...(description ? { description } : {}),
|
||||||
|
openGraph: { title, ...(description ? { description } : {}), images: [ogImage] },
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title,
|
||||||
|
...(description ? { description } : {}),
|
||||||
|
images: [ogImage],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rendered page, `'not-found'` for a draft a visitor may not see, or null
|
||||||
|
* when no page has this slug (the caller then tries a standalone album).
|
||||||
|
*/
|
||||||
|
export async function renderContentPage(
|
||||||
|
slug: string,
|
||||||
|
): Promise<React.ReactElement | 'not-found' | null> {
|
||||||
|
const page = await readPage(slug).catch(() => null);
|
||||||
|
if (!page) return null;
|
||||||
|
|
||||||
|
const access = await accessFor(page);
|
||||||
|
if (access === 'draft') return 'not-found';
|
||||||
|
const { frontmatter, blocks } = page.parsed;
|
||||||
|
if (access === 'locked') {
|
||||||
|
return <PasswordGate slug={slug} title={frontmatter.title || slug} type="page" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No map block in v1 (#722): it is dropped before any of its photos are
|
||||||
|
// fetched, so a hand-written `::map` cannot publish positions either.
|
||||||
|
const { essay, images } = await buildEssayPayload(blocks, {
|
||||||
|
logTag: 'page',
|
||||||
|
slug,
|
||||||
|
allowMap: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="content-page" style={{ paddingTop: '2rem' }}>
|
||||||
|
<EssayView
|
||||||
|
// Only the title crosses to the client: the frontmatter also holds
|
||||||
|
// the password, stored hashed but still not the visitor's business.
|
||||||
|
essay={{ ...essay, frontmatter: { title: frontmatter.title } }}
|
||||||
|
assets={images}
|
||||||
|
title={frontmatter.title}
|
||||||
|
watermark={getConfig().watermark}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
197
app/[...path]/essayPayload.ts
Normal file
197
app/[...path]/essayPayload.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* Turn a parsed block-markdown document into what `EssayView` renders: the
|
||||||
|
* blocks with every asset id replaced by its token, and the photo items those
|
||||||
|
* tokens resolve to. Shared by journal entries and content pages (#722).
|
||||||
|
*
|
||||||
|
* Server only — it talks to Immich and encrypts asset ids.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
collectAssetIds,
|
||||||
|
mapBlockAssetIds,
|
||||||
|
type JournalBlock,
|
||||||
|
type MapPin,
|
||||||
|
type ParsedJournal,
|
||||||
|
} from '@/lib/journal';
|
||||||
|
import { entryMapPins } from '@/lib/journalMap';
|
||||||
|
import { expandAlbumBlocks } from '@/lib/journalAlbum';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { immich, type ImmichAsset } from '@/lib/immich';
|
||||||
|
import {
|
||||||
|
imageUrl,
|
||||||
|
videoUrl,
|
||||||
|
exifUrl,
|
||||||
|
assetPlaceholder,
|
||||||
|
assetAspectRatio,
|
||||||
|
assetCaption,
|
||||||
|
assetExifSummary,
|
||||||
|
} from '@/lib/urls';
|
||||||
|
import { encodeAssetId } from '@/lib/tokens';
|
||||||
|
import type { PhotoItem } from './PhotoGrid';
|
||||||
|
|
||||||
|
export interface EssayPayload {
|
||||||
|
/** Blocks and cover carry tokens, never raw asset ids. Frontmatter is left empty. */
|
||||||
|
essay: ParsedJournal;
|
||||||
|
images: PhotoItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EssayPayloadOptions {
|
||||||
|
/** Prefix for log lines, e.g. `journal` or `page`. */
|
||||||
|
logTag: string;
|
||||||
|
slug: string;
|
||||||
|
/** Map blocks are dropped when false, whatever the site's map setting. */
|
||||||
|
allowMap: boolean;
|
||||||
|
/** Raw asset id of a cover photo, shown in the lightbox sequence. */
|
||||||
|
coverAssetId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The assets of every album an album block names, keyed by album id, loaded
|
||||||
|
* concurrently. An album that fails is reported and left out, so its block
|
||||||
|
* drops rather than the page.
|
||||||
|
*/
|
||||||
|
export async function loadAlbumBlocks(
|
||||||
|
blocks: readonly JournalBlock[],
|
||||||
|
onError: (albumId: string, error: unknown) => void,
|
||||||
|
): Promise<Map<string, ImmichAsset[]>> {
|
||||||
|
const albumIds = [
|
||||||
|
...new Set(blocks.flatMap((b) => (b.type === 'album' && b.albumId ? [b.albumId] : []))),
|
||||||
|
];
|
||||||
|
const loaded = await Promise.all(
|
||||||
|
albumIds.map(async (albumId) => {
|
||||||
|
try {
|
||||||
|
return [albumId, await immich.getAlbumAssetsRaw(albumId)] as const;
|
||||||
|
} catch (error) {
|
||||||
|
onError(albumId, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return new Map(loaded.filter((entry) => entry !== null));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildEssayPayload(
|
||||||
|
authoredBlocks: JournalBlock[],
|
||||||
|
{ logTag, slug, allowMap, coverAssetId }: EssayPayloadOptions,
|
||||||
|
): Promise<EssayPayload & { coverToken?: string }> {
|
||||||
|
const config = getConfig();
|
||||||
|
const mapAllowed = allowMap && config.map;
|
||||||
|
const sourceBlocks = allowMap ? authoredBlocks : authoredBlocks.filter((b) => b.type !== 'map');
|
||||||
|
const referencedAssetIds = [
|
||||||
|
...new Set([...(coverAssetId ? [coverAssetId] : []), ...collectAssetIds(sourceBlocks)]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const fetchedAssets = (
|
||||||
|
await Promise.all(
|
||||||
|
referencedAssetIds.map((assetId) => immich.getAssetInfo(assetId).catch(() => null)),
|
||||||
|
)
|
||||||
|
).filter((a): a is ImmichAsset => a !== null);
|
||||||
|
|
||||||
|
// Album blocks become photo blocks here. The raw fetch bypasses the album
|
||||||
|
// allowlist on purpose: an entry may already show any single photo by id,
|
||||||
|
// and the author's pick is the gate for a whole album just the same. An
|
||||||
|
// album Immich cannot deliver drops its block rather than the page.
|
||||||
|
// All albums at once, not one round trip after the other.
|
||||||
|
const albumAssetsById = await loadAlbumBlocks(sourceBlocks, (albumId, error) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn(`[${logTag}] ${slug}: album ${albumId} could not be loaded:`, error);
|
||||||
|
});
|
||||||
|
const { blocks } = expandAlbumBlocks(
|
||||||
|
sourceBlocks,
|
||||||
|
(albumId) => albumAssetsById.get(albumId),
|
||||||
|
config.albumManualOrders,
|
||||||
|
);
|
||||||
|
const knownIds = new Set(fetchedAssets.map((a) => a.id));
|
||||||
|
const rawAssets = [...fetchedAssets];
|
||||||
|
for (const list of albumAssetsById.values()) {
|
||||||
|
for (const asset of list) {
|
||||||
|
if (knownIds.has(asset.id)) continue;
|
||||||
|
knownIds.add(asset.id);
|
||||||
|
rawAssets.push(asset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EssayView drops photo blocks it cannot resolve, which is right for visitors
|
||||||
|
// but leaves no trace of *why* a photo vanished. Legacy positional references
|
||||||
|
// ("1", "2") are the usual cause: they only resolved against a subpage album.
|
||||||
|
if (fetchedAssets.length < referencedAssetIds.length) {
|
||||||
|
const resolved = new Set(fetchedAssets.map((a) => a.id));
|
||||||
|
const missing = referencedAssetIds.filter((id) => !resolved.has(id));
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn(
|
||||||
|
`[${logTag}] ${slug}: ${missing.length} photo reference(s) could not be resolved and will not render: ${missing.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* EssayView resolves a block's photo through PhotoItem.id, and those ids are
|
||||||
|
* encrypted tokens — while the blocks coming out of the Markdown carry raw
|
||||||
|
* asset UUIDs. Translating here also keeps raw UUIDs out of the client
|
||||||
|
* payload. Unresolvable references collapse to an empty string, which
|
||||||
|
* EssayView skips.
|
||||||
|
*/
|
||||||
|
const tokenByAssetId = new Map(rawAssets.map((a) => [a.id, encodeAssetId(a.id)]));
|
||||||
|
const toToken = (assetId: string) => tokenByAssetId.get(assetId) ?? '';
|
||||||
|
|
||||||
|
// A map block's pins are computed here, from the photos fetched above and
|
||||||
|
// under each photo's `location:` precision, and only when the site
|
||||||
|
// publishes a map at all. The client gets pins only — never the items,
|
||||||
|
// which carry raw asset ids.
|
||||||
|
const entryPhotoIds = collectAssetIds(blocks.filter((b) => b.type !== 'map'));
|
||||||
|
const pinsByBlock = new Map<JournalBlock, MapPin[]>();
|
||||||
|
if (mapAllowed) {
|
||||||
|
for (const block of blocks) {
|
||||||
|
if (block.type === 'map') {
|
||||||
|
pinsByBlock.set(block, await entryMapPins(block.items, rawAssets, entryPhotoIds));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Photos that only anchor a map pin are fetched for their EXIF but are not
|
||||||
|
// part of the story, so they stay out of the lightbox sequence.
|
||||||
|
const shownAssetIds = new Set([...entryPhotoIds, ...(coverAssetId ? [coverAssetId] : [])]);
|
||||||
|
|
||||||
|
const essay: ParsedJournal = {
|
||||||
|
frontmatter: {},
|
||||||
|
blocks: blocks.flatMap((block): JournalBlock[] => {
|
||||||
|
if (block.type === 'map') {
|
||||||
|
const pins = pinsByBlock.get(block);
|
||||||
|
return pins
|
||||||
|
? [{ type: 'map', caption: block.caption, line: block.line, items: [], pins }]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
return [mapBlockAssetIds(block, toToken)];
|
||||||
|
}),
|
||||||
|
referencedAssetIds: rawAssets.map((a) => encodeAssetId(a.id)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const images: PhotoItem[] = rawAssets
|
||||||
|
.filter((a) => shownAssetIds.has(a.id) && (a.type === 'IMAGE' || a.type === 'VIDEO'))
|
||||||
|
.map((a) => {
|
||||||
|
const ph = assetPlaceholder(a);
|
||||||
|
const exif =
|
||||||
|
config.exif.onHover && config.exif.camera && a.type === 'IMAGE'
|
||||||
|
? assetExifSummary(a)
|
||||||
|
: undefined;
|
||||||
|
const caption = assetCaption(a, config.exif.caption);
|
||||||
|
const isVideo = a.type === 'VIDEO';
|
||||||
|
return {
|
||||||
|
id: encodeAssetId(a.id),
|
||||||
|
type: isVideo ? 'video' : 'image',
|
||||||
|
thumbUrl: imageUrl(a, 'preview'),
|
||||||
|
previewUrl: imageUrl(a, 'preview'),
|
||||||
|
...(isVideo ? { videoUrl: videoUrl(a.id) } : {}),
|
||||||
|
exifUrl: exifUrl(a.id),
|
||||||
|
...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}),
|
||||||
|
...(exif ?? {}),
|
||||||
|
...(caption ? { caption } : {}),
|
||||||
|
aspectRatio: assetAspectRatio(a),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
essay,
|
||||||
|
images,
|
||||||
|
...(coverAssetId ? { coverToken: toToken(coverAssetId) || undefined } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
/**
|
|
||||||
* Instant shell for album and subpage routes. These are force-dynamic and wait
|
|
||||||
* on a live Immich round-trip — up to IMMICH_TIMEOUT_MS when the server is
|
|
||||||
* struggling — so without this the viewer stares at a blank page.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import styles from '../loading.module.css';
|
|
||||||
import { getServerDictionary } from '@/lib/i18n/server';
|
|
||||||
|
|
||||||
export default function Loading() {
|
|
||||||
const t = getServerDictionary();
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={styles.heading} aria-hidden="true" />
|
|
||||||
<div className={styles.grid} role="status" aria-label={t.common.loadingPhotos}>
|
|
||||||
{Array.from({ length: 9 }).map((_, i) => (
|
|
||||||
<div key={i} className={styles.tile} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,36 +3,33 @@
|
|||||||
*
|
*
|
||||||
* Single segment:
|
* Single segment:
|
||||||
* - If slug matches a subpage → render subpage album grid
|
* - If slug matches a subpage → render subpage album grid
|
||||||
|
* - If slug matches a content page (content/pages/<slug>.md) → render it
|
||||||
* - If slug matches a standalone album → render album detail
|
* - If slug matches a standalone album → render album detail
|
||||||
*
|
*
|
||||||
* Two segments:
|
* Two segments:
|
||||||
* - Treat as subpage-slug/album-slug → render album detail
|
* - Treat as subpage-slug/album-slug → render album detail
|
||||||
* with back-link to the subpage
|
* with back-link to the subpage
|
||||||
|
*
|
||||||
|
* Three or more segments: 404.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Suspense } from 'react';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { immich, type ImmichAsset } from '@/lib/immich';
|
import { immich, type ImmichAsset } from '@/lib/immich';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import type { PhotoItem } from './PhotoGrid';
|
import { toPhotoItems } from './photoItems';
|
||||||
import {
|
import { imageUrl, assetPlaceholder, archiveUrl } from '@/lib/urls';
|
||||||
imageUrl,
|
import { ogImageUrl } from '@/lib/ogImage';
|
||||||
exifUrl,
|
|
||||||
videoUrl,
|
|
||||||
assetPlaceholder,
|
|
||||||
assetCaption,
|
|
||||||
assetExifSummary,
|
|
||||||
downloadUrl,
|
|
||||||
archiveUrl,
|
|
||||||
assetAspectRatio,
|
|
||||||
} from '@/lib/urls';
|
|
||||||
import { encodeAssetId, decodeAssetId } from '@/lib/tokens';
|
import { encodeAssetId, decodeAssetId } from '@/lib/tokens';
|
||||||
|
import { encodeEmail } from '@/lib/emailObfuscation';
|
||||||
import {
|
import {
|
||||||
buildCoverGridVars,
|
buildCoverGridVars,
|
||||||
getConfig,
|
getConfig,
|
||||||
hasExifPanelContent,
|
hasExifPanelContent,
|
||||||
normalizeSlug,
|
normalizeSlug,
|
||||||
resolveProofing,
|
resolveProofing,
|
||||||
|
resolveZoom,
|
||||||
type GridConfig,
|
type GridConfig,
|
||||||
} from '@/lib/config';
|
} from '@/lib/config';
|
||||||
import { isProtected, isAuthenticated, withoutLockedAlbums } from '@/lib/auth';
|
import { isProtected, isAuthenticated, withoutLockedAlbums } from '@/lib/auth';
|
||||||
@@ -45,13 +42,17 @@ 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';
|
||||||
|
import { contentPageMetadata, renderContentPage } from './contentPage';
|
||||||
|
import { loadAlbumBlocks } from './essayPayload';
|
||||||
|
import { isLocked, routeExists } from './routeExists';
|
||||||
|
import { PathSkeleton } from './PathSkeleton';
|
||||||
|
|
||||||
// Render at request time — requires live Immich connection
|
// Render at request time — requires live Immich connection
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -62,17 +63,21 @@ interface PathPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether `key` is password-protected and this request has not unlocked it.
|
* Metadata for a path that names nothing this site publishes. The title is
|
||||||
* generateMetadata runs unconditionally — unlike the page body, nothing
|
* the dictionary's, never the requested slug: generateMetadata runs for any
|
||||||
* downstream of it stops a real title, photo count or cover image from
|
* URL a visitor types, and echoing it would put their text in the `<title>`
|
||||||
* reaching an unauthenticated `<head>` unless this is checked first
|
* and, through a signed share card, in an image served from this domain.
|
||||||
* (GHSA-fvgv-97g3-wjr7).
|
* Leaving `openGraph` out keeps the root layout's site card. `robots: null`
|
||||||
|
* drops the layout's `index, follow`, which would otherwise contradict the
|
||||||
|
* `noindex` Next adds to every not-found response.
|
||||||
*/
|
*/
|
||||||
async function isLocked(key: string, type: 'subpage' | 'album'): Promise<boolean> {
|
function notFoundMetadata(): Metadata {
|
||||||
if (!isProtected(key, type)) return false;
|
return { title: getServerDictionary().error.notFoundTitle, robots: null };
|
||||||
const cookieStore = await cookies();
|
}
|
||||||
const getCookie = (name: string) => cookieStore.get(name)?.value;
|
|
||||||
return !isAuthenticated(key, getCookie, type);
|
/** Metadata for a path behind a password this request has not given. */
|
||||||
|
function lockedMetadata(): Metadata {
|
||||||
|
return { title: getServerDictionary().password.protectedPage, robots: { index: false } };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params, searchParams }: PathPageProps): Promise<Metadata> {
|
export async function generateMetadata({ params, searchParams }: PathPageProps): Promise<Metadata> {
|
||||||
@@ -81,7 +86,8 @@ export async function generateMetadata({ params, searchParams }: PathPageProps):
|
|||||||
// comparison downstream works on the same form (#522).
|
// comparison downstream works on the same form (#522).
|
||||||
const { path: rawPath } = await params;
|
const { path: rawPath } = await params;
|
||||||
const path = rawPath?.map(normalizeSlug);
|
const path = rawPath?.map(normalizeSlug);
|
||||||
if (!path || path.length === 0) return {};
|
// Deeper paths are a 404 (see the page below); they name nothing to describe.
|
||||||
|
if (!path || path.length === 0 || path.length > 2) return notFoundMetadata();
|
||||||
|
|
||||||
// A shared photo link's whole point is that it reaches the server — unlike
|
// A shared photo link's whole point is that it reaches the server — unlike
|
||||||
// the #photo-N hash it replaces, a `photo` query param is visible here, so
|
// the #photo-N hash it replaces, a `photo` query param is visible here, so
|
||||||
@@ -92,45 +98,56 @@ export async function generateMetadata({ params, searchParams }: PathPageProps):
|
|||||||
const photoAssetId = photoToken ? decodeAssetId(photoToken) : null;
|
const photoAssetId = photoToken ? decodeAssetId(photoToken) : null;
|
||||||
let photoAsset: ImmichAsset | undefined;
|
let photoAsset: ImmichAsset | undefined;
|
||||||
|
|
||||||
|
// Each branch follows the page body's order of gates and 404s, and takes
|
||||||
|
// the title only from site-authored text (an album or subpage name). A
|
||||||
|
// branch that finds nothing, or finds it locked, returns early, so the
|
||||||
|
// requested slug never becomes a title or share-card text. isLocked() comes
|
||||||
|
// first because generateMetadata runs unconditionally: nothing downstream
|
||||||
|
// stops a real title, photo count or cover image from reaching an
|
||||||
|
// unauthenticated `<head>` otherwise (GHSA-fvgv-97g3-wjr7).
|
||||||
const slug = path[0];
|
const slug = path[0];
|
||||||
let title = slug;
|
const t = getServerDictionary();
|
||||||
|
let title: string;
|
||||||
let subtitle = '';
|
let subtitle = '';
|
||||||
let description: string | undefined = undefined;
|
let description: string | undefined = undefined;
|
||||||
|
const photoCount = (assets: ImmichAsset[]) =>
|
||||||
|
t.common.photos(assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length);
|
||||||
|
|
||||||
if (path.length === 1 && immich.isSubpageSlug(slug)) {
|
if (path.length === 1 && immich.isSubpageSlug(slug)) {
|
||||||
const subpageLocked = await isLocked(slug, 'subpage');
|
if (await isLocked(slug, 'subpage')) return lockedMetadata();
|
||||||
const result = await immich.getSubpageAlbums(slug);
|
const result = await immich.getSubpageAlbums(slug);
|
||||||
if (result) {
|
if (!result || result.albums.length === 0) return notFoundMetadata();
|
||||||
if (!subpageLocked && result.subpage.subtitle) {
|
if (result.subpage.subtitle) description = result.subpage.subtitle;
|
||||||
description = result.subpage.subtitle;
|
if (result.albums.length === 1) {
|
||||||
}
|
const album = await immich.getAlbumBySlug(result.albums[0].slug, slug);
|
||||||
if (result.albums.length === 1) {
|
if (!album) return notFoundMetadata();
|
||||||
const album = await immich.getAlbumBySlug(result.albums[0].slug, slug);
|
if (await isLocked(album.id, 'album')) return lockedMetadata();
|
||||||
if (album && !subpageLocked && !(await isLocked(album.id, 'album'))) {
|
title = album.albumName;
|
||||||
title = album.albumName;
|
subtitle = photoCount(album.assets);
|
||||||
const count = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length;
|
if (photoAssetId) photoAsset = album.assets.find((a) => a.id === photoAssetId);
|
||||||
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
|
} else {
|
||||||
if (photoAssetId) photoAsset = album.assets.find((a) => a.id === photoAssetId);
|
title = result.subpage.title || result.subpage.name;
|
||||||
}
|
// A cover grid has no photo count to describe it by; without this it
|
||||||
} else if (!subpageLocked) {
|
// inherited the site-wide description.
|
||||||
title = result.subpage.title || result.subpage.name;
|
description ??= `${title} — ${t.common.albums(result.albums.length)}`;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if (path.length === 2) {
|
} else if (path.length === 2) {
|
||||||
|
if (await isLocked(slug, 'subpage')) return lockedMetadata();
|
||||||
const album = await immich.getAlbumBySlug(path[1], slug);
|
const album = await immich.getAlbumBySlug(path[1], slug);
|
||||||
if (album && !(await isLocked(slug, 'subpage')) && !(await isLocked(album.id, 'album'))) {
|
if (!album) return notFoundMetadata();
|
||||||
title = album.albumName;
|
if (await isLocked(album.id, 'album')) return lockedMetadata();
|
||||||
const count = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length;
|
title = album.albumName;
|
||||||
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
|
subtitle = photoCount(album.assets);
|
||||||
if (photoAssetId) photoAsset = album.assets.find((a) => a.id === photoAssetId);
|
if (photoAssetId) photoAsset = album.assets.find((a) => a.id === photoAssetId);
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
|
const pageMeta = await contentPageMetadata(slug);
|
||||||
|
if (pageMeta) return pageMeta;
|
||||||
const album = await immich.getAlbumBySlug(slug);
|
const album = await immich.getAlbumBySlug(slug);
|
||||||
if (album && !(await isLocked(album.id, 'album'))) {
|
if (!album) return notFoundMetadata();
|
||||||
title = album.albumName;
|
if (await isLocked(album.id, 'album')) return lockedMetadata();
|
||||||
const count = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length;
|
title = album.albumName;
|
||||||
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
|
subtitle = photoCount(album.assets);
|
||||||
if (photoAssetId) photoAsset = album.assets.find((a) => a.id === photoAssetId);
|
if (photoAssetId) photoAsset = album.assets.find((a) => a.id === photoAssetId);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The title stays the album's — it's still the context a reader wants —
|
// The title stays the album's — it's still the context a reader wants —
|
||||||
@@ -140,13 +157,19 @@ export async function generateMetadata({ params, searchParams }: PathPageProps):
|
|||||||
photoAsset?.exifInfo?.description?.trim() ||
|
photoAsset?.exifInfo?.description?.trim() ||
|
||||||
description ||
|
description ||
|
||||||
(subtitle ? `${title} — ${subtitle}` : undefined);
|
(subtitle ? `${title} — ${subtitle}` : undefined);
|
||||||
const ogImage = photoAsset
|
const ogImage = photoAsset ? imageUrl(photoAsset, 'preview') : ogImageUrl(title, subtitle);
|
||||||
? imageUrl(photoAsset.id, 'preview')
|
|
||||||
: `/api/og?title=${encodeURIComponent(title)}${subtitle ? `&subtitle=${encodeURIComponent(subtitle)}` : ''}`;
|
// A hidden subpage is reachable by link only: out of the nav, the home page
|
||||||
|
// and the sitemap. Indexing it, or an album below it, would undo that.
|
||||||
|
const config = getConfig();
|
||||||
|
const hidden =
|
||||||
|
(path.length === 2 || immich.isSubpageSlug(slug)) &&
|
||||||
|
config.subpages.some((sp) => sp.slug === slug && sp.hidden === true);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
description: ogDescription,
|
description: ogDescription,
|
||||||
|
...(hidden ? { robots: { index: false, follow: !config.seo.noFollow } } : {}),
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title,
|
title,
|
||||||
description: ogDescription,
|
description: ogDescription,
|
||||||
@@ -161,57 +184,28 @@ export async function generateMetadata({ params, searchParams }: PathPageProps):
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Map Immich assets to PhotoItem props for the grid/lightbox. */
|
|
||||||
/**
|
|
||||||
* `showExif` covers the hover overlay only, and that overlay carries camera,
|
|
||||||
* lens and focal length — so it follows the `camera` group, not the panel.
|
|
||||||
*
|
|
||||||
* `showCaption` follows the `caption` group and decides whether the Immich
|
|
||||||
* description becomes alt text; see `assetCaption`.
|
|
||||||
*/
|
|
||||||
function toPhotoItems(
|
|
||||||
assets: ImmichAsset[],
|
|
||||||
showExif: boolean,
|
|
||||||
showCaption: boolean,
|
|
||||||
/** The album offering downloads, or undefined when it does not. */
|
|
||||||
downloadAlbumId?: string,
|
|
||||||
): PhotoItem[] {
|
|
||||||
return assets
|
|
||||||
.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO')
|
|
||||||
.map((a) => {
|
|
||||||
const ph = assetPlaceholder(a);
|
|
||||||
const exif = showExif && a.type === 'IMAGE' ? assetExifSummary(a) : undefined;
|
|
||||||
const caption = assetCaption(a, showCaption);
|
|
||||||
const isVideo = a.type === 'VIDEO';
|
|
||||||
return {
|
|
||||||
id: encodeAssetId(a.id),
|
|
||||||
type: isVideo ? 'video' : 'image',
|
|
||||||
thumbUrl: imageUrl(a.id, 'preview'),
|
|
||||||
previewUrl: imageUrl(a.id, 'preview'),
|
|
||||||
...(isVideo ? { videoUrl: videoUrl(a.id) } : {}),
|
|
||||||
exifUrl: exifUrl(a.id),
|
|
||||||
...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}),
|
|
||||||
...(exif ?? {}),
|
|
||||||
...(caption ? { caption } : {}),
|
|
||||||
...(downloadAlbumId ? { downloadUrl: downloadUrl(downloadAlbumId, a.id) } : {}),
|
|
||||||
aspectRatio: assetAspectRatio(a),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JSON-LD for an album page. Null unless a site URL is configured — structured
|
* JSON-LD for an album page. Null unless a site URL is configured — structured
|
||||||
* data is a set of claims about absolute URLs, and there is nothing truthful to
|
* data is a set of claims about absolute URLs, and there is nothing truthful to
|
||||||
* claim without one (#472).
|
* claim without one (#472).
|
||||||
*/
|
*/
|
||||||
function structuredDataFor(
|
function structuredDataFor(
|
||||||
album: { albumName: string; description?: string; albumThumbnailAssetId?: string | null },
|
album: {
|
||||||
|
albumName: string;
|
||||||
|
description?: string;
|
||||||
|
albumThumbnailAssetId?: string | null;
|
||||||
|
assets?: ImmichAsset[];
|
||||||
|
},
|
||||||
path: string,
|
path: string,
|
||||||
photoCount: number,
|
photoCount: number,
|
||||||
) {
|
) {
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
const cover = album.albumThumbnailAssetId
|
const coverId = album.albumThumbnailAssetId;
|
||||||
? absoluteUrl(config.siteUrl, imageUrl(album.albumThumbnailAssetId, 'preview'))
|
// The album's own copy of its cover, when it has one, so an edit made in
|
||||||
|
// Immich reaches the URL (#831).
|
||||||
|
const coverAsset = coverId ? album.assets?.find((a) => a.id === coverId) : undefined;
|
||||||
|
const cover = coverId
|
||||||
|
? absoluteUrl(config.siteUrl, imageUrl(coverAsset ?? coverId, 'preview'))
|
||||||
: null;
|
: null;
|
||||||
return albumStructuredData({
|
return albumStructuredData({
|
||||||
siteUrl: config.siteUrl,
|
siteUrl: config.siteUrl,
|
||||||
@@ -242,7 +236,8 @@ async function gateIfProtected(
|
|||||||
let title = titleOverride || key;
|
let title = titleOverride || key;
|
||||||
if (!titleOverride && type === 'subpage') {
|
if (!titleOverride && type === 'subpage') {
|
||||||
const subpageData = await immich.getSubpageAlbums(key);
|
const subpageData = await immich.getSubpageAlbums(key);
|
||||||
title = subpageData?.subpage.name ?? key;
|
const sp = subpageData?.subpage;
|
||||||
|
title = sp ? sp.title || sp.name : key;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <PasswordGate slug={key} title={title} type={type} />;
|
return <PasswordGate slug={key} title={title} type={type} />;
|
||||||
@@ -258,12 +253,39 @@ async function getAlbumHeroData(
|
|||||||
const asset = await immich.getAssetInfo(heroAssetId);
|
const asset = await immich.getAssetInfo(heroAssetId);
|
||||||
const ph = asset ? assetPlaceholder(asset) : null;
|
const ph = asset ? assetPlaceholder(asset) : null;
|
||||||
return {
|
return {
|
||||||
heroImageUrl: imageUrl(heroAssetId, 'preview'),
|
heroImageUrl: imageUrl(asset ?? heroAssetId, 'preview'),
|
||||||
heroBlurDataURL: ph?.blurDataURL,
|
heroBlurDataURL: ph?.blurDataURL,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function PathPage({ params, searchParams }: PathPageProps) {
|
/**
|
||||||
|
* Decides whether the path exists before anything streams, then renders the
|
||||||
|
* content behind the skeleton.
|
||||||
|
*
|
||||||
|
* The order is the point. Next sends the status with the first byte, and the
|
||||||
|
* first byte goes out as soon as a Suspense fallback renders. A notFound()
|
||||||
|
* thrown after that can only add a noindex tag to a 200 (a "soft 404"), which
|
||||||
|
* is what every unknown slug got while this page sat inside a loading.tsx
|
||||||
|
* boundary. routeExists() needs only the config and the album list the header
|
||||||
|
* nav already waited for, so the check costs no time; the album's photos, the
|
||||||
|
* slow part, still load behind the skeleton.
|
||||||
|
*
|
||||||
|
* PathContent keeps its own notFound() calls as the backstop for what the
|
||||||
|
* check cannot see, such as an album removed from Immich since the list was
|
||||||
|
* cached. Those are still soft.
|
||||||
|
*/
|
||||||
|
export default async function PathPage(props: PathPageProps) {
|
||||||
|
const { path: rawPath } = await props.params;
|
||||||
|
if (!(await routeExists(rawPath?.map(normalizeSlug)))) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<PathSkeleton />}>
|
||||||
|
<PathContent {...props} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function PathContent({ params, searchParams }: PathPageProps) {
|
||||||
// Next hands catch-all segments over percent-encoded, so a non-ASCII slug
|
// Next hands catch-all segments over percent-encoded, so a non-ASCII slug
|
||||||
// ("/家族相册") would never match a stored one. Decode once, here, and every
|
// ("/家族相册") would never match a stored one. Decode once, here, and every
|
||||||
// comparison downstream works on the same form (#522).
|
// comparison downstream works on the same form (#522).
|
||||||
@@ -280,6 +302,12 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
|||||||
|
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
|
|
||||||
|
// The proofing dialog's "email to photographer" needs a recipient: the
|
||||||
|
// configured `proofing.email`, else the footer contact email (#736). Encoded
|
||||||
|
// here so the plain address stays out of the HTML and the RSC payload, like
|
||||||
|
// every other address the site renders.
|
||||||
|
const encodedMailto = config.proofing.email ? encodeEmail(config.proofing.email) : undefined;
|
||||||
|
|
||||||
// Build grid CSS custom properties, optionally merging subpage overrides
|
// Build grid CSS custom properties, optionally merging subpage overrides
|
||||||
const buildGridStyle = (overrides?: Partial<GridConfig>): React.CSSProperties => {
|
const buildGridStyle = (overrides?: Partial<GridConfig>): React.CSSProperties => {
|
||||||
const g = { ...config.grid, ...overrides };
|
const g = { ...config.grid, ...overrides };
|
||||||
@@ -310,6 +338,10 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
|||||||
// reached without a subpage follow the global setting.
|
// reached without a subpage follow the global setting.
|
||||||
const proofingFor = (subpage?: { proofing?: boolean }) =>
|
const proofingFor = (subpage?: { proofing?: boolean }) =>
|
||||||
resolveProofing(subpage, config.proofing.enabled);
|
resolveProofing(subpage, config.proofing.enabled);
|
||||||
|
// Lightbox zoom (#467): album, then the subpage it is shown on, then the
|
||||||
|
// site setting. The album id goes to toPhotoItems() when it resolves on.
|
||||||
|
const zoomAlbumFor = (albumId: string, subpage?: { zoom?: boolean }) =>
|
||||||
|
resolveZoom(config, albumId, subpage) ? albumId : undefined;
|
||||||
|
|
||||||
// EXPERIMENTAL: per-album grid override — merged over the subpage grid so
|
// EXPERIMENTAL: per-album grid override — merged over the subpage grid so
|
||||||
// the precedence is global < subpage < album.
|
// the precedence is global < subpage < album.
|
||||||
@@ -322,7 +354,10 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
|||||||
return { ...spGrid, ...albumGrid };
|
return { ...spGrid, ...albumGrid };
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!path || path.length === 0) {
|
// One segment or two: nothing on this site lives deeper. A third segment
|
||||||
|
// used to fall through to the one-segment branch below, which reads only
|
||||||
|
// `path[0]`, so `/travel/iceland/anything` rendered the Travel subpage.
|
||||||
|
if (!path || path.length === 0 || path.length > 2) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,13 +386,16 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
|||||||
// Look up subpage config for grid overrides and back link
|
// Look up subpage config for grid overrides and back link
|
||||||
const subpageData = await immich.getSubpageAlbums(subpageSlug, forceFresh);
|
const subpageData = await immich.getSubpageAlbums(subpageSlug, forceFresh);
|
||||||
const spGrid = subpageData?.subpage.grid;
|
const spGrid = subpageData?.subpage.grid;
|
||||||
const subpageName = subpageData?.subpage.name ?? subpageSlug;
|
const subpageName = subpageData
|
||||||
|
? subpageData.subpage.title || subpageData.subpage.name
|
||||||
|
: subpageSlug;
|
||||||
|
|
||||||
const images = toPhotoItems(
|
const images = toPhotoItems(
|
||||||
album.assets,
|
album.assets,
|
||||||
config.exif.onHover && config.exif.camera,
|
config.exif.onHover && config.exif.camera,
|
||||||
config.exif.caption,
|
config.exif.caption,
|
||||||
config.albumDownloads[album.id] ? album.id : undefined,
|
config.albumDownloads[album.id] ? album.id : undefined,
|
||||||
|
zoomAlbumFor(album.id, subpageData?.subpage),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Password gate for protected albums
|
// Password gate for protected albums
|
||||||
@@ -385,6 +423,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
|||||||
showGear={config.exif.camera}
|
showGear={config.exif.camera}
|
||||||
proofing={proofingFor(subpageData?.subpage)}
|
proofing={proofingFor(subpageData?.subpage)}
|
||||||
allowMailto={config.proofing.allowMailto}
|
allowMailto={config.proofing.allowMailto}
|
||||||
|
encodedMailto={encodedMailto}
|
||||||
downloadArchiveUrl={config.albumDownloads[album.id] ? archiveUrl(album.id) : undefined}
|
downloadArchiveUrl={config.albumDownloads[album.id] ? archiveUrl(album.id) : undefined}
|
||||||
{...heroData}
|
{...heroData}
|
||||||
/>
|
/>
|
||||||
@@ -424,16 +463,33 @@ 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') {
|
||||||
|
// The subpage's own title, which the nav already shows — not the
|
||||||
|
// borrowed entry's, which the journal index hides while it is locked.
|
||||||
|
return (
|
||||||
|
<PasswordGate
|
||||||
|
slug={result.subpage.essayFile}
|
||||||
|
title={result.subpage.title || result.subpage.name}
|
||||||
|
type="journal"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (file.status === 'open') essayParsed = file.parsed;
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch assets from the subpage's albums. An essay has no per-album
|
// 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)),
|
||||||
@@ -444,16 +500,10 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
|||||||
// does, from a raw fetch, and add those photos to the page's assets.
|
// does, from a raw fetch, and add those photos to the page's assets.
|
||||||
// Their ids are encoded here because this path hands EssayView tokens.
|
// Their ids are encoded here because this path hands EssayView tokens.
|
||||||
if (essayParsed?.blocks.some((b) => b.type === 'album')) {
|
if (essayParsed?.blocks.some((b) => b.type === 'album')) {
|
||||||
const byAlbum = new Map<string, typeof allAssets>();
|
const byAlbum = await loadAlbumBlocks(essayParsed.blocks, (albumId, error) => {
|
||||||
for (const b of essayParsed.blocks) {
|
// eslint-disable-next-line no-console
|
||||||
if (b.type !== 'album' || !b.albumId || byAlbum.has(b.albumId)) continue;
|
console.warn(`[essay] ${slug}: album ${albumId} could not be loaded:`, error);
|
||||||
try {
|
});
|
||||||
byAlbum.set(b.albumId, await immich.getAlbumAssetsRaw(b.albumId));
|
|
||||||
} catch (error) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.warn(`[essay] ${slug}: album ${b.albumId} could not be loaded:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const expanded = expandAlbumBlocks(
|
const expanded = expandAlbumBlocks(
|
||||||
essayParsed.blocks,
|
essayParsed.blocks,
|
||||||
(id) => byAlbum.get(id),
|
(id) => byAlbum.get(id),
|
||||||
@@ -494,7 +544,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,
|
||||||
})),
|
})),
|
||||||
]),
|
]),
|
||||||
@@ -542,6 +592,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
|||||||
watermark={config.watermark}
|
watermark={config.watermark}
|
||||||
proofing={essayProofing}
|
proofing={essayProofing}
|
||||||
allowMailto={config.proofing.allowMailto}
|
allowMailto={config.proofing.allowMailto}
|
||||||
|
encodedMailto={encodedMailto}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -568,6 +619,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
|||||||
config.exif.onHover && config.exif.camera,
|
config.exif.onHover && config.exif.camera,
|
||||||
config.exif.caption,
|
config.exif.caption,
|
||||||
config.albumDownloads[album.id] ? album.id : undefined,
|
config.albumDownloads[album.id] ? album.id : undefined,
|
||||||
|
zoomAlbumFor(album.id, result.subpage),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Password gate for protected albums
|
// Password gate for protected albums
|
||||||
@@ -590,6 +642,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
|||||||
showGear={config.exif.camera}
|
showGear={config.exif.camera}
|
||||||
proofing={proofingFor(result.subpage)}
|
proofing={proofingFor(result.subpage)}
|
||||||
allowMailto={config.proofing.allowMailto}
|
allowMailto={config.proofing.allowMailto}
|
||||||
|
encodedMailto={encodedMailto}
|
||||||
downloadArchiveUrl={config.albumDownloads[album.id] ? archiveUrl(album.id) : undefined}
|
downloadArchiveUrl={config.albumDownloads[album.id] ? archiveUrl(album.id) : undefined}
|
||||||
{...heroData}
|
{...heroData}
|
||||||
/>
|
/>
|
||||||
@@ -611,14 +664,23 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Batch-fetch ThumbHash for album cover placeholders
|
// Batch-fetch the cover assets: ThumbHash for the placeholders, and
|
||||||
const coverPlaceholders = await Promise.all(
|
// whether the cover was edited in Immich, which its URL has to say (#831).
|
||||||
albumsWithHero.map(async (album) => {
|
const coverAssets = await Promise.all(
|
||||||
if (!album.albumThumbnailAssetId) return null;
|
albumsWithHero.map((album) =>
|
||||||
const asset = await immich.getAssetInfo(album.albumThumbnailAssetId);
|
album.albumThumbnailAssetId ? immich.getAssetInfo(album.albumThumbnailAssetId) : null,
|
||||||
return asset ? assetPlaceholder(asset) : null;
|
),
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
const coverPlaceholders = coverAssets.map((asset) => (asset ? assetPlaceholder(asset) : null));
|
||||||
|
const albumsWithCover = albumsWithHero.map((album, i) => {
|
||||||
|
const asset = coverAssets[i];
|
||||||
|
return asset
|
||||||
|
? {
|
||||||
|
...album,
|
||||||
|
cover: { id: asset.id, isEdited: asset.isEdited, updatedAt: asset.updatedAt },
|
||||||
|
}
|
||||||
|
: album;
|
||||||
|
});
|
||||||
|
|
||||||
// 1-based position among the enabled subpages — drives the header kicker.
|
// 1-based position among the enabled subpages — drives the header kicker.
|
||||||
const enabledSubpages = config.subpages.filter((sp) => sp.enabled !== false);
|
const enabledSubpages = config.subpages.filter((sp) => sp.enabled !== false);
|
||||||
@@ -638,18 +700,28 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
|||||||
slug={slug}
|
slug={slug}
|
||||||
title={result.subpage.title || result.subpage.name}
|
title={result.subpage.title || result.subpage.name}
|
||||||
subtitle={result.subpage.subtitle}
|
subtitle={result.subpage.subtitle}
|
||||||
albums={albumsWithHero}
|
albums={albumsWithCover}
|
||||||
coverPlaceholders={coverPlaceholders}
|
coverPlaceholders={coverPlaceholders}
|
||||||
sections={result.subpage.sections}
|
sections={result.subpage.sections}
|
||||||
gridStyle={buildCoverGridStyle(result.subpage.coverGrid)}
|
gridStyle={buildCoverGridStyle(result.subpage.coverGrid)}
|
||||||
{...(subpageIndex >= 0 ? { index: subpageIndex + 1 } : {})}
|
{...(subpageIndex >= 0 ? { index: subpageIndex + 1 } : {})}
|
||||||
{...(nextSubpage
|
{...(nextSubpage
|
||||||
? { nextSubpage: { slug: nextSubpage.slug, name: nextSubpage.name } }
|
? {
|
||||||
|
nextSubpage: { slug: nextSubpage.slug, name: nextSubpage.title || nextSubpage.name },
|
||||||
|
}
|
||||||
: {})}
|
: {})}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A content page (#722). The admin refuses a page slug that collides with a
|
||||||
|
// subpage or an album, so the order here only matters for a hand-edited file.
|
||||||
|
if (path.length === 1) {
|
||||||
|
const page = await renderContentPage(slug);
|
||||||
|
if (page === 'not-found') notFound();
|
||||||
|
if (page) return page;
|
||||||
|
}
|
||||||
|
|
||||||
// Otherwise treat as a standalone album slug
|
// Otherwise treat as a standalone album slug
|
||||||
const album = await immich.getAlbumBySlug(slug, undefined, forceFresh);
|
const album = await immich.getAlbumBySlug(slug, undefined, forceFresh);
|
||||||
if (!album) {
|
if (!album) {
|
||||||
@@ -669,6 +741,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
|||||||
config.exif.onHover && config.exif.camera,
|
config.exif.onHover && config.exif.camera,
|
||||||
config.exif.caption,
|
config.exif.caption,
|
||||||
config.albumDownloads[album.id] ? album.id : undefined,
|
config.albumDownloads[album.id] ? album.id : undefined,
|
||||||
|
zoomAlbumFor(album.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Password gate for protected albums
|
// Password gate for protected albums
|
||||||
@@ -692,6 +765,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
|||||||
showGear={config.exif.camera}
|
showGear={config.exif.camera}
|
||||||
proofing={proofingFor()}
|
proofing={proofingFor()}
|
||||||
allowMailto={config.proofing.allowMailto}
|
allowMailto={config.proofing.allowMailto}
|
||||||
|
encodedMailto={encodedMailto}
|
||||||
downloadArchiveUrl={config.albumDownloads[album.id] ? archiveUrl(album.id) : undefined}
|
downloadArchiveUrl={config.albumDownloads[album.id] ? archiveUrl(album.id) : undefined}
|
||||||
{...heroData}
|
{...heroData}
|
||||||
/>
|
/>
|
||||||
|
|||||||
72
app/[...path]/photoItems.ts
Normal file
72
app/[...path]/photoItems.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Immich assets → the PhotoItem props the grid and lightbox render.
|
||||||
|
*
|
||||||
|
* Shared by the album routes and the client proofing page, so both render a
|
||||||
|
* photo exactly the same way.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ImmichAsset } from '@/lib/immich';
|
||||||
|
import { encodeAssetId } from '@/lib/tokens';
|
||||||
|
import {
|
||||||
|
assetAspectRatio,
|
||||||
|
assetCaption,
|
||||||
|
assetExifSummary,
|
||||||
|
assetPlaceholder,
|
||||||
|
downloadUrl,
|
||||||
|
exifUrl,
|
||||||
|
imageUrl,
|
||||||
|
videoUrl,
|
||||||
|
zoomUrl,
|
||||||
|
} from '@/lib/urls';
|
||||||
|
import { zoomDimensions, zoomSourceFor } from '@/lib/zoomSource';
|
||||||
|
import type { PhotoItem } from './PhotoGrid';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `showExif` covers the hover overlay only, and that overlay carries camera,
|
||||||
|
* lens and focal length — so it follows the `camera` group, not the panel.
|
||||||
|
*
|
||||||
|
* `showCaption` follows the `caption` group and decides whether the Immich
|
||||||
|
* description becomes alt text; see `assetCaption`.
|
||||||
|
*/
|
||||||
|
export function toPhotoItems(
|
||||||
|
assets: ImmichAsset[],
|
||||||
|
showExif: boolean,
|
||||||
|
showCaption: boolean,
|
||||||
|
/** The album offering downloads, or undefined when it does not. */
|
||||||
|
downloadAlbumId?: string,
|
||||||
|
/**
|
||||||
|
* The album whose page offers lightbox zoom, or undefined when zoom resolves
|
||||||
|
* off for it (#467). Each photo still has to be zoomable on its own.
|
||||||
|
*/
|
||||||
|
zoomAlbumId?: string,
|
||||||
|
): PhotoItem[] {
|
||||||
|
return assets
|
||||||
|
.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO')
|
||||||
|
.map((a) => {
|
||||||
|
const ph = assetPlaceholder(a);
|
||||||
|
const exif = showExif && a.type === 'IMAGE' ? assetExifSummary(a) : undefined;
|
||||||
|
const caption = assetCaption(a, showCaption);
|
||||||
|
const isVideo = a.type === 'VIDEO';
|
||||||
|
const zoomSize = zoomAlbumId && zoomSourceFor(a) ? zoomDimensions(a) : undefined;
|
||||||
|
return {
|
||||||
|
id: encodeAssetId(a.id),
|
||||||
|
type: isVideo ? 'video' : 'image',
|
||||||
|
thumbUrl: imageUrl(a, 'preview'),
|
||||||
|
previewUrl: imageUrl(a, 'preview'),
|
||||||
|
...(isVideo ? { videoUrl: videoUrl(a.id) } : {}),
|
||||||
|
exifUrl: exifUrl(a.id),
|
||||||
|
...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}),
|
||||||
|
...(exif ?? {}),
|
||||||
|
...(caption ? { caption } : {}),
|
||||||
|
...(downloadAlbumId ? { downloadUrl: downloadUrl(downloadAlbumId, a.id) } : {}),
|
||||||
|
...(zoomAlbumId && zoomSize
|
||||||
|
? {
|
||||||
|
zoomUrl: zoomUrl(zoomAlbumId, a.id),
|
||||||
|
zoomWidth: zoomSize.width,
|
||||||
|
zoomHeight: zoomSize.height,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
aspectRatio: assetAspectRatio(a),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
70
app/[...path]/routeExists.ts
Normal file
70
app/[...path]/routeExists.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Whether the catch-all has anything to show for a path — asked before the
|
||||||
|
* page streams, so that a miss can still answer with a real 404.
|
||||||
|
*
|
||||||
|
* Next commits to the status with the first byte. The page used to sit inside
|
||||||
|
* a loading.tsx Suspense boundary, so the skeleton went out as a 200 and the
|
||||||
|
* notFound() that followed could only add a noindex tag: every unknown slug, an
|
||||||
|
* offline subpage, a draft content page and `/<subpage>/<unknown>` were soft
|
||||||
|
* 404s. The page now calls this first, outside any boundary, and only then
|
||||||
|
* streams the skeleton around the part that fetches photos.
|
||||||
|
*
|
||||||
|
* Cost: it reads the config, the album list and content/pages/. The album list
|
||||||
|
* is the one the header nav already waits for before the first byte, so this
|
||||||
|
* adds no Immich request and no latency; the album's assets — the slow part —
|
||||||
|
* are still fetched behind the skeleton.
|
||||||
|
*
|
||||||
|
* It mirrors the order of gates and 404s in the page body, and may be looser
|
||||||
|
* than the body, never stricter. A path it passes that the body then rejects
|
||||||
|
* only loses the status (an album that vanished from Immich between the list
|
||||||
|
* and the asset fetch stays a soft 404); a path it rejects that the body would
|
||||||
|
* render is a page nobody can open.
|
||||||
|
*
|
||||||
|
* Errors propagate. An Immich outage with no stale list to fall back on must
|
||||||
|
* surface as an error, never as "this album does not exist".
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { immich } from '@/lib/immich';
|
||||||
|
import { isAuthenticated, isProtected } from '@/lib/auth';
|
||||||
|
import { isAdminAuthenticated } from '@/lib/admin/auth';
|
||||||
|
import { contentPageAccess } from './contentPage';
|
||||||
|
|
||||||
|
/** Whether `key` is password-protected and this request has not unlocked it. */
|
||||||
|
export async function isLocked(key: string, type: 'subpage' | 'album'): Promise<boolean> {
|
||||||
|
if (!isProtected(key, type)) return false;
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const getCookie = (name: string) => cookieStore.get(name)?.value;
|
||||||
|
return !isAuthenticated(key, getCookie, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `path` is already slug-normalised, as the page hands it over. */
|
||||||
|
export async function routeExists(path: readonly string[] | undefined): Promise<boolean> {
|
||||||
|
// One segment or two: nothing on this site lives deeper, for anyone.
|
||||||
|
if (!path || path.length === 0 || path.length > 2) return false;
|
||||||
|
if (await exists(path)) return true;
|
||||||
|
// Where a visitor gets the 404, a signed-in admin gets a diagnostic banner
|
||||||
|
// (or the draft content page).
|
||||||
|
return isAdminAuthenticated();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exists([first, second]: readonly string[]): Promise<boolean> {
|
||||||
|
if (second !== undefined) {
|
||||||
|
// A locked subpage shows its gate for any album slug below it. Answering
|
||||||
|
// 404 for the unknown ones would tell a visitor without the password which
|
||||||
|
// albums are behind it.
|
||||||
|
if (await isLocked(first, 'subpage')) return true;
|
||||||
|
return (await immich.findAlbumBySlug(second, first)) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (immich.isSubpageSlug(first)) {
|
||||||
|
if (await isLocked(first, 'subpage')) return true;
|
||||||
|
const result = await immich.getSubpageAlbums(first);
|
||||||
|
return !!result && result.albums.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = await contentPageAccess(first);
|
||||||
|
if (page) return page !== 'draft';
|
||||||
|
|
||||||
|
return (await immich.findAlbumBySlug(first)) !== null;
|
||||||
|
}
|
||||||
72
app/__tests__/gate-page.test.tsx
Normal file
72
app/__tests__/gate-page.test.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A locked site still serves /impressum and /privacy (proxy.ts, UNGATED_PAGES),
|
||||||
|
* and the gate is where a visitor who cannot get in finds them. A link to a
|
||||||
|
* page that is switched off would lead to a 404, so each appears only when its
|
||||||
|
* page exists.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
siteTitle: 'Test Folio',
|
||||||
|
lang: 'en',
|
||||||
|
legal: { enabled: true },
|
||||||
|
privacy: { enabled: true },
|
||||||
|
};
|
||||||
|
let privacy = true;
|
||||||
|
|
||||||
|
vi.mock('@/lib/config', () => ({
|
||||||
|
getConfig: () => config,
|
||||||
|
getConfigOrNull: () => config,
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/auth', () => ({
|
||||||
|
isSiteLocked: () => true,
|
||||||
|
SITE_AUTH_KEY: '__site__',
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/privacy', () => ({
|
||||||
|
privacyAvailable: () => privacy,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import GatePage from '../gate/page';
|
||||||
|
|
||||||
|
const html = () => renderToStaticMarkup(<GatePage />);
|
||||||
|
|
||||||
|
describe('site gate page', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
config.lang = 'en';
|
||||||
|
config.legal.enabled = true;
|
||||||
|
privacy = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links the legal notice and the privacy policy', () => {
|
||||||
|
const out = html();
|
||||||
|
expect(out).toContain('href="/impressum"');
|
||||||
|
expect(out).toContain('href="/privacy"');
|
||||||
|
expect(out).toContain('aria-label="Legal information"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves out a page that is switched off', () => {
|
||||||
|
config.legal.enabled = false;
|
||||||
|
expect(html()).not.toContain('href="/impressum"');
|
||||||
|
expect(html()).toContain('href="/privacy"');
|
||||||
|
|
||||||
|
config.legal.enabled = true;
|
||||||
|
privacy = false;
|
||||||
|
expect(html()).toContain('href="/impressum"');
|
||||||
|
expect(html()).not.toContain('href="/privacy"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders no empty link list when neither page exists', () => {
|
||||||
|
config.legal.enabled = false;
|
||||||
|
privacy = false;
|
||||||
|
expect(html()).not.toContain('<nav');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('labels the links in the site language', () => {
|
||||||
|
config.lang = 'de';
|
||||||
|
const out = html();
|
||||||
|
expect(out).toContain('>Impressum</a>');
|
||||||
|
expect(out).toContain('>Datenschutz</a>');
|
||||||
|
});
|
||||||
|
});
|
||||||
29
app/__tests__/not-found-metadata.test.ts
Normal file
29
app/__tests__/not-found-metadata.test.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A 404 decided before streaming — by proxy.ts, or by a page calling
|
||||||
|
* notFound() outside any Suspense boundary — is rendered from app/not-found.tsx
|
||||||
|
* inside the root layout, and Next takes its metadata from the layouts and
|
||||||
|
* that file, never from the page. Without metadata of its own the tab showed
|
||||||
|
* the bare site title, and the layout's `index, follow` sat next to Next's
|
||||||
|
* `noindex`; the soft-404 path meanwhile read "Not found".
|
||||||
|
*/
|
||||||
|
|
||||||
|
vi.mock('@/lib/i18n/server', async () => {
|
||||||
|
const { getDictionary } = await import('@/lib/i18n');
|
||||||
|
return { getServerDictionary: () => getDictionary('de') };
|
||||||
|
});
|
||||||
|
|
||||||
|
import * as notFoundModule from '../not-found';
|
||||||
|
import { getDictionary } from '@/lib/i18n';
|
||||||
|
|
||||||
|
describe('app/not-found.tsx metadata', () => {
|
||||||
|
it('titles a hard 404 from the dictionary and drops the layout robots tag', async () => {
|
||||||
|
const { generateMetadata } = notFoundModule as { generateMetadata?: () => unknown };
|
||||||
|
expect(typeof generateMetadata).toBe('function');
|
||||||
|
expect(await generateMetadata!()).toEqual({
|
||||||
|
title: getDictionary('de').error.notFoundTitle,
|
||||||
|
robots: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
68
app/about/__tests__/about-meta.test.ts
Normal file
68
app/about/__tests__/about-meta.test.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P-17: /about shared with no picture. A page's openGraph replaces the
|
||||||
|
* layout's whole, so leaving `images` out dropped the site card too.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let aboutMd = '';
|
||||||
|
|
||||||
|
vi.mock('fs', async (orig) => {
|
||||||
|
const actual = await orig<typeof import('fs')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
promises: {
|
||||||
|
...actual.promises,
|
||||||
|
readFile: async (path: string, enc: string) =>
|
||||||
|
String(path).endsWith('about.md')
|
||||||
|
? aboutMd
|
||||||
|
: actual.promises.readFile(path, enc as 'utf-8'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock('@/lib/config', () => ({
|
||||||
|
getConfig: () => ({ authSecret: 'test-secret', aboutEnabled: true }),
|
||||||
|
getConfigOrNull: () => ({ lang: 'en' }),
|
||||||
|
}));
|
||||||
|
vi.mock('@/lib/urls', () => ({
|
||||||
|
imageUrl: (ref: string | { id: string; isEdited?: boolean }, size: string) =>
|
||||||
|
typeof ref === 'string'
|
||||||
|
? `/api/image/tok-${ref}?size=${size}`
|
||||||
|
: `/api/image/tok-${ref.id}?size=${size}${ref.isEdited ? '&e=edit' : ''}`,
|
||||||
|
assetPlaceholder: () => null,
|
||||||
|
}));
|
||||||
|
const portraitAsset = vi.hoisted(() => ({ value: null as null | Record<string, unknown> }));
|
||||||
|
vi.mock('@/lib/immich', () => ({
|
||||||
|
immich: { getAssetInfo: async () => portraitAsset.value },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { generateMetadata } = await import('../page');
|
||||||
|
|
||||||
|
describe('/about metadata', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
aboutMd = '';
|
||||||
|
portraitAsset.value = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shares a portrait edited in Immich under its edited URL (#831)', async () => {
|
||||||
|
aboutMd = '---\nname: Ada\nportrait: portrait-uuid\n---\nHello.';
|
||||||
|
portraitAsset.value = { id: 'portrait-uuid', isEdited: true };
|
||||||
|
const m = await generateMetadata();
|
||||||
|
expect(m.openGraph?.images).toEqual(['/api/image/tok-portrait-uuid?size=preview&e=edit']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the portrait as the share image', async () => {
|
||||||
|
aboutMd = '---\nname: Ada\nportrait: portrait-uuid\n---\nHello.';
|
||||||
|
const m = await generateMetadata();
|
||||||
|
expect(m.openGraph?.images).toEqual(['/api/image/tok-portrait-uuid?size=preview']);
|
||||||
|
expect(m.twitter?.images).toEqual(['/api/image/tok-portrait-uuid?size=preview']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the generated card without a portrait', async () => {
|
||||||
|
aboutMd = '---\nname: Ada\n---\nHello.';
|
||||||
|
const m = await generateMetadata();
|
||||||
|
const [image] = m.openGraph?.images as string[];
|
||||||
|
expect(image).toMatch(/^\/api\/og\?/);
|
||||||
|
expect(new URL(image, 'https://x.test').searchParams.get('title')).toBe(m.title);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ import yaml from 'js-yaml';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { imageUrl, assetPlaceholder } from '@/lib/urls';
|
import { imageUrl, assetPlaceholder } from '@/lib/urls';
|
||||||
|
import { ogImageUrl } from '@/lib/ogImage';
|
||||||
import { immich } from '@/lib/immich';
|
import { immich } from '@/lib/immich';
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
import { getServerDictionary } from '@/lib/i18n/server';
|
import { getServerDictionary } from '@/lib/i18n/server';
|
||||||
@@ -56,6 +57,17 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
const t = getServerDictionary();
|
const t = getServerDictionary();
|
||||||
const title = meta.name ? t.about.metaTitle(meta.name) : t.about.title;
|
const title = meta.name ? t.about.metaTitle(meta.name) : t.about.title;
|
||||||
const description = body ? body.slice(0, 160).replace(/\s+/g, ' ').trim() : undefined;
|
const description = body ? body.slice(0, 160).replace(/\s+/g, ' ').trim() : undefined;
|
||||||
|
// A page's openGraph replaces the layout's whole, images included, so
|
||||||
|
// leaving them out shared /about with no picture at all. The portrait is the
|
||||||
|
// obvious one (it is a published asset); without it, the generated card.
|
||||||
|
// The portrait's asset for its edit marker (#831), from the cache the page
|
||||||
|
// body reads as well.
|
||||||
|
const portraitAsset = meta.portrait
|
||||||
|
? await immich.getAssetInfo(meta.portrait).catch(() => null)
|
||||||
|
: null;
|
||||||
|
const ogImage = meta.portrait
|
||||||
|
? imageUrl(portraitAsset ?? meta.portrait, 'preview')
|
||||||
|
: ogImageUrl(title);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
@@ -63,11 +75,13 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
openGraph: {
|
openGraph: {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
images: [ogImage],
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
images: [ogImage],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -89,7 +103,7 @@ export default async function AboutPage() {
|
|||||||
<div className="about__portrait-col">
|
<div className="about__portrait-col">
|
||||||
{meta.portrait ? (
|
{meta.portrait ? (
|
||||||
<Image
|
<Image
|
||||||
src={imageUrl(meta.portrait, 'preview')}
|
src={imageUrl(portraitAsset ?? meta.portrait, 'preview')}
|
||||||
alt={meta.name || t.about.portraitAlt}
|
alt={meta.name || t.about.portraitAlt}
|
||||||
className="about__portrait"
|
className="about__portrait"
|
||||||
fill
|
fill
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -47,8 +49,9 @@ export default function AdminShell({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
if (authenticated === null) {
|
if (authenticated === null) {
|
||||||
return (
|
return (
|
||||||
<div className="admin-loading">
|
<div className="admin-loading" role="status">
|
||||||
<div className="admin-spinner" />
|
<div className="admin-spinner" aria-hidden="true" />
|
||||||
|
<span className="sr-only">Loading admin panel</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -79,5 +82,11 @@ export default function AdminShell({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <AdminDashboard onLogout={() => setAuthenticated(false)}>{children}</AdminDashboard>;
|
return (
|
||||||
|
<NotificationProvider>
|
||||||
|
<ConfirmProvider>
|
||||||
|
<AdminDashboard onLogout={() => setAuthenticated(false)}>{children}</AdminDashboard>
|
||||||
|
</ConfirmProvider>
|
||||||
|
</NotificationProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
58
app/admin/__tests__/AdminOverview.test.tsx
Normal file
58
app/admin/__tests__/AdminOverview.test.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||||
|
import { render, screen, cleanup, waitFor } from '@testing-library/react';
|
||||||
|
import AdminOverview from '../components/AdminOverview';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Every admin endpoint answers 404 except gallery.yaml, which holds `gallery`. */
|
||||||
|
function stubGallery(gallery: unknown) {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async (url: string) =>
|
||||||
|
url === '/api/admin/gallery'
|
||||||
|
? new Response(JSON.stringify({ gallery, version: 'v' }), { status: 200 })
|
||||||
|
: new Response(JSON.stringify({ error: 'nope' }), { status: 404 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The "Pages" tile: its number and the note under it, once loaded. */
|
||||||
|
async function pagesTile() {
|
||||||
|
const label = await screen.findByText('Pages');
|
||||||
|
const tile = label.closest('a')!;
|
||||||
|
const value = () => tile.querySelector('.overview-stat-value')!.textContent;
|
||||||
|
await waitFor(() => expect(value()).not.toBe('–'));
|
||||||
|
return { value: value(), note: tile.querySelector('.overview-stat-note')!.textContent };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AdminOverview — Pages tile', () => {
|
||||||
|
it('counts subpages written in the older map form of gallery.yaml', async () => {
|
||||||
|
// `subpages:` as a map of name → albums (or name → object) is still a
|
||||||
|
// valid gallery.yaml — getConfig() and the page builder both read it.
|
||||||
|
// The overview called .filter() on it, and the first admin screen crashed.
|
||||||
|
stubGallery({
|
||||||
|
subpages: {
|
||||||
|
Weddings: ['11111111-1111-4111-8111-111111111111'],
|
||||||
|
Travel: { albums: ['22222222-2222-4222-8222-222222222222'], enabled: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
render(<AdminOverview />);
|
||||||
|
expect(await pagesTile()).toEqual({ value: '1', note: '1 switched off' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves content-page menu entries out of the list form', async () => {
|
||||||
|
stubGallery({
|
||||||
|
subpages: [
|
||||||
|
{ name: 'Weddings', albums: ['11111111-1111-4111-8111-111111111111'] },
|
||||||
|
{ page: 'pricing' },
|
||||||
|
{ name: 'Old', albums: [], enabled: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
render(<AdminOverview />);
|
||||||
|
expect(await pagesTile()).toEqual({ value: '1', note: '1 switched off' });
|
||||||
|
});
|
||||||
|
});
|
||||||
76
app/admin/__tests__/AssetPicker.test.tsx
Normal file
76
app/admin/__tests__/AssetPicker.test.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
|
||||||
|
import { render, screen, cleanup, fireEvent, act } from '@testing-library/react';
|
||||||
|
import AssetPicker from '../components/AssetPicker';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fetch whose answers the test hands out by hand, in any order — the
|
||||||
|
* situation of a slow request still running when the operator moves on.
|
||||||
|
*/
|
||||||
|
function deferredFetch() {
|
||||||
|
const pending: Array<{ url: string; resolve: (body: unknown) => void }> = [];
|
||||||
|
const fetchMock = vi.fn(
|
||||||
|
(url: string) =>
|
||||||
|
new Promise<Response>((resolve) => {
|
||||||
|
pending.push({
|
||||||
|
url,
|
||||||
|
resolve: (body) =>
|
||||||
|
resolve({ ok: true, status: 200, json: async () => body } as unknown as Response),
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
/** Answer the first open request whose URL contains `match`. */
|
||||||
|
const answer = async (match: string, body: unknown) => {
|
||||||
|
const i = pending.findIndex((p) => p.url.includes(match));
|
||||||
|
if (i === -1) throw new Error(`no pending request for ${match}`);
|
||||||
|
const [req] = pending.splice(i, 1);
|
||||||
|
await act(async () => req.resolve(body));
|
||||||
|
};
|
||||||
|
return { fetchMock, answer };
|
||||||
|
}
|
||||||
|
|
||||||
|
const asset = (id: string, name: string) => ({
|
||||||
|
id,
|
||||||
|
originalFileName: name,
|
||||||
|
fileCreatedAt: '2024-05-17T10:00:00.000Z',
|
||||||
|
isFavorite: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
let net: ReturnType<typeof deferredFetch>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
net = deferredFetch();
|
||||||
|
vi.stubGlobal('fetch', net.fetchMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AssetPicker', () => {
|
||||||
|
it('shows the tab it is on when an older request answers last', async () => {
|
||||||
|
render(<AssetPicker onSelect={() => {}} onClose={() => {}} />);
|
||||||
|
// Opens on Favorites; the operator switches to All Photos before it loads.
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /All Photos/ }));
|
||||||
|
|
||||||
|
await net.answer('favorites=false', { assets: [asset('a1', 'all.jpg')], nextPage: null });
|
||||||
|
await net.answer('favorites=true', { assets: [asset('f1', 'fav.jpg')], nextPage: null });
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: /all\.jpg/ })).toBeTruthy();
|
||||||
|
expect(screen.queryByRole('button', { name: /fav\.jpg/ })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the spinner until the current request answers', async () => {
|
||||||
|
render(<AssetPicker onSelect={() => {}} onClose={() => {}} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /All Photos/ }));
|
||||||
|
|
||||||
|
// The stale Favorites answer must not end the All Photos loading state.
|
||||||
|
await net.answer('favorites=true', { assets: [asset('f1', 'fav.jpg')], nextPage: null });
|
||||||
|
expect(screen.queryByRole('button', { name: /fav\.jpg/ })).toBeNull();
|
||||||
|
expect(screen.queryByText(/No favorite photos found/)).toBeNull();
|
||||||
|
|
||||||
|
await net.answer('favorites=false', { assets: [asset('a1', 'all.jpg')], nextPage: null });
|
||||||
|
expect(screen.getByRole('button', { name: /all\.jpg/ })).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
105
app/admin/__tests__/ConfirmDialog.test.tsx
Normal file
105
app/admin/__tests__/ConfirmDialog.test.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { ConfirmProvider, useChoice, useConfirm } from '../components/ConfirmDialog';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
/** UX stage 3 (#694 §3): what replaced window.confirm in the admin. */
|
||||||
|
function Trigger({ onResult }: { onResult: (ok: boolean) => void }) {
|
||||||
|
const confirm = useConfirm();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={async () =>
|
||||||
|
onResult(
|
||||||
|
await confirm({
|
||||||
|
title: 'Delete it?',
|
||||||
|
message: 'Gone for good.',
|
||||||
|
confirmLabel: 'Delete',
|
||||||
|
danger: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
go
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const results: boolean[] = [];
|
||||||
|
render(
|
||||||
|
<ConfirmProvider>
|
||||||
|
<Trigger onResult={(ok) => results.push(ok)} />
|
||||||
|
</ConfirmProvider>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText('go'));
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ConfirmDialog', () => {
|
||||||
|
it('asks in an alertdialog named by its title', () => {
|
||||||
|
setup();
|
||||||
|
const dialog = screen.getByRole('alertdialog', { name: 'Delete it?' });
|
||||||
|
expect(dialog.textContent).toContain('Gone for good.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves true on confirm and closes', async () => {
|
||||||
|
const results = setup();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
|
||||||
|
await waitFor(() => expect(results).toEqual([true]));
|
||||||
|
expect(screen.queryByRole('alertdialog')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves false on cancel, and on Escape', async () => {
|
||||||
|
const results = setup();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||||
|
await waitFor(() => expect(results).toEqual([false]));
|
||||||
|
fireEvent.click(screen.getByText('go'));
|
||||||
|
fireEvent.keyDown(document, { key: 'Escape' });
|
||||||
|
await waitFor(() => expect(results).toEqual([false, false]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The concurrent-edit prompt (#601): more than two ways out. */
|
||||||
|
function ChoiceTrigger({ onResult }: { onResult: (id: string | null) => void }) {
|
||||||
|
const choose = useChoice();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={async () =>
|
||||||
|
onResult(
|
||||||
|
await choose({
|
||||||
|
title: 'Changed elsewhere',
|
||||||
|
actions: [
|
||||||
|
{ id: 'overwrite', label: 'Overwrite anyway' },
|
||||||
|
{ id: 'reload', label: 'Reload (discard mine)' },
|
||||||
|
],
|
||||||
|
cancelLabel: 'Keep editing',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
choose
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useChoice', () => {
|
||||||
|
it('resolves to the picked action, or null on cancel', async () => {
|
||||||
|
const results: (string | null)[] = [];
|
||||||
|
render(
|
||||||
|
<ConfirmProvider>
|
||||||
|
<ChoiceTrigger onResult={(id) => results.push(id)} />
|
||||||
|
</ConfirmProvider>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText('choose'));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Reload (discard mine)' }));
|
||||||
|
await waitFor(() => expect(results).toEqual(['reload']));
|
||||||
|
fireEvent.click(screen.getByText('choose'));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Overwrite anyway' }));
|
||||||
|
await waitFor(() => expect(results).toEqual(['reload', 'overwrite']));
|
||||||
|
fireEvent.click(screen.getByText('choose'));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Keep editing' }));
|
||||||
|
await waitFor(() => expect(results).toEqual(['reload', 'overwrite', null]));
|
||||||
|
});
|
||||||
|
});
|
||||||
57
app/admin/__tests__/NewPageDialog.test.tsx
Normal file
57
app/admin/__tests__/NewPageDialog.test.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||||
|
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
|
||||||
|
import NewPageDialog from '../components/page-builder/NewPageDialog';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const taken = { subpages: [], albums: [], journal: [] };
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const onCreate = vi.fn();
|
||||||
|
render(
|
||||||
|
<NewPageDialog
|
||||||
|
taken={taken}
|
||||||
|
existingPageSlugs={[]}
|
||||||
|
creating={false}
|
||||||
|
onCreate={onCreate}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
return { onCreate, slug: screen.getByLabelText('URL') as HTMLInputElement };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Type one key at a time into a controlled input, as a person does. */
|
||||||
|
function typeKeys(input: HTMLInputElement, text: string) {
|
||||||
|
for (const ch of text) fireEvent.change(input, { target: { value: input.value + ch } });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('NewPageDialog slug field', () => {
|
||||||
|
it('takes a hyphen typed between two words', () => {
|
||||||
|
const { slug, onCreate } = setup();
|
||||||
|
fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'About' } });
|
||||||
|
// Select all and retype: the first key replaces the derived slug.
|
||||||
|
fireEvent.change(slug, { target: { value: 'a' } });
|
||||||
|
typeKeys(slug, 'bout-us');
|
||||||
|
expect(slug.value).toBe('about-us');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Create page' }));
|
||||||
|
expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ slug: 'about-us' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates the finished slug when the field ends in a hyphen', () => {
|
||||||
|
const { slug, onCreate } = setup();
|
||||||
|
fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'Pricing' } });
|
||||||
|
typeKeys(slug, '-');
|
||||||
|
expect(slug.value).toBe('pricing-');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Create page' }));
|
||||||
|
expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ slug: 'pricing' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives a readable slug from a German title', () => {
|
||||||
|
const { slug } = setup();
|
||||||
|
fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'Preise für Paare' } });
|
||||||
|
expect(slug.value).toBe('preise-fur-paare');
|
||||||
|
});
|
||||||
|
});
|
||||||
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', () => {
|
||||||
|
|||||||
61
app/admin/__tests__/SortableTiles.test.tsx
Normal file
61
app/admin/__tests__/SortableTiles.test.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { render, screen, cleanup } from '@testing-library/react';
|
||||||
|
import { DndContext } from '@dnd-kit/core';
|
||||||
|
import { SortableContext } from '@dnd-kit/sortable';
|
||||||
|
import { SortablePageRow, SortableHeroTile } from '../components/page-builder/SortableTiles';
|
||||||
|
import { dndAnnouncements } from '../components/dndAnnouncements';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QA A-20: the sortable attributes (role="button", tabindex) sat on the whole
|
||||||
|
* row, which also holds the row's own button — nested interactive content. They
|
||||||
|
* belong on the drag handle. And the drag announcements read out internal ids.
|
||||||
|
*/
|
||||||
|
describe('sortable rows', () => {
|
||||||
|
it('put the sortable role on the handle, not around the row button', () => {
|
||||||
|
render(
|
||||||
|
<DndContext>
|
||||||
|
<SortableContext items={['page-pricing']}>
|
||||||
|
<SortablePageRow id="page-pricing" title="Pricing" isActive={false} onClick={() => {}} />
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>,
|
||||||
|
);
|
||||||
|
const handle = screen.getByLabelText('Reorder Pricing');
|
||||||
|
expect(handle.getAttribute('role')).toBe('button');
|
||||||
|
for (const button of screen.getAllByRole('button')) {
|
||||||
|
expect(button.parentElement?.closest('[role="button"]')).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the hero tile remove button out of the handle', () => {
|
||||||
|
render(
|
||||||
|
<DndContext>
|
||||||
|
<SortableContext items={['hero-0']}>
|
||||||
|
<SortableHeroTile id="abc" index={0} onRemove={() => {}} />
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>,
|
||||||
|
);
|
||||||
|
const remove = screen.getByTitle('Remove');
|
||||||
|
expect(remove.parentElement?.closest('[role="button"]')).toBeNull();
|
||||||
|
expect(screen.getByLabelText('Reorder hero photo 1').getAttribute('role')).toBe('button');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dndAnnouncements', () => {
|
||||||
|
const label = (id: string | number) => (id === 'page-pricing' ? 'page "Pricing"' : 'page "FAQ"');
|
||||||
|
const a = dndAnnouncements(label);
|
||||||
|
const active = { id: 'page-pricing' } as never;
|
||||||
|
const over = { id: 'subpage-3' } as never;
|
||||||
|
|
||||||
|
it('speaks the names the list shows, not the ids', () => {
|
||||||
|
expect(a.onDragStart({ active })).toBe('Picked up page "Pricing".');
|
||||||
|
expect(a.onDragEnd({ active, over } as never)).toBe(
|
||||||
|
'Page "Pricing" was dropped at the position of page "FAQ".',
|
||||||
|
);
|
||||||
|
expect(a.onDragCancel({ active, over: null } as never)).toBe(
|
||||||
|
'Moving page "Pricing" was cancelled.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,6 +35,7 @@ const everyOption: Required<Omit<AlbumEntryObject, 'grid'>> & Pick<AlbumEntryObj
|
|||||||
assetOrder: ['33333333-3333-3333-3333-333333333333'],
|
assetOrder: ['33333333-3333-3333-3333-333333333333'],
|
||||||
coverPosition: '50% 25%',
|
coverPosition: '50% 25%',
|
||||||
download: true,
|
download: true,
|
||||||
|
zoom: false,
|
||||||
location: 'hidden',
|
location: 'hidden',
|
||||||
grid: { columns: 4, gap: 8, aspectRatio: '3/2', layout: 'masonry' },
|
grid: { columns: 4, gap: 8, aspectRatio: '3/2', layout: 'masonry' },
|
||||||
};
|
};
|
||||||
@@ -47,6 +48,17 @@ describe('album entry round trip', () => {
|
|||||||
expect(serialized).toEqual({ [uuid]: everyOption });
|
expect(serialized).toEqual({ [uuid]: everyOption });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps zoom: false, which switches zoom off for the album (#467)', () => {
|
||||||
|
const parsed = parseAlbumEntries([{ [uuid]: { zoom: false } }]);
|
||||||
|
expect(parsed[0].zoom).toBe(false);
|
||||||
|
expect(hasAlbumOptions(parsed[0])).toBe(true);
|
||||||
|
expect(serializeAlbumEntries(parsed)).toEqual([{ [uuid]: { zoom: false } }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes no zoom key while the album inherits', () => {
|
||||||
|
expect(serializeAlbumEntries([{ id: uuid, zoom: undefined }])).toEqual([uuid]);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps map precision across a save', () => {
|
it('keeps map precision across a save', () => {
|
||||||
const parsed = parseAlbumEntries([{ [uuid]: { location: 'city' } }]);
|
const parsed = parseAlbumEntries([{ [uuid]: { location: 'city' } }]);
|
||||||
|
|
||||||
|
|||||||
35
app/admin/__tests__/analyticsDays.test.ts
Normal file
35
app/admin/__tests__/analyticsDays.test.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { lastDays } from '../components/analyticsDays';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Last 14 Days" used to show the last 14 days that had any views, so quiet
|
||||||
|
* days vanished and the chart could span a month (QA A-16).
|
||||||
|
*/
|
||||||
|
describe('lastDays', () => {
|
||||||
|
const days = {
|
||||||
|
'2026-08-28': { pageviews: 4 },
|
||||||
|
'2026-09-20': { pageviews: 6 },
|
||||||
|
'2026-09-29': { pageviews: 16 },
|
||||||
|
};
|
||||||
|
|
||||||
|
it('returns every calendar day up to today, with 0 for days without views', () => {
|
||||||
|
const out = lastDays(days, 14, new Date('2026-09-29T12:00:00Z'));
|
||||||
|
expect(out).toHaveLength(14);
|
||||||
|
expect(out[0]).toEqual({ date: '2026-09-16', pageviews: 0 });
|
||||||
|
expect(out[4]).toEqual({ date: '2026-09-20', pageviews: 6 });
|
||||||
|
expect(out[13]).toEqual({ date: '2026-09-29', pageviews: 16 });
|
||||||
|
// Older recorded days are outside the window, not pulled into it.
|
||||||
|
expect(out.find((d) => d.date === '2026-08-28')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts days in UTC, the way the tracker keys them', () => {
|
||||||
|
// 23:30 UTC on the 29th is already the 30th in Berlin.
|
||||||
|
const out = lastDays(days, 2, new Date('2026-09-29T23:30:00Z'));
|
||||||
|
expect(out.map((d) => d.date)).toEqual(['2026-09-28', '2026-09-29']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('crosses a month boundary without skipping or repeating a day', () => {
|
||||||
|
const out = lastDays({}, 3, new Date('2026-10-01T00:00:00Z'));
|
||||||
|
expect(out.map((d) => d.date)).toEqual(['2026-09-29', '2026-09-30', '2026-10-01']);
|
||||||
|
});
|
||||||
|
});
|
||||||
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
176
app/admin/__tests__/gallery-ops.test.ts
Normal file
176
app/admin/__tests__/gallery-ops.test.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import * as ops from '../components/page-builder/galleryOps';
|
||||||
|
import type { GalleryState } from '../components/page-builder/galleryYaml';
|
||||||
|
|
||||||
|
/** #608: the page builder's edits, out of the component and under test. */
|
||||||
|
|
||||||
|
const state = (): GalleryState => ({
|
||||||
|
hero: ['h1', 'h2', 'h3'],
|
||||||
|
albums: [{ id: 'a1' }, { id: 'a2' }],
|
||||||
|
subpages: [
|
||||||
|
{ name: 'Japan', albums: [{ id: 'j1' }, { id: 'j2' }] },
|
||||||
|
{
|
||||||
|
name: 'Trips',
|
||||||
|
enabled: false,
|
||||||
|
albums: [],
|
||||||
|
sections: [{ title: 'North', albums: [{ id: 'n1' }] }],
|
||||||
|
},
|
||||||
|
{ name: 'Korea', albums: [{ id: 'k1' }] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('album lists by address', () => {
|
||||||
|
it('adds to the standalone list, a subpage and a section', () => {
|
||||||
|
let g = ops.addAlbum(state(), { type: 'standalone' }, { id: 'x' });
|
||||||
|
expect(g.albums.map((a) => a.id)).toEqual(['a1', 'a2', 'x']);
|
||||||
|
g = ops.addAlbum(g, { type: 'subpage', subpageIndex: 0 }, { id: 'y' });
|
||||||
|
expect(g.subpages[0].albums.map((a) => a.id)).toEqual(['j1', 'j2', 'y']);
|
||||||
|
g = ops.addAlbum(g, { type: 'section', subpageIndex: 1, sectionIndex: 0 }, { id: 'z' });
|
||||||
|
expect(g.subpages[1].sections![0].albums.map((a) => a.id)).toEqual(['n1', 'z']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates, removes and moves one entry and leaves its neighbours alone', () => {
|
||||||
|
const at = { type: 'subpage' as const, subpageIndex: 0 };
|
||||||
|
let g = ops.updateAlbum(state(), at, 1, { title: 'Tokyo', sort: 'manual' });
|
||||||
|
expect(g.subpages[0].albums).toEqual([
|
||||||
|
{ id: 'j1' },
|
||||||
|
{ id: 'j2', title: 'Tokyo', sort: 'manual' },
|
||||||
|
]);
|
||||||
|
g = ops.moveAlbum(g, at, 1, 0);
|
||||||
|
expect(g.subpages[0].albums.map((a) => a.id)).toEqual(['j2', 'j1']);
|
||||||
|
g = ops.removeAlbum(g, at, 0);
|
||||||
|
expect(g.subpages[0].albums.map((a) => a.id)).toEqual(['j1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never mutates the state it was given', () => {
|
||||||
|
const before = state();
|
||||||
|
const copy = JSON.parse(JSON.stringify(before));
|
||||||
|
ops.removeAlbum(before, { type: 'section', subpageIndex: 1, sectionIndex: 0 }, 0);
|
||||||
|
ops.updateSubpage(before, 0, { title: 'x' });
|
||||||
|
ops.moveHero(before, 0, 2);
|
||||||
|
expect(before).toEqual(copy);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the state as it is for an address that no longer exists', () => {
|
||||||
|
const g = state();
|
||||||
|
expect(ops.addAlbum(g, { type: 'subpage', subpageIndex: 9 }, { id: 'x' })).toBe(g);
|
||||||
|
expect(ops.removeAlbum(g, { type: 'section', subpageIndex: 0, sectionIndex: 0 }, 0)).toBe(g);
|
||||||
|
expect(ops.albumsAt(g, { type: 'section', subpageIndex: 1, sectionIndex: 5 })).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hero, subpages and sections', () => {
|
||||||
|
it('adds, moves and removes hero photos', () => {
|
||||||
|
let g = ops.addHero(state(), 'h4');
|
||||||
|
g = ops.moveHero(g, 3, 0);
|
||||||
|
expect(ops.removeHero(g, 1).hero).toEqual(['h4', 'h2', 'h3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names a new subpage after its position and starts it empty', () => {
|
||||||
|
const g = ops.addSubpage(state());
|
||||||
|
expect(g.subpages[3]).toEqual({ name: 'New subpage 4', albums: [], sections: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds, updates and removes sections', () => {
|
||||||
|
let g = ops.addSection(state(), 0);
|
||||||
|
expect(g.subpages[0].sections).toEqual([{ title: 'New Section', albums: [] }]);
|
||||||
|
g = ops.updateSection(g, 0, 0, { title: 'Tokyo', description: 'Night' });
|
||||||
|
expect(g.subpages[0].sections![0]).toEqual({
|
||||||
|
title: 'Tokyo',
|
||||||
|
description: 'Night',
|
||||||
|
albums: [],
|
||||||
|
});
|
||||||
|
expect(ops.removeSection(g, 0, 0).subpages[0].sections).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reading', () => {
|
||||||
|
it('numbers subpages over the enabled ones only', () => {
|
||||||
|
const g = state();
|
||||||
|
expect(ops.enabledPosition(g, 0)).toBe(1);
|
||||||
|
expect(ops.enabledPosition(g, 1)).toBeUndefined();
|
||||||
|
expect(ops.enabledPosition(g, 2)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collects every album in use, sections included', () => {
|
||||||
|
expect([...ops.usedAlbumIds(state())].sort()).toEqual(['a1', 'a2', 'j1', 'j2', 'k1', 'n1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The open subpage sheet follows its page through a drag: the page itself,
|
||||||
|
* or a neighbour moving across it, shifts its index.
|
||||||
|
*/
|
||||||
|
it.each([
|
||||||
|
[null, 0, 2, null],
|
||||||
|
[1, 1, 2, 2],
|
||||||
|
[1, 0, 2, 0],
|
||||||
|
[1, 2, 0, 2],
|
||||||
|
[0, 1, 2, 0],
|
||||||
|
])('keeps sheet %s on its page when %s moves to %s', (open, from, to, expected) => {
|
||||||
|
expect(ops.followMovedIndex(open, from, to)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('menu with content pages (#722)', () => {
|
||||||
|
const withPages = (): GalleryState => ({
|
||||||
|
...state(),
|
||||||
|
// pricing before Japan, faq between Trips and Korea.
|
||||||
|
pageRefs: [
|
||||||
|
{ slug: 'pricing', position: 0 },
|
||||||
|
{ slug: 'faq', position: 2 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const labels = (g: GalleryState) =>
|
||||||
|
ops.menuItems(g).map((i) => (i.kind === 'page' ? `page:${i.slug}` : g.subpages[i.index].name));
|
||||||
|
|
||||||
|
it('interleaves pages and subpages', () => {
|
||||||
|
expect(labels(withPages())).toEqual(['page:pricing', 'Japan', 'Trips', 'page:faq', 'Korea']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves a page between subpages', () => {
|
||||||
|
const g = ops.moveMenuItem(withPages(), 0, 2);
|
||||||
|
expect(labels(g)).toEqual(['Japan', 'Trips', 'page:pricing', 'page:faq', 'Korea']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves a subpage past a page and follows the open sheet', () => {
|
||||||
|
const before = withPages();
|
||||||
|
// Japan (row 1) to the end (row 4).
|
||||||
|
expect(ops.followMenuMove(before, 0, 1, 4)).toBe(2);
|
||||||
|
const g = ops.moveMenuItem(before, 1, 4);
|
||||||
|
expect(labels(g)).toEqual(['page:pricing', 'Trips', 'page:faq', 'Korea', 'Japan']);
|
||||||
|
expect(g.subpages.map((s) => s.name)).toEqual(['Trips', 'Korea', 'Japan']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts a page in the menu at the end, or before a row, and takes it out', () => {
|
||||||
|
let g = ops.setPageInMenu(state(), 'about-me', true);
|
||||||
|
expect(labels(g)).toEqual(['Japan', 'Trips', 'Korea', 'page:about-me']);
|
||||||
|
g = ops.setPageInMenu(g, 'rates', true, 1);
|
||||||
|
expect(labels(g)).toEqual(['Japan', 'page:rates', 'Trips', 'Korea', 'page:about-me']);
|
||||||
|
g = ops.setPageInMenu(g, 'rates', false);
|
||||||
|
expect(labels(g)).toEqual(['Japan', 'Trips', 'Korea', 'page:about-me']);
|
||||||
|
expect(ops.isPageInMenu(g, 'rates')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps pages in place when a subpage is removed', () => {
|
||||||
|
const g = ops.removeSubpage(withPages(), 0);
|
||||||
|
expect(labels(g)).toEqual(['page:pricing', 'Trips', 'page:faq', 'Korea']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows a rename', () => {
|
||||||
|
const g = ops.renamePageRef(withPages(), 'faq', 'questions');
|
||||||
|
expect(labels(g)).toContain('page:questions');
|
||||||
|
expect(labels(g)).not.toContain('page:faq');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addHeroes (#602)', () => {
|
||||||
|
it('appends a multi-pick in pick order, skipping duplicates', () => {
|
||||||
|
expect(ops.addHeroes(state(), ['h9', 'h2', 'h8', 'h9']).hero).toEqual([
|
||||||
|
'h1',
|
||||||
|
'h2',
|
||||||
|
'h3',
|
||||||
|
'h9',
|
||||||
|
'h8',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
201
app/admin/__tests__/gallery-yaml.test.ts
Normal file
201
app/admin/__tests__/gallery-yaml.test.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parseGalleryYaml, serializeGallery } from '../components/page-builder/galleryYaml';
|
||||||
|
import type { GalleryYaml } from '@/lib/config/schema';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #608: gallery.yaml ⇄ the page builder's model. This is what decides what the
|
||||||
|
* builder writes on save, and until it left PageBuilder.tsx nothing tested it.
|
||||||
|
* Album entries themselves are covered in album-entries-round-trip.test.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const A = '11111111-1111-1111-1111-111111111111';
|
||||||
|
const B = '22222222-2222-2222-2222-222222222222';
|
||||||
|
|
||||||
|
/** Load and save once, the way the builder does without any edit in between. */
|
||||||
|
const roundTrip = (yaml: Record<string, unknown>) => serializeGallery(parseGalleryYaml(yaml));
|
||||||
|
|
||||||
|
describe('gallery.yaml round trip', () => {
|
||||||
|
it('keeps hero photos, standalone albums and a plain subpage', () => {
|
||||||
|
const yaml = {
|
||||||
|
hero: ['h1', 'h2'],
|
||||||
|
albums: [A],
|
||||||
|
subpages: [{ name: 'Japan', albums: [B] }],
|
||||||
|
};
|
||||||
|
expect(roundTrip(yaml)).toEqual(yaml);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns a single hero string into a list', () => {
|
||||||
|
expect(roundTrip({ hero: 'h1' })).toEqual({ hero: ['h1'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves empty groups out instead of writing empty lists', () => {
|
||||||
|
expect(roundTrip({ hero: [], albums: [], subpages: [] })).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps every subpage field the builder knows', () => {
|
||||||
|
const sp = {
|
||||||
|
name: 'Clients',
|
||||||
|
title: 'For clients',
|
||||||
|
subtitle: 'Handover',
|
||||||
|
password: 'scrypt:aa:bb',
|
||||||
|
enabled: false,
|
||||||
|
hidden: true,
|
||||||
|
essayText: 'Text',
|
||||||
|
essayFile: 'story',
|
||||||
|
grid: { columns: 4, layout: 'justified' },
|
||||||
|
coverGrid: { columns: 2, gap: 8 },
|
||||||
|
location: 'city',
|
||||||
|
albums: [A],
|
||||||
|
};
|
||||||
|
expect(roundTrip({ subpages: [sp] })).toEqual({ subpages: [sp] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes enabled and hidden only when they differ from the default', () => {
|
||||||
|
const out = roundTrip({ subpages: [{ name: 'P', enabled: true, hidden: false, albums: [A] }] });
|
||||||
|
expect(out).toEqual({ subpages: [{ name: 'P', albums: [A] }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps sections, and page-level albums next to them only when there are any', () => {
|
||||||
|
const withSections = {
|
||||||
|
subpages: [
|
||||||
|
{
|
||||||
|
name: 'Trips',
|
||||||
|
sections: [
|
||||||
|
{ title: 'North', description: 'Cold', albums: [A] },
|
||||||
|
{ title: 'South', albums: [B] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(roundTrip(withSections)).toEqual(withSections);
|
||||||
|
|
||||||
|
const both = {
|
||||||
|
subpages: [{ name: 'Trips', albums: [B], sections: [{ title: 'North', albums: [A] }] }],
|
||||||
|
};
|
||||||
|
expect(roundTrip(both)).toEqual({
|
||||||
|
subpages: [{ name: 'Trips', sections: [{ title: 'North', albums: [A] }], albums: [B] }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts the older name → albums map into the list the builder writes', () => {
|
||||||
|
expect(
|
||||||
|
roundTrip({ subpages: { Japan: [A], Korea: { title: 'South Korea', albums: [B] } } }),
|
||||||
|
).toEqual({
|
||||||
|
subpages: [
|
||||||
|
{ name: 'Japan', albums: [A] },
|
||||||
|
{ name: 'Korea', title: 'South Korea', albums: [B] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* #523 split `grid` into photo grid and cover grid. A pre-#523 page keeps
|
||||||
|
* its cover columns and gap by being seeded from `grid` on load, so the
|
||||||
|
* first save writes the split out explicitly.
|
||||||
|
*/
|
||||||
|
it('seeds the cover grid from a pre-#523 grid', () => {
|
||||||
|
const out = roundTrip({
|
||||||
|
subpages: [{ name: 'Old', grid: { columns: 3, gap: 10, layout: 'masonry' }, albums: [A] }],
|
||||||
|
});
|
||||||
|
expect(out).toEqual({
|
||||||
|
subpages: [
|
||||||
|
{
|
||||||
|
name: 'Old',
|
||||||
|
grid: { columns: 3, gap: 10, layout: 'masonry' },
|
||||||
|
coverGrid: { columns: 3, gap: 10 },
|
||||||
|
albums: [A],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is stable: a second round trip changes nothing', () => {
|
||||||
|
const once = roundTrip({
|
||||||
|
hero: 'h1',
|
||||||
|
subpages: { Japan: { grid: { columns: 2 }, albums: [A] } },
|
||||||
|
});
|
||||||
|
expect(roundTrip(once)).toEqual(once);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The builder rewrites gallery.yaml wholesale on every save, so a subpage key
|
||||||
|
* it does not carry is deleted, not ignored. `proofing` was lost this way: a
|
||||||
|
* page switched off for client proofing by hand came back on after any
|
||||||
|
* unrelated save in the admin. Same lesson as #614 for album entries.
|
||||||
|
*
|
||||||
|
* The type annotation is the enforcement: every key the schema allows on a
|
||||||
|
* subpage has to be listed here, so the next one added and forgotten in
|
||||||
|
* galleryYaml.ts fails this test instead of vanishing from someone's file.
|
||||||
|
*/
|
||||||
|
type SubpageYaml = Extract<NonNullable<GalleryYaml['subpages']>, unknown[]>[number];
|
||||||
|
|
||||||
|
const everySubpageKey: Required<SubpageYaml> = {
|
||||||
|
name: 'Clients',
|
||||||
|
title: 'For clients',
|
||||||
|
subtitle: 'Handover',
|
||||||
|
albums: [A],
|
||||||
|
sections: [{ title: 'North', description: 'Cold', albums: [B] }],
|
||||||
|
password: 'scrypt:aa:bb',
|
||||||
|
proofing: false,
|
||||||
|
zoom: true,
|
||||||
|
essayFile: 'story',
|
||||||
|
essayText: 'Text',
|
||||||
|
enabled: false,
|
||||||
|
hidden: true,
|
||||||
|
location: 'city',
|
||||||
|
grid: { columns: 4, gap: 6, aspectRatio: '3/2', layout: 'justified' },
|
||||||
|
coverGrid: { columns: 2, gap: 8, aspectRatio: '1', layout: 'masonry' },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('subpage keys', () => {
|
||||||
|
it('survives a round trip with every key the schema allows', () => {
|
||||||
|
expect(roundTrip({ subpages: [everySubpageKey] })).toEqual({ subpages: [everySubpageKey] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps proofing in either direction, and writes nothing when unset', () => {
|
||||||
|
const on = { name: 'P', proofing: true, albums: [A] };
|
||||||
|
expect(roundTrip({ subpages: [on] })).toEqual({ subpages: [on] });
|
||||||
|
expect(roundTrip({ subpages: [{ name: 'P', albums: [A] }] })).toEqual({
|
||||||
|
subpages: [{ name: 'P', albums: [A] }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('subpage zoom (#467)', () => {
|
||||||
|
it('keeps zoom in either direction, and writes nothing when unset', () => {
|
||||||
|
const off = { name: 'Z', zoom: false, albums: [A] };
|
||||||
|
const on = { name: 'Z', zoom: true, albums: [A] };
|
||||||
|
expect(roundTrip({ subpages: [off] })).toEqual({ subpages: [off] });
|
||||||
|
expect(roundTrip({ subpages: [on] })).toEqual({ subpages: [on] });
|
||||||
|
expect(roundTrip({ subpages: [{ name: 'Z', albums: [A] }] })).toEqual({
|
||||||
|
subpages: [{ name: 'Z', albums: [A] }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('content page references (#722)', () => {
|
||||||
|
it('keeps `- page:` entries where they were among the subpages', () => {
|
||||||
|
const yaml = {
|
||||||
|
subpages: [
|
||||||
|
{ page: 'pricing' },
|
||||||
|
{ name: 'Japan', albums: [A] },
|
||||||
|
{ page: 'faq' },
|
||||||
|
{ name: 'Korea', albums: [B] },
|
||||||
|
{ page: 'contact-me' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(roundTrip(yaml)).toEqual(yaml);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses page references apart from the subpages', () => {
|
||||||
|
const g = parseGalleryYaml({ subpages: [{ name: 'Japan', albums: [A] }, { page: 'faq' }] });
|
||||||
|
expect(g.subpages.map((s) => s.name)).toEqual(['Japan']);
|
||||||
|
expect(g.pageRefs).toEqual([{ slug: 'faq', position: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes a menu of pages only', () => {
|
||||||
|
const yaml = { subpages: [{ page: 'pricing' }] };
|
||||||
|
expect(roundTrip(yaml)).toEqual(yaml);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import type { JournalBlock } from '@/lib/journal';
|
import type { JournalBlock } from '@/lib/journal';
|
||||||
import { serializeJournalMarkdown, parseJournalMarkdown } from '@/lib/journal';
|
import { serializeJournalMarkdown, parseJournalMarkdown } from '@/lib/journal';
|
||||||
import { createBlock, moveBlock, isLegacyAssetRef } from '../components/journal/blockOps';
|
import {
|
||||||
|
createBlock,
|
||||||
|
createPhotoBlocks,
|
||||||
|
fillAssetSlots,
|
||||||
|
moveBlock,
|
||||||
|
isLegacyAssetRef,
|
||||||
|
} from '../components/journal/blockOps';
|
||||||
import {
|
import {
|
||||||
clampSplit,
|
clampSplit,
|
||||||
splitFromPointer,
|
splitFromPointer,
|
||||||
@@ -124,3 +130,21 @@ describe('split pane', () => {
|
|||||||
expect(parseStoredSplit('abc')).toBeNull();
|
expect(parseStoredSplit('abc')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('multi-pick into photo blocks (#602)', () => {
|
||||||
|
it('fills empty slots first, then appends', () => {
|
||||||
|
expect(fillAssetSlots(['a', '', ''], ['x', 'y', 'z'])).toEqual(['a', 'x', 'y', 'z']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never grows past a capacity', () => {
|
||||||
|
expect(fillAssetSlots(['', ''], ['x', 'y', 'z'], 2)).toEqual(['x', 'y']);
|
||||||
|
expect(fillAssetSlots(['a', ''], ['x'], 2)).toEqual(['a', 'x']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates one contained photo block per asset', () => {
|
||||||
|
expect(createPhotoBlocks(['x', 'y'])).toEqual([
|
||||||
|
{ type: 'photo', assetId: 'x', caption: '', layout: 'contained' },
|
||||||
|
{ type: 'photo', assetId: 'y', caption: '', layout: 'contained' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
24
app/admin/__tests__/page-link-slug.test.ts
Normal file
24
app/admin/__tests__/page-link-slug.test.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
// `?page=<slug>` in /admin/pages selects a page, and the page panel turns the
|
||||||
|
// slug into its "open live page" link. A slug such as `/evil.example` would
|
||||||
|
// make that link `//evil.example`, which leaves the site. Both ends guard it.
|
||||||
|
const read = (file: string) => readFileSync(path.join(process.cwd(), file), 'utf8');
|
||||||
|
|
||||||
|
describe('page link from ?page=', () => {
|
||||||
|
it('only selects a page for a valid slug', () => {
|
||||||
|
const src = read('app/admin/components/PageBuilder.tsx');
|
||||||
|
const fn = src.slice(src.indexOf('function openPageFromLink'));
|
||||||
|
const body = fn.slice(0, fn.indexOf('\n }\n'));
|
||||||
|
expect(body).toMatch(/if \(!isValidSlug\(slug\)\) return;/);
|
||||||
|
expect(body.indexOf('isValidSlug(slug)')).toBeLessThan(body.indexOf('setSelectedPage(slug)'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes the slug in the live-page link', () => {
|
||||||
|
const src = read('app/admin/components/page-builder/PagePanel.tsx');
|
||||||
|
expect(src).toContain('href={`/${encodeURIComponent(slug)}`}');
|
||||||
|
expect(src).not.toContain('href={`/${slug}`}');
|
||||||
|
});
|
||||||
|
});
|
||||||
35
app/admin/__tests__/sameDraft.test.ts
Normal file
35
app/admin/__tests__/sameDraft.test.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { sameDraft } from '../components/sameDraft';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Unsaved changes" stayed up after an edit was typed back to the saved value
|
||||||
|
* (QA A-19): the editors only ever set their dirty flag. They now also compare
|
||||||
|
* with the saved state, which has to see through what an undone edit leaves.
|
||||||
|
*/
|
||||||
|
describe('sameDraft', () => {
|
||||||
|
it('is false for a real change', () => {
|
||||||
|
expect(sameDraft({ title: 'A' }, { title: 'B' })).toBe(false);
|
||||||
|
expect(sameDraft({ navLinks: [{ label: 'A' }] }, { navLinks: [] })).toBe(false);
|
||||||
|
expect(sameDraft({ albums: ['a', 'b'] }, { albums: ['b', 'a'] })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores key order — a deleted key typed again moves to the end', () => {
|
||||||
|
expect(
|
||||||
|
sameDraft(
|
||||||
|
{ theme: { accent: '#fff', preset: 'noir' } },
|
||||||
|
{
|
||||||
|
theme: { preset: 'noir', accent: '#fff' },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores the empty parent objects a settings edit creates', () => {
|
||||||
|
expect(sameDraft({ title: 'A', contact: {} }, { title: 'A' })).toBe(true);
|
||||||
|
expect(sameDraft({ theme: { fonts: {} } }, {})).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores undefined, which the YAML never holds', () => {
|
||||||
|
expect(sameDraft({ title: 'A', subtitle: undefined }, { title: 'A' })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -50,9 +50,9 @@ describe('systemHealth', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports a doctor warning as neutral', () => {
|
it('reports a doctor warning in the warning tone, not the neutral one', () => {
|
||||||
expect(health({ doctorLevel: 'warn' })).toEqual({
|
expect(health({ doctorLevel: 'warn' })).toEqual({
|
||||||
tone: 'unknown',
|
tone: 'warning',
|
||||||
label: 'Check Diagnostics',
|
label: 'Check Diagnostics',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -67,7 +67,7 @@ describe('systemHealth', () => {
|
|||||||
'Setup Incomplete': 'unknown',
|
'Setup Incomplete': 'unknown',
|
||||||
'System Degraded': 'disconnected',
|
'System Degraded': 'disconnected',
|
||||||
'Needs Attention': 'disconnected',
|
'Needs Attention': 'disconnected',
|
||||||
'Check Diagnostics': 'unknown',
|
'Check Diagnostics': 'warning',
|
||||||
'System OK': 'connected',
|
'System OK': 'connected',
|
||||||
'Status Unknown': 'unknown',
|
'Status Unknown': 'unknown',
|
||||||
};
|
};
|
||||||
|
|||||||
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,7 @@
|
|||||||
@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/proofing.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,17 +17,112 @@ 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: 'Proofing',
|
||||||
|
href: '/admin/proofing',
|
||||||
|
match: /^\/admin\/proofing/,
|
||||||
|
icon: <Icons.IconHeart size={16} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Analytics',
|
||||||
|
href: '/admin/analytics',
|
||||||
|
match: /^\/admin\/analytics/,
|
||||||
|
icon: <Icons.IconBarChart size={16} />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'Site',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Settings',
|
||||||
|
href: '/admin/settings',
|
||||||
|
match: /^\/admin\/settings/,
|
||||||
|
icon: <Icons.IconGear size={16} />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'System',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Diagnostics',
|
||||||
|
href: '/admin/diagnostics',
|
||||||
|
match: /^\/admin\/diagnostics/,
|
||||||
|
icon: <Icons.IconShieldCheck size={16} />,
|
||||||
|
badge: 'health',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Help',
|
||||||
|
href: '/admin/help',
|
||||||
|
match: /^\/admin\/help/,
|
||||||
|
icon: <Icons.IconQuote size={16} />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const SIDEBAR_KEY = 'folio_admin_sidebar';
|
||||||
|
|
||||||
export default function AdminDashboard({ onLogout, children }: Props) {
|
export default function AdminDashboard({ onLogout, children }: Props) {
|
||||||
|
const notify = useNotify();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
// Phones: the sidebar folds into a header row, and the nav opens from a
|
||||||
|
// menu button (#732) — spread out above the content, it took most of an
|
||||||
|
// iPhone screen before anything could be edited.
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
useEffect(() => setMenuOpen(false), [pathname]);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
// Diagnostics & Backup state
|
// Diagnostics & Backup state
|
||||||
@@ -40,6 +137,29 @@ export default function AdminDashboard({ onLogout, children }: Props) {
|
|||||||
// an expired session or a 500 rendered exactly like a real outage — every
|
// 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 +174,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 +232,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,159 +278,246 @@ 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
|
||||||
<header className="admin-header">
|
className={`admin-dashboard admin-app${collapsed ? ' is-collapsed' : ''}${
|
||||||
<div className="admin-header-left">
|
menuOpen ? ' menu-open' : ''
|
||||||
<h1>Immich Folio</h1>
|
}`}
|
||||||
<nav className="admin-tabs">
|
>
|
||||||
{TABS.map((t) => {
|
<a href="#admin-main" className="skip-link">
|
||||||
const active = t.match.test(pathname);
|
Skip to content
|
||||||
return (
|
</a>
|
||||||
<Link
|
<aside className="admin-sidebar" aria-label="Admin">
|
||||||
key={t.href}
|
<button
|
||||||
href={t.href}
|
type="button"
|
||||||
className={`admin-tab ${active ? 'active' : ''}`}
|
className="admin-sidebar-toggle"
|
||||||
aria-current={active ? 'page' : undefined}
|
onClick={toggleCollapsed}
|
||||||
>
|
aria-expanded={!collapsed}
|
||||||
{t.label}
|
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
</Link>
|
data-tip={collapsed ? 'Expand' : undefined}
|
||||||
);
|
>
|
||||||
})}
|
<svg
|
||||||
</nav>
|
width="14"
|
||||||
</div>
|
height="14"
|
||||||
<div className="admin-header-right">
|
viewBox="0 0 24 24"
|
||||||
{/* Diagnostics Badge */}
|
fill="none"
|
||||||
<div className="status-indicator-container">
|
stroke="currentColor"
|
||||||
<button
|
strokeWidth="2"
|
||||||
className={`status-badge-btn ${health.tone}`}
|
strokeLinecap="round"
|
||||||
onClick={() => {
|
strokeLinejoin="round"
|
||||||
setShowStatus(!showStatus);
|
aria-hidden="true"
|
||||||
if (!showStatus) fetchStatus();
|
>
|
||||||
}}
|
<polyline points={collapsed ? '9 6 15 12 9 18' : '15 6 9 12 15 18'} />
|
||||||
title="Show system status"
|
</svg>
|
||||||
>
|
</button>
|
||||||
<span className="status-dot"></span>
|
<Link href="/admin" className="admin-brand" data-tip="Immich Folio">
|
||||||
<span className="status-text">{health.label}</span>
|
<span className="admin-brand-mark" aria-hidden="true" />
|
||||||
</button>
|
<span className="admin-brand-text">
|
||||||
|
<span className="admin-brand-name">Immich Folio</span>
|
||||||
{showStatus && (
|
<span className="admin-brand-sub">Admin</span>
|
||||||
<>
|
</span>
|
||||||
<div className="status-dropdown-backdrop" onClick={() => setShowStatus(false)} />
|
</Link>
|
||||||
<div className="status-dropdown">
|
<button
|
||||||
<div className="status-dropdown-header">
|
type="button"
|
||||||
<h4>System Diagnostics</h4>
|
className="admin-menu-toggle"
|
||||||
<button
|
onClick={() => setMenuOpen((open) => !open)}
|
||||||
className="status-refresh-btn"
|
aria-expanded={menuOpen}
|
||||||
onClick={fetchStatus}
|
aria-controls="admin-sidebar-menu"
|
||||||
disabled={statusLoading}
|
aria-label={menuOpen ? 'Close menu' : 'Open menu'}
|
||||||
title="Refresh diagnostics"
|
>
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{menuOpen ? <path d="M6 6l12 12M18 6L6 18" /> : <path d="M4 7h16M4 12h16M4 17h16" />}
|
||||||
|
</svg>
|
||||||
|
{unread > 0 && !menuOpen && <span className="admin-menu-dot" aria-hidden="true" />}
|
||||||
|
</button>
|
||||||
|
<div className="admin-sidebar-menu" id="admin-sidebar-menu">
|
||||||
|
<nav className="admin-nav">
|
||||||
|
{NAV.map((section, i) => (
|
||||||
|
<div key={section.group ?? i} className="admin-nav-group">
|
||||||
|
{section.group && <span className="admin-nav-label">{section.group}</span>}
|
||||||
|
{section.items.map((item) => {
|
||||||
|
const active = item.match.test(pathname);
|
||||||
|
const count = item.badge === 'messages' ? unread : 0;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={`admin-nav-item ${active ? 'active' : ''}`}
|
||||||
|
data-tip={item.label}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
>
|
>
|
||||||
{statusLoading ? '...' : '↻'}
|
<span className="admin-nav-icon">{item.icon}</span>
|
||||||
</button>
|
<span className="admin-nav-text">{item.label}</span>
|
||||||
</div>
|
{count > 0 && (
|
||||||
<div className="status-dropdown-body">
|
<span className="admin-nav-count" aria-label={`${count} unread`}>
|
||||||
{setupIncomplete && (
|
{count}
|
||||||
<p className="status-setup-note">
|
</span>
|
||||||
{missingCredentials
|
)}
|
||||||
? 'No Immich URL or API key configured. Set IMMICH_API_URL and IMMICH_API_KEY, or run the '
|
{item.badge === 'health' && health.tone === 'disconnected' && (
|
||||||
: 'No content/gallery.yaml yet — the public site has nothing to show. Add a page below, or run the '}
|
<span
|
||||||
<a href="/install" target="_blank" rel="noopener noreferrer">
|
className={`admin-nav-dot ${health.tone}`}
|
||||||
setup wizard
|
aria-label={health.label}
|
||||||
</a>
|
/>
|
||||||
{missingCredentials
|
)}
|
||||||
? '.'
|
</Link>
|
||||||
: ' (the one-time token is printed to the server log).'}
|
);
|
||||||
</p>
|
})}
|
||||||
)}
|
</div>
|
||||||
<div className="status-item">
|
))}
|
||||||
<span className="status-label">Immich Connection</span>
|
</nav>
|
||||||
<span className={`status-val ${immichIndicator.className}`}>
|
|
||||||
{immichIndicator.label}
|
<div className="admin-sidebar-foot">
|
||||||
</span>
|
{/* Diagnostics Badge */}
|
||||||
|
<div className="status-indicator-container">
|
||||||
|
<button
|
||||||
|
className={`status-badge-btn sidebar-status ${health.tone}`}
|
||||||
|
onClick={() => {
|
||||||
|
setShowStatus(!showStatus);
|
||||||
|
if (!showStatus) fetchStatus();
|
||||||
|
}}
|
||||||
|
title="Show system status"
|
||||||
|
data-tip={health.label}
|
||||||
|
>
|
||||||
|
<span className="status-dot"></span>
|
||||||
|
<span className="status-text">{health.label}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showStatus && (
|
||||||
|
<>
|
||||||
|
<div className="status-dropdown-backdrop" onClick={() => setShowStatus(false)} />
|
||||||
|
<div className="status-dropdown">
|
||||||
|
<div className="status-dropdown-header">
|
||||||
|
<h4>System Diagnostics</h4>
|
||||||
|
<button
|
||||||
|
className="status-refresh-btn"
|
||||||
|
onClick={fetchStatus}
|
||||||
|
disabled={statusLoading}
|
||||||
|
title="Refresh diagnostics"
|
||||||
|
>
|
||||||
|
{statusLoading ? '...' : '↻'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="status-item">
|
<div className="status-dropdown-body">
|
||||||
<span className="status-label">Config Integrity</span>
|
{setupIncomplete && (
|
||||||
<span className={`status-val ${configIndicator.className}`}>
|
<p className="status-setup-note">
|
||||||
{configIndicator.label}
|
{missingCredentials
|
||||||
</span>
|
? 'No Immich URL or API key configured. Set IMMICH_API_URL and IMMICH_API_KEY, or run the '
|
||||||
</div>
|
: 'No content/gallery.yaml yet — the public site has nothing to show. Add a page below, or run the '}
|
||||||
<div className="status-item">
|
<a href="/install" target="_blank" rel="noopener noreferrer">
|
||||||
<span className="status-label">Config Backups</span>
|
setup wizard
|
||||||
<span className="status-val">{status?.backups?.count ?? 0} Backups</span>
|
</a>
|
||||||
</div>
|
{missingCredentials
|
||||||
<div className="status-item">
|
? '.'
|
||||||
<span className="status-label">Latest Backup</span>
|
: ' (the one-time token is printed to the server log).'}
|
||||||
<span className="status-val" title={status?.backups?.lastBackup || 'None'}>
|
</p>
|
||||||
{status?.backups?.lastBackup
|
)}
|
||||||
? new Date(status.backups.lastBackup).toLocaleDateString()
|
<div className="status-item">
|
||||||
: 'None'}
|
<span className="status-label">Immich Connection</span>
|
||||||
</span>
|
<span className={`status-val ${immichIndicator.className}`}>
|
||||||
</div>
|
{immichIndicator.label}
|
||||||
<div className="status-item">
|
</span>
|
||||||
<span className="status-label">In-Memory Cache</span>
|
</div>
|
||||||
<span className="status-val">{status?.cache?.size ?? 0} items</span>
|
<div className="status-item">
|
||||||
</div>
|
<span className="status-label">Config Integrity</span>
|
||||||
{/*
|
<span className={`status-val ${configIndicator.className}`}>
|
||||||
|
{configIndicator.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="status-item">
|
||||||
|
<span className="status-label">Config Backups</span>
|
||||||
|
<span className="status-val">{status?.backups?.count ?? 0} Backups</span>
|
||||||
|
</div>
|
||||||
|
<div className="status-item">
|
||||||
|
<span className="status-label">Latest Backup</span>
|
||||||
|
<span className="status-val" title={status?.backups?.lastBackup || 'None'}>
|
||||||
|
{status?.backups?.lastBackup
|
||||||
|
? new Date(status.backups.lastBackup).toLocaleDateString()
|
||||||
|
: 'None'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="status-item">
|
||||||
|
<span className="status-label">In-Memory Cache</span>
|
||||||
|
<span className="status-val">{status?.cache?.size ?? 0} items</span>
|
||||||
|
</div>
|
||||||
|
{/*
|
||||||
Discreet on purpose: a row in the status list, not a
|
Discreet on purpose: a row in the status list, not a
|
||||||
banner. When the check is off or could not run, the row
|
banner. When the check is off or could not run, the row
|
||||||
still names the running version and says nothing about
|
still names the running version and says nothing about
|
||||||
updates (#496).
|
updates (#496).
|
||||||
*/}
|
*/}
|
||||||
<div className="status-item">
|
<div className="status-item">
|
||||||
<span className="status-label">Version</span>
|
<span className="status-label">Version</span>
|
||||||
{status?.update?.updateAvailable ? (
|
{status?.update?.updateAvailable ? (
|
||||||
<a
|
<a
|
||||||
className="status-val update"
|
className="status-val update"
|
||||||
href="https://github.com/ralksta/immich-folio/releases/latest"
|
href="https://github.com/ralksta/immich-folio/releases/latest"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
title={`Version ${status.update.latest} is available`}
|
title={`Version ${status.update.latest} is available`}
|
||||||
>
|
>
|
||||||
{status.update.current} → {status.update.latest}
|
{status.update.current} → {status.update.latest}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<span className="status-val">{status?.update?.current ?? '—'}</span>
|
<span className="status-val">{status?.update?.current ?? '—'}</span>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-sidebar-actions">
|
||||||
|
<a
|
||||||
|
href="/?fresh=1"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="admin-side-action"
|
||||||
|
data-tip="View site"
|
||||||
|
title="Open site in new tab (bypassing cache)"
|
||||||
|
>
|
||||||
|
<Icons.IconLink size={14} />
|
||||||
|
<span className="admin-side-label">View site</span>
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
className="admin-side-action"
|
||||||
|
data-tip="Backups"
|
||||||
|
onClick={() => setShowBackupModal(true)}
|
||||||
|
title="Manage config backups & restore"
|
||||||
|
>
|
||||||
|
<Icons.IconArchive size={14} />
|
||||||
|
<span className="admin-side-label">Backups</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="admin-side-action"
|
||||||
|
data-tip="Reload"
|
||||||
|
onClick={handleReload}
|
||||||
|
disabled={saving}
|
||||||
|
title="Reload config & clear cache"
|
||||||
|
>
|
||||||
|
<Icons.IconRefresh size={14} />
|
||||||
|
<span className="admin-side-label">Reload</span>
|
||||||
|
</button>
|
||||||
|
<button className="admin-side-action" data-tip="Sign out" onClick={handleLogout}>
|
||||||
|
<Icons.IconX size={14} />
|
||||||
|
<span className="admin-side-label">Sign out</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="admin-header-divider" aria-hidden="true" />
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="/?fresh=1"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="admin-btn admin-btn-ghost"
|
|
||||||
title="Open site in new tab (bypassing cache)"
|
|
||||||
>
|
|
||||||
<Icons.IconLink size={14} /> Site
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
className="admin-btn admin-btn-ghost"
|
|
||||||
onClick={() => setShowBackupModal(true)}
|
|
||||||
title="Manage config backups & restore"
|
|
||||||
>
|
|
||||||
<Icons.IconArchive size={14} /> Backups
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="admin-btn admin-btn-ghost"
|
|
||||||
onClick={handleReload}
|
|
||||||
disabled={saving}
|
|
||||||
title="Reload config & clear cache"
|
|
||||||
>
|
|
||||||
<Icons.IconRefresh size={14} /> Reload
|
|
||||||
</button>
|
|
||||||
<button className="admin-btn admin-btn-ghost" onClick={handleLogout}>
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</aside>
|
||||||
|
|
||||||
<main className="admin-main">{children}</main>
|
<main id="admin-main" tabIndex={-1} 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;
|
||||||
|
}
|
||||||
234
app/admin/components/AdminOverview.tsx
Normal file
234
app/admin/components/AdminOverview.tsx
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /admin: the first screen after signing in (docs/admin-ux-concept.md).
|
||||||
|
*
|
||||||
|
* Says what needs attention before it is asked for (unread messages, doctor
|
||||||
|
* findings), then the site at a glance. Everything on it links to the screen
|
||||||
|
* where it is acted on. It only reads the existing admin endpoints; a failed
|
||||||
|
* one leaves its part out rather than failing the page.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import * as Icons from './Icons';
|
||||||
|
import { adminGet } from './useAdminFetch';
|
||||||
|
import type { DoctorFinding, DoctorLevel } from '@/lib/admin/doctor';
|
||||||
|
import { DOCTOR_LEVEL_EVENT } from './systemHealth';
|
||||||
|
import { parseGalleryYaml } from './page-builder/galleryYaml';
|
||||||
|
|
||||||
|
interface OverviewData {
|
||||||
|
/** gallery.yaml as stored; `subpages` may be a list or the older name → albums map. */
|
||||||
|
gallery: Record<string, unknown> | null;
|
||||||
|
journal: Array<{ frontmatter: { draft?: boolean } }> | null;
|
||||||
|
messages: Array<{ read: boolean; name: string }> | null;
|
||||||
|
analytics: { days?: Record<string, { pageviews?: number }> } | null;
|
||||||
|
doctor: { level: DoctorLevel; findings: DoctorFinding[] } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a doctor finding is fixed; mirrors FIXES in DiagnosticsView. */
|
||||||
|
const FIX_ROUTE: Record<string, string> = {
|
||||||
|
legal: '/admin/settings/legal',
|
||||||
|
contact: '/admin/settings/legal',
|
||||||
|
privacy: '/admin/settings/legal',
|
||||||
|
passwords: '/admin/settings/security',
|
||||||
|
'album-ids': '/admin/pages',
|
||||||
|
'albums-shared': '/admin/pages',
|
||||||
|
'content-pages': '/admin/pages',
|
||||||
|
};
|
||||||
|
|
||||||
|
function viewsLastDays(
|
||||||
|
days: Record<string, { pageviews?: number }> | undefined,
|
||||||
|
n: number,
|
||||||
|
): number {
|
||||||
|
if (!days) return 0;
|
||||||
|
const cutoff = new Date();
|
||||||
|
cutoff.setDate(cutoff.getDate() - (n - 1));
|
||||||
|
const from = cutoff.toISOString().slice(0, 10);
|
||||||
|
return Object.entries(days)
|
||||||
|
.filter(([date]) => date >= from)
|
||||||
|
.reduce((sum, [, d]) => sum + (d.pageviews || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function greeting(): string {
|
||||||
|
const h = new Date().getHours();
|
||||||
|
if (h < 5) return 'Good night';
|
||||||
|
if (h < 12) return 'Good morning';
|
||||||
|
if (h < 18) return 'Good afternoon';
|
||||||
|
return 'Good evening';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminOverview() {
|
||||||
|
const [data, setData] = useState<OverviewData | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
Promise.all([
|
||||||
|
adminGet<{ gallery: OverviewData['gallery'] }>('/api/admin/gallery'),
|
||||||
|
adminGet<{ entries: NonNullable<OverviewData['journal']> }>('/api/admin/journal'),
|
||||||
|
adminGet<{ messages: NonNullable<OverviewData['messages']> }>('/api/admin/messages'),
|
||||||
|
adminGet<NonNullable<OverviewData['analytics']>>('/api/admin/analytics'),
|
||||||
|
adminGet<NonNullable<OverviewData['doctor']>>('/api/admin/doctor'),
|
||||||
|
]).then(([gallery, journal, messages, analytics, doctor]) => {
|
||||||
|
if (!live) return;
|
||||||
|
setData({
|
||||||
|
gallery: gallery.data?.gallery ?? null,
|
||||||
|
journal: journal.data?.entries ?? null,
|
||||||
|
messages: messages.data?.messages ?? null,
|
||||||
|
analytics: analytics.data ?? null,
|
||||||
|
doctor: doctor.data ?? null,
|
||||||
|
});
|
||||||
|
// The sidebar badge takes the level from here instead of asking again.
|
||||||
|
if (doctor.data?.level) {
|
||||||
|
window.dispatchEvent(new CustomEvent(DOCTOR_LEVEL_EVENT, { detail: doctor.data.level }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Read the way the page builder reads it: both shapes of `subpages:`, and
|
||||||
|
// `- page:` entries, which are content pages in the menu, not subpages (#722).
|
||||||
|
const subpages = data?.gallery ? parseGalleryYaml(data.gallery).subpages : [];
|
||||||
|
const published = subpages.filter((sp) => sp.enabled !== false).length;
|
||||||
|
const heroRaw = data?.gallery?.hero;
|
||||||
|
const heroCount = Array.isArray(heroRaw) ? heroRaw.length : heroRaw ? 1 : 0;
|
||||||
|
const entries = data?.journal ?? [];
|
||||||
|
const drafts = entries.filter((e) => e.frontmatter.draft).length;
|
||||||
|
const unread = (data?.messages ?? []).filter((m) => !m.read);
|
||||||
|
const views7 = viewsLastDays(data?.analytics?.days, 7);
|
||||||
|
const findings = (data?.doctor?.findings ?? []).filter((f) => f.level !== 'ok');
|
||||||
|
|
||||||
|
const attention: Array<{
|
||||||
|
tone: 'accent' | 'warn' | 'error';
|
||||||
|
title: string;
|
||||||
|
detail?: string;
|
||||||
|
href: string;
|
||||||
|
}> = [];
|
||||||
|
if (unread.length) {
|
||||||
|
attention.push({
|
||||||
|
tone: 'accent',
|
||||||
|
title: `${unread.length} unread message${unread.length === 1 ? '' : 's'}`,
|
||||||
|
detail: `From ${[...new Set(unread.map((m) => m.name))].slice(0, 3).join(', ')}`,
|
||||||
|
href: '/admin/messages',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const f of findings) {
|
||||||
|
attention.push({
|
||||||
|
tone: f.level === 'error' ? 'error' : 'warn',
|
||||||
|
title: f.title,
|
||||||
|
detail: f.detail,
|
||||||
|
href: FIX_ROUTE[f.id] ?? '/admin/diagnostics',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-overview">
|
||||||
|
<header className="overview-head">
|
||||||
|
<div>
|
||||||
|
<p className="overview-kicker">Overview</p>
|
||||||
|
<h1 className="overview-title">{greeting()}.</h1>
|
||||||
|
<p className="overview-sub">
|
||||||
|
{data
|
||||||
|
? attention.length
|
||||||
|
? `${attention.length} thing${attention.length === 1 ? '' : 's'} could use a look.`
|
||||||
|
: 'Everything is in order.'
|
||||||
|
: 'Checking the site…'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="overview-actions">
|
||||||
|
<a
|
||||||
|
className="admin-btn admin-btn-ghost"
|
||||||
|
href="/?fresh=1"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Icons.IconLink size={14} /> View site
|
||||||
|
</a>
|
||||||
|
<Link className="admin-btn admin-btn-primary" href="/admin/pages">
|
||||||
|
<Icons.IconPencil size={14} /> Edit pages
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section aria-labelledby="overview-attention">
|
||||||
|
<h2 id="overview-attention" className="overview-section-label">
|
||||||
|
Needs attention
|
||||||
|
</h2>
|
||||||
|
{!data ? (
|
||||||
|
<div className="admin-spinner" />
|
||||||
|
) : attention.length ? (
|
||||||
|
<div className="overview-attention">
|
||||||
|
{attention.map((a, i) => (
|
||||||
|
<Link key={i} href={a.href} className="overview-attention-item">
|
||||||
|
<span className={`overview-attention-marker ${a.tone}`} aria-hidden="true" />
|
||||||
|
<span className="overview-attention-text">
|
||||||
|
{a.title}
|
||||||
|
{a.detail && <span className="overview-attention-detail">{a.detail}</span>}
|
||||||
|
</span>
|
||||||
|
<span className="overview-attention-go">Open</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="overview-calm">Nothing waiting. New messages and problems show up here.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="overview-glance">
|
||||||
|
<h2 id="overview-glance" className="overview-section-label">
|
||||||
|
At a glance
|
||||||
|
</h2>
|
||||||
|
<div className="overview-stats">
|
||||||
|
<Link href="/admin/pages" className="overview-stat">
|
||||||
|
<span className="overview-stat-label">Pages</span>
|
||||||
|
<span className="overview-stat-value">{data ? published : '–'}</span>
|
||||||
|
<span className="overview-stat-note">
|
||||||
|
{subpages.length - published > 0
|
||||||
|
? `${subpages.length - published} switched off`
|
||||||
|
: 'all published'}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/pages" className="overview-stat">
|
||||||
|
<span className="overview-stat-label">Hero photos</span>
|
||||||
|
<span className="overview-stat-value">{data ? heroCount : '–'}</span>
|
||||||
|
<span className="overview-stat-note">on the home page</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/journal" className="overview-stat">
|
||||||
|
<span className="overview-stat-label">Journal</span>
|
||||||
|
<span className="overview-stat-value">{data ? entries.length : '–'}</span>
|
||||||
|
<span className="overview-stat-note">
|
||||||
|
{drafts ? `${drafts} draft${drafts === 1 ? '' : 's'}` : 'no drafts'}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/analytics" className="overview-stat">
|
||||||
|
<span className="overview-stat-label">Views · 7 days</span>
|
||||||
|
<span className="overview-stat-value">{data ? views7.toLocaleString() : '–'}</span>
|
||||||
|
<span className="overview-stat-note">cookieless count</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="overview-next">
|
||||||
|
<h2 id="overview-next" className="overview-section-label">
|
||||||
|
Quick actions
|
||||||
|
</h2>
|
||||||
|
<div className="overview-actions">
|
||||||
|
<Link className="admin-btn admin-btn-ghost" href="/admin/journal">
|
||||||
|
<Icons.IconPlus size={14} /> New journal entry
|
||||||
|
</Link>
|
||||||
|
<Link className="admin-btn admin-btn-ghost" href="/admin/messages">
|
||||||
|
<Icons.IconFileText size={14} /> Messages
|
||||||
|
</Link>
|
||||||
|
<Link className="admin-btn admin-btn-ghost" href="/admin/settings/theme">
|
||||||
|
<Icons.IconPalette size={14} /> Theme
|
||||||
|
</Link>
|
||||||
|
<Link className="admin-btn admin-btn-ghost" href="/admin/diagnostics">
|
||||||
|
<Icons.IconShieldCheck size={14} /> Diagnostics
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@ export default function AlbumPicker({ albums, onSelect, onClose, usedAlbumIds }:
|
|||||||
>
|
>
|
||||||
<div className="picker-header">
|
<div className="picker-header">
|
||||||
<h3 id="album-picker-title">Select Album</h3>
|
<h3 id="album-picker-title">Select Album</h3>
|
||||||
<button className="admin-btn-icon" onClick={onClose}>
|
<button type="button" className="admin-btn-icon" onClick={onClose} aria-label="Close">
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -76,12 +76,16 @@ export default function AlbumPicker({ albums, onSelect, onClose, usedAlbumIds }:
|
|||||||
{filtered.map((album) => {
|
{filtered.map((album) => {
|
||||||
const isUsed = usedAlbumIds.has(album.id);
|
const isUsed = usedAlbumIds.has(album.id);
|
||||||
return (
|
return (
|
||||||
<div
|
// A button, not a div: the list has to work from the keyboard.
|
||||||
|
// An album already on the site is disabled rather than inert.
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
key={album.id}
|
key={album.id}
|
||||||
className={`picker-item ${isUsed ? 'used' : ''}`}
|
className={`picker-item ${isUsed ? 'used' : ''}`}
|
||||||
onClick={() => !isUsed && onSelect(album.id)}
|
disabled={isUsed}
|
||||||
|
onClick={() => onSelect(album.id)}
|
||||||
>
|
>
|
||||||
<div className="picker-item-thumb">
|
<span className="picker-item-thumb">
|
||||||
{album.thumbnailAssetId ? (
|
{album.thumbnailAssetId ? (
|
||||||
<img
|
<img
|
||||||
className="picker-thumb-img"
|
className="picker-thumb-img"
|
||||||
@@ -90,12 +94,12 @@ export default function AlbumPicker({ albums, onSelect, onClose, usedAlbumIds }:
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="picker-thumb-placeholder">
|
<span className="picker-thumb-placeholder">
|
||||||
<IconFolder size={20} />
|
<IconFolder size={20} />
|
||||||
</div>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</span>
|
||||||
<div className="picker-item-info">
|
<span className="picker-item-info">
|
||||||
<span className="picker-item-name">{album.albumName}</span>
|
<span className="picker-item-name">{album.albumName}</span>
|
||||||
<span className="picker-item-meta">
|
<span className="picker-item-meta">
|
||||||
{album.assetCount} photos
|
{album.assetCount} photos
|
||||||
@@ -108,15 +112,15 @@ export default function AlbumPicker({ albums, onSelect, onClose, usedAlbumIds }:
|
|||||||
{album.shared === false && (
|
{album.shared === false && (
|
||||||
<span className="picker-item-note">Not shared in Immich</span>
|
<span className="picker-item-note">Not shared in Immich</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</span>
|
||||||
<div className="picker-item-action">
|
<span className="picker-item-action">
|
||||||
{isUsed ? (
|
{isUsed ? (
|
||||||
<span className="picker-used-badge">In use</span>
|
<span className="picker-used-badge">In use</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="picker-select-badge">Select</span>
|
<span className="picker-select-badge">Select</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
'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';
|
||||||
|
import { lastDays } from './analyticsDays';
|
||||||
|
|
||||||
interface AnalyticsData {
|
interface AnalyticsData {
|
||||||
trackingEnabled?: boolean;
|
trackingEnabled?: boolean;
|
||||||
@@ -22,56 +25,25 @@ 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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalViews = data.summary?.totalViews || 0;
|
const totalViews = data.summary?.totalViews || 0;
|
||||||
const maxPageViews = data.topPages[0]?.count || 1;
|
const maxPageViews = data.topPages[0]?.count || 1;
|
||||||
const daysList = Object.entries(data.days || {})
|
const hasDays = Object.keys(data.days || {}).length > 0;
|
||||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
const daysList = lastDays(data.days || {}, 14, new Date());
|
||||||
.slice(-14); // Last 14 days
|
const maxDayViews = Math.max(...daysList.map((d) => d.pageviews), 1);
|
||||||
const maxDayViews = Math.max(...daysList.map(([, d]) => d.pageviews || 0), 1);
|
|
||||||
|
|
||||||
const desktopRatio =
|
const desktopRatio =
|
||||||
data.devices.desktop + data.devices.mobile > 0
|
data.devices.desktop + data.devices.mobile > 0
|
||||||
@@ -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
|
||||||
@@ -137,27 +105,32 @@ export default function AnalyticsView() {
|
|||||||
<Icons.IconGrid size={16} /> Daily Traffic Trend (Last 14 Days)
|
<Icons.IconGrid size={16} /> Daily Traffic Trend (Last 14 Days)
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
{daysList.length === 0 ? (
|
{!hasDays ? (
|
||||||
<p className="analytics-empty">
|
<p className="analytics-empty">
|
||||||
No pageviews recorded yet. Visit portfolio pages to start tracking!
|
No pageviews recorded yet. Visit portfolio pages to start tracking!
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="analytics-bar-chart">
|
<div className="analytics-bar-chart">
|
||||||
{daysList.map(([dateStr, dayData]) => {
|
{daysList.map(({ date, pageviews }) => {
|
||||||
const heightPct = Math.round((dayData.pageviews / maxDayViews) * 100);
|
const heightPct = Math.round((pageviews / maxDayViews) * 100);
|
||||||
const formattedDate = dateStr.slice(5); // MM-DD
|
const month = date.slice(5, 8); // "MM-"
|
||||||
|
const day = date.slice(8); // "DD"
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={date} className="bar-col" title={`${date}: ${pageviews} views`}>
|
||||||
key={dateStr}
|
|
||||||
className="bar-col"
|
|
||||||
title={`${dateStr}: ${dayData.pageviews} views`}
|
|
||||||
>
|
|
||||||
<div className="bar-wrapper">
|
<div className="bar-wrapper">
|
||||||
<div className="bar-fill" style={{ height: `${Math.max(heightPct, 8)}%` }}>
|
{/* A day with views keeps a sliver of bar; an empty one has none. */}
|
||||||
<span className="bar-val">{dayData.pageviews}</span>
|
<div
|
||||||
|
className="bar-fill"
|
||||||
|
style={{ height: `${pageviews > 0 ? Math.max(heightPct, 3) : 0}%` }}
|
||||||
|
>
|
||||||
|
<span className="bar-val">{pageviews}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="bar-label">{formattedDate}</span>
|
<span className="bar-label">
|
||||||
|
{/* Hidden at phone width, where only the day fits (A-18). */}
|
||||||
|
<span className="bar-label-month">{month}</span>
|
||||||
|
{day}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { useScrollLock } from './useScrollLock';
|
import { useScrollLock } from './useScrollLock';
|
||||||
|
import { useModalDialog } from '@/hooks/useModalDialog';
|
||||||
|
|
||||||
interface AssetInfo {
|
interface AssetInfo {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -65,8 +66,10 @@ function SortableAssetTile({
|
|||||||
<img src={`/api/admin/thumbnail/${asset.id}`} alt={asset.originalFileName} loading="lazy" />
|
<img src={`/api/admin/thumbnail/${asset.id}`} alt={asset.originalFileName} loading="lazy" />
|
||||||
<span className="order-tile-index">{index + 1}</span>
|
<span className="order-tile-index">{index + 1}</span>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="order-tile-action"
|
className="order-tile-action"
|
||||||
title="Unpin — let this photo follow automatically"
|
title="Unpin — let this photo follow automatically"
|
||||||
|
aria-label={`Unpin ${asset.originalFileName}`}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -94,6 +97,7 @@ export default function AssetOrderEditor({
|
|||||||
onClose,
|
onClose,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
useScrollLock(true);
|
useScrollLock(true);
|
||||||
|
const cardRef = useModalDialog(onClose);
|
||||||
const [assets, setAssets] = useState<AssetInfo[]>([]);
|
const [assets, setAssets] = useState<AssetInfo[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -164,10 +168,17 @@ export default function AssetOrderEditor({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="picker-overlay" onClick={onClose}>
|
<div className="picker-overlay" onClick={onClose}>
|
||||||
<div className="asset-picker-modal" onClick={(e) => e.stopPropagation()}>
|
<div
|
||||||
|
className="asset-picker-modal"
|
||||||
|
ref={cardRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="order-editor-title"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<div className="picker-header">
|
<div className="picker-header">
|
||||||
<h3>Photo order — {albumName}</h3>
|
<h3 id="order-editor-title">Photo order — {albumName}</h3>
|
||||||
<button className="admin-btn-icon" onClick={onClose}>
|
<button type="button" className="admin-btn-icon" onClick={onClose} aria-label="Close">
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -240,19 +251,19 @@ export default function AssetOrderEditor({
|
|||||||
</p>
|
</p>
|
||||||
<div className="order-editor-grid">
|
<div className="order-editor-grid">
|
||||||
{unpinned.map((asset) => (
|
{unpinned.map((asset) => (
|
||||||
<div
|
<button
|
||||||
|
type="button"
|
||||||
key={asset.id}
|
key={asset.id}
|
||||||
className="order-tile order-tile-unpinned"
|
className="order-tile order-tile-unpinned"
|
||||||
title={asset.originalFileName}
|
title={asset.originalFileName}
|
||||||
|
aria-label={`Pin ${asset.originalFileName}`}
|
||||||
onClick={() => setPinnedIds([...pinnedIds, asset.id])}
|
onClick={() => setPinnedIds([...pinnedIds, asset.id])}
|
||||||
>
|
>
|
||||||
<img
|
<img src={`/api/admin/thumbnail/${asset.id}`} alt="" loading="lazy" />
|
||||||
src={`/api/admin/thumbnail/${asset.id}`}
|
<span className="order-tile-action" aria-hidden="true">
|
||||||
alt={asset.originalFileName}
|
+
|
||||||
loading="lazy"
|
</span>
|
||||||
/>
|
</button>
|
||||||
<span className="order-tile-action">+</span>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||||
import { useModalDialog } from '@/hooks/useModalDialog';
|
import { useModalDialog } from '@/hooks/useModalDialog';
|
||||||
import { IconCheck, IconFolder, IconImage, IconStar } from './Icons';
|
import { canSelectMore, matchesQuery, toggleSelection } from '@/lib/admin/assetSelection';
|
||||||
|
import { IconCheck, IconFolder, IconImage, IconSearch, IconStar } from './Icons';
|
||||||
import { useScrollLock } from './useScrollLock';
|
import { useScrollLock } from './useScrollLock';
|
||||||
|
|
||||||
interface AssetInfo {
|
interface AssetInfo {
|
||||||
@@ -13,7 +14,15 @@ interface AssetInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onSelect: (assetId: string) => void;
|
/** Single-select: called with the one asset picked; the caller closes the picker. */
|
||||||
|
onSelect?: (assetId: string) => void;
|
||||||
|
/**
|
||||||
|
* Multi-select (#602): tiles toggle, and a confirm button hands over every
|
||||||
|
* picked asset in the order they were picked. Takes precedence over `onSelect`.
|
||||||
|
*/
|
||||||
|
onSelectMany?: (assetIds: string[]) => void;
|
||||||
|
/** Most assets `onSelectMany` accepts; unlimited when omitted. */
|
||||||
|
max?: number;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
currentAssetIds?: string[];
|
currentAssetIds?: string[];
|
||||||
albumId?: string;
|
albumId?: string;
|
||||||
@@ -22,8 +31,12 @@ interface Props {
|
|||||||
|
|
||||||
type Tab = 'album' | 'favorites' | 'all';
|
type Tab = 'album' | 'favorites' | 'all';
|
||||||
|
|
||||||
|
const SEARCH_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
export default function AssetPicker({
|
export default function AssetPicker({
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onSelectMany,
|
||||||
|
max,
|
||||||
onClose,
|
onClose,
|
||||||
currentAssetIds = [],
|
currentAssetIds = [],
|
||||||
albumId,
|
albumId,
|
||||||
@@ -31,21 +44,50 @@ export default function AssetPicker({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
useScrollLock(true);
|
useScrollLock(true);
|
||||||
const cardRef = useModalDialog(onClose);
|
const cardRef = useModalDialog(onClose);
|
||||||
|
const multi = onSelectMany !== undefined;
|
||||||
const [tab, setTab] = useState<Tab>(albumId ? 'album' : 'favorites');
|
const [tab, setTab] = useState<Tab>(albumId ? 'album' : 'favorites');
|
||||||
const [assets, setAssets] = useState<AssetInfo[]>([]);
|
const [assets, setAssets] = useState<AssetInfo[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [hasMore, setHasMore] = useState(false);
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const [queryInput, setQueryInput] = useState('');
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
const [uuidInput, setUuidInput] = useState('');
|
const [uuidInput, setUuidInput] = useState('');
|
||||||
const [uuidError, setUuidError] = useState('');
|
const [uuidError, setUuidError] = useState('');
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
const currentIds = new Set(currentAssetIds);
|
/**
|
||||||
|
* Bumped each time the list starts over (tab, album or search changed). A
|
||||||
|
* request is only allowed to touch the list while its generation is current:
|
||||||
|
* without this, a slow Favorites answer landing after a switch to All Photos
|
||||||
|
* replaced that tab's photos, and ended its spinner early.
|
||||||
|
*/
|
||||||
|
const generation = useRef(0);
|
||||||
|
const currentIds = useMemo(() => new Set(currentAssetIds), [currentAssetIds]);
|
||||||
|
|
||||||
|
// Debounce typing into the query the fetch runs on.
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setQuery(queryInput.trim()), SEARCH_DEBOUNCE_MS);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [queryInput]);
|
||||||
|
|
||||||
|
// The album tab loads the album whole and filters locally; the library tabs
|
||||||
|
// search server-side, since they are paged.
|
||||||
|
const serverQuery = tab === 'album' ? '' : query;
|
||||||
|
|
||||||
const loadAssets = useCallback(
|
const loadAssets = useCallback(
|
||||||
async (pageNum: number, append: boolean = false) => {
|
async (pageNum: number, append: boolean = false) => {
|
||||||
if (pageNum === 1) setLoading(true);
|
// A fresh list starts a generation; a further page belongs to the current one.
|
||||||
else setLoadingMore(true);
|
const gen = append ? generation.current : ++generation.current;
|
||||||
|
const current = () => gen === generation.current;
|
||||||
|
if (append) {
|
||||||
|
setLoadingMore(true);
|
||||||
|
} else {
|
||||||
|
setLoading(true);
|
||||||
|
// A "load more" of the previous list will not clear this itself.
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let res: Response;
|
let res: Response;
|
||||||
@@ -56,22 +98,31 @@ export default function AssetPicker({
|
|||||||
page: pageNum.toString(),
|
page: pageNum.toString(),
|
||||||
favorites: tab === 'favorites' ? 'true' : 'false',
|
favorites: tab === 'favorites' ? 'true' : 'false',
|
||||||
});
|
});
|
||||||
|
if (serverQuery) params.set('q', serverQuery);
|
||||||
res = await fetch(`/api/admin/assets?${params}`);
|
res = await fetch(`/api/admin/assets?${params}`);
|
||||||
}
|
}
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setAssets((prev) => (append ? [...prev, ...data.assets] : data.assets));
|
if (!current()) return;
|
||||||
|
setAssets((prev) => {
|
||||||
|
if (!append) return data.assets;
|
||||||
|
// Merged filename + description pages can overlap across pages.
|
||||||
|
const seen = new Set(prev.map((a) => a.id));
|
||||||
|
return [...prev, ...data.assets.filter((a: AssetInfo) => !seen.has(a.id))];
|
||||||
|
});
|
||||||
setHasMore(data.nextPage !== null);
|
setHasMore(data.nextPage !== null);
|
||||||
setPage(pageNum);
|
setPage(pageNum);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load assets:', err);
|
if (current()) console.error('Failed to load assets:', err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (current()) {
|
||||||
setLoadingMore(false);
|
setLoading(false);
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[tab, albumId],
|
[tab, albumId, serverQuery],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -79,7 +130,9 @@ export default function AssetPicker({
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
setHasMore(false);
|
setHasMore(false);
|
||||||
loadAssets(1);
|
loadAssets(1);
|
||||||
}, [tab, loadAssets]);
|
}, [loadAssets]);
|
||||||
|
|
||||||
|
const visible = tab === 'album' ? assets.filter((a) => matchesQuery(a, query)) : assets;
|
||||||
|
|
||||||
// Infinite scroll
|
// Infinite scroll
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
@@ -90,6 +143,16 @@ export default function AssetPicker({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pick(id: string) {
|
||||||
|
if (currentIds.has(id)) return;
|
||||||
|
if (multi) setSelected((s) => toggleSelection(s, id, { disabled: currentIds, max }));
|
||||||
|
else onSelect?.(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirm() {
|
||||||
|
if (multi && selected.length > 0) onSelectMany(selected);
|
||||||
|
}
|
||||||
|
|
||||||
function handleUuidSubmit(e: React.FormEvent) {
|
function handleUuidSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const uuid = uuidInput.trim();
|
const uuid = uuidInput.trim();
|
||||||
@@ -99,7 +162,12 @@ export default function AssetPicker({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setUuidError('');
|
setUuidError('');
|
||||||
onSelect(uuid);
|
if (multi) {
|
||||||
|
setSelected((s) => toggleSelection(s, uuid.toLowerCase(), { disabled: currentIds, max }));
|
||||||
|
setUuidInput('');
|
||||||
|
} else {
|
||||||
|
onSelect?.(uuid);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
function formatDate(dateStr: string): string {
|
||||||
@@ -114,6 +182,8 @@ export default function AssetPicker({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const full = multi && !canSelectMore(selected, max);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="picker-overlay" onClick={onClose}>
|
<div className="picker-overlay" onClick={onClose}>
|
||||||
<div
|
<div
|
||||||
@@ -126,7 +196,7 @@ export default function AssetPicker({
|
|||||||
>
|
>
|
||||||
<div className="picker-header">
|
<div className="picker-header">
|
||||||
<h3 id="asset-picker-title">{title || 'Select Hero Image'}</h3>
|
<h3 id="asset-picker-title">{title || 'Select Hero Image'}</h3>
|
||||||
<button className="admin-btn-icon" onClick={onClose}>
|
<button className="admin-btn-icon" onClick={onClose} aria-label="Close">
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -136,6 +206,7 @@ export default function AssetPicker({
|
|||||||
{albumId && (
|
{albumId && (
|
||||||
<button
|
<button
|
||||||
className={`asset-picker-tab ${tab === 'album' ? 'active' : ''}`}
|
className={`asset-picker-tab ${tab === 'album' ? 'active' : ''}`}
|
||||||
|
aria-pressed={tab === 'album'}
|
||||||
onClick={() => setTab('album')}
|
onClick={() => setTab('album')}
|
||||||
>
|
>
|
||||||
<IconFolder size={14} />
|
<IconFolder size={14} />
|
||||||
@@ -144,6 +215,7 @@ export default function AssetPicker({
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
className={`asset-picker-tab ${tab === 'favorites' ? 'active' : ''}`}
|
className={`asset-picker-tab ${tab === 'favorites' ? 'active' : ''}`}
|
||||||
|
aria-pressed={tab === 'favorites'}
|
||||||
onClick={() => setTab('favorites')}
|
onClick={() => setTab('favorites')}
|
||||||
>
|
>
|
||||||
<IconStar size={14} />
|
<IconStar size={14} />
|
||||||
@@ -151,6 +223,7 @@ export default function AssetPicker({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`asset-picker-tab ${tab === 'all' ? 'active' : ''}`}
|
className={`asset-picker-tab ${tab === 'all' ? 'active' : ''}`}
|
||||||
|
aria-pressed={tab === 'all'}
|
||||||
onClick={() => setTab('all')}
|
onClick={() => setTab('all')}
|
||||||
>
|
>
|
||||||
<IconImage size={14} />
|
<IconImage size={14} />
|
||||||
@@ -158,45 +231,91 @@ export default function AssetPicker({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="asset-picker-search">
|
||||||
|
<IconSearch size={14} />
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={queryInput}
|
||||||
|
onChange={(e) => setQueryInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// Enter in the field searches at once instead of submitting anything.
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
setQuery(queryInput.trim());
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
maxLength={100}
|
||||||
|
placeholder={
|
||||||
|
tab === 'album'
|
||||||
|
? 'Filter this album by filename…'
|
||||||
|
: 'Search filename, description, or a date (2024-05)…'
|
||||||
|
}
|
||||||
|
aria-label="Search photos"
|
||||||
|
className="asset-picker-search-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Asset Grid */}
|
{/* Asset Grid */}
|
||||||
<div className="asset-picker-grid" ref={listRef} onScroll={handleScroll}>
|
<div
|
||||||
|
className="asset-picker-grid"
|
||||||
|
ref={listRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
role="group"
|
||||||
|
aria-label="Photos"
|
||||||
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="asset-picker-loading">
|
<div className="asset-picker-loading">
|
||||||
<div className="admin-spinner" />
|
<div className="admin-spinner" />
|
||||||
</div>
|
</div>
|
||||||
) : assets.length === 0 ? (
|
) : visible.length === 0 ? (
|
||||||
<p className="empty-hint">
|
<p className="empty-hint">
|
||||||
{tab === 'favorites'
|
{query
|
||||||
? 'No favorite photos found. Star photos in Immich first, or switch to "All Photos".'
|
? `No photos match “${query}”.`
|
||||||
: 'No photos found.'}
|
: tab === 'favorites'
|
||||||
|
? 'No favorite photos found. Star photos in Immich first, or switch to "All Photos".'
|
||||||
|
: 'No photos found.'}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{assets.map((asset) => {
|
{visible.map((asset) => {
|
||||||
const isUsed = currentIds.has(asset.id);
|
const isUsed = currentIds.has(asset.id);
|
||||||
|
const order = selected.indexOf(asset.id);
|
||||||
|
const isSelected = order !== -1;
|
||||||
|
const blocked = isUsed || (full && !isSelected);
|
||||||
|
const label = `${asset.originalFileName}, ${formatDate(asset.fileCreatedAt)}${
|
||||||
|
isUsed ? ' (already used)' : ''
|
||||||
|
}`;
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
|
type="button"
|
||||||
key={asset.id}
|
key={asset.id}
|
||||||
className={`asset-picker-tile ${isUsed ? 'used' : ''}`}
|
className={`asset-picker-tile ${isUsed ? 'used' : ''} ${
|
||||||
onClick={() => !isUsed && onSelect(asset.id)}
|
isSelected ? 'selected' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => pick(asset.id)}
|
||||||
|
disabled={blocked}
|
||||||
|
aria-pressed={multi ? isSelected : undefined}
|
||||||
|
aria-label={label}
|
||||||
title={`${asset.originalFileName}\n${formatDate(asset.fileCreatedAt)}`}
|
title={`${asset.originalFileName}\n${formatDate(asset.fileCreatedAt)}`}
|
||||||
>
|
>
|
||||||
<img
|
<img src={`/api/admin/thumbnail/${asset.id}`} alt="" loading="lazy" />
|
||||||
src={`/api/admin/thumbnail/${asset.id}`}
|
|
||||||
alt={asset.originalFileName}
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
{isUsed && (
|
{isUsed && (
|
||||||
<div className="asset-picker-used-badge">
|
<span className="asset-picker-used-badge" aria-hidden="true">
|
||||||
<IconCheck size={11} />
|
<IconCheck size={11} />
|
||||||
</div>
|
</span>
|
||||||
|
)}
|
||||||
|
{isSelected && (
|
||||||
|
<span className="asset-picker-order-badge" aria-hidden="true">
|
||||||
|
{max === 1 ? <IconCheck size={11} /> : order + 1}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{asset.isFavorite && !isUsed && (
|
{asset.isFavorite && !isUsed && (
|
||||||
<div className="asset-picker-fav-badge" title="Favorite in Immich">
|
<span className="asset-picker-fav-badge" aria-hidden="true">
|
||||||
<IconStar size={14} />
|
<IconStar size={14} />
|
||||||
</div>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{loadingMore && (
|
{loadingMore && (
|
||||||
@@ -208,6 +327,36 @@ export default function AssetPicker({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Selection bar (multi-select) */}
|
||||||
|
{multi && (
|
||||||
|
<div className="asset-picker-selection">
|
||||||
|
<span aria-live="polite">
|
||||||
|
{selected.length === 0
|
||||||
|
? max && max > 1
|
||||||
|
? `Pick up to ${max} photos`
|
||||||
|
: 'Pick photos'
|
||||||
|
: `${selected.length}${max && max > 1 ? ` of ${max}` : ''} selected`}
|
||||||
|
</span>
|
||||||
|
{selected.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-btn admin-btn-sm"
|
||||||
|
onClick={() => setSelected([])}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-btn admin-btn-sm admin-btn-primary"
|
||||||
|
disabled={selected.length === 0}
|
||||||
|
onClick={confirm}
|
||||||
|
>
|
||||||
|
{selected.length > 1 ? `Add ${selected.length} photos` : 'Add photo'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* UUID Fallback */}
|
{/* UUID Fallback */}
|
||||||
<div className="asset-picker-uuid-section">
|
<div className="asset-picker-uuid-section">
|
||||||
<form onSubmit={handleUuidSubmit} className="asset-picker-uuid-form">
|
<form onSubmit={handleUuidSubmit} className="asset-picker-uuid-form">
|
||||||
@@ -219,6 +368,7 @@ export default function AssetPicker({
|
|||||||
setUuidError('');
|
setUuidError('');
|
||||||
}}
|
}}
|
||||||
placeholder="Or paste asset UUID directly..."
|
placeholder="Or paste asset UUID directly..."
|
||||||
|
aria-label="Asset UUID"
|
||||||
className="asset-picker-uuid-input"
|
className="asset-picker-uuid-input"
|
||||||
/>
|
/>
|
||||||
<button type="submit" className="admin-btn admin-btn-sm" disabled={!uuidInput.trim()}>
|
<button type="submit" className="admin-btn admin-btn-sm" disabled={!uuidInput.trim()}>
|
||||||
|
|||||||
@@ -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,49 @@ 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' },
|
||||||
|
{ target: 'pages', label: 'Content pages', file: 'content pages' },
|
||||||
];
|
];
|
||||||
|
|
||||||
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: [],
|
||||||
|
pages: [],
|
||||||
|
};
|
||||||
|
|
||||||
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 +71,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 {
|
||||||
@@ -86,14 +89,16 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
|
|||||||
|
|
||||||
setSuccessMsg(
|
setSuccessMsg(
|
||||||
item.slug
|
item.slug
|
||||||
? `Journal entry "${item.slug}" restored.`
|
? `${item.target === 'pages' ? 'Page' : 'Journal entry'} "${item.slug}" restored.`
|
||||||
: `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);
|
||||||
}
|
}
|
||||||
@@ -118,8 +123,8 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
|
|||||||
<div>
|
<div>
|
||||||
<h2 id="backup-modal-title">Backup History & Restoration</h2>
|
<h2 id="backup-modal-title">Backup History & Restoration</h2>
|
||||||
<p className="backup-modal-subtitle">
|
<p className="backup-modal-subtitle">
|
||||||
Restore an earlier version of your pages, settings, about page or journal — including
|
Restore an earlier version of your pages, settings, about page, journal or content
|
||||||
deleted journal entries.
|
pages — including deleted journal entries and pages.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="backup-modal-close-btn" onClick={onClose} aria-label="Close">
|
<button className="backup-modal-close-btn" onClick={onClose} aria-label="Close">
|
||||||
@@ -191,7 +196,7 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
|
|||||||
const dateObj = item.timestamp ? new Date(item.timestamp) : null;
|
const dateObj = item.timestamp ? new Date(item.timestamp) : null;
|
||||||
const formattedDate =
|
const formattedDate =
|
||||||
dateObj && !isNaN(dateObj.getTime())
|
dateObj && !isNaN(dateObj.getTime())
|
||||||
? dateObj.toLocaleString('de-DE', {
|
? dateObj.toLocaleString(undefined, {
|
||||||
dateStyle: 'medium',
|
dateStyle: 'medium',
|
||||||
timeStyle: 'medium',
|
timeStyle: 'medium',
|
||||||
})
|
})
|
||||||
@@ -248,8 +253,8 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{showAllBackups
|
{showAllBackups
|
||||||
? 'Ältere Einträge einklappen'
|
? 'Hide older backups'
|
||||||
: `Ältere Einträge anzeigen (${hiddenCount} weitere)`}
|
: `Show ${hiddenCount} older ${hiddenCount === 1 ? 'backup' : 'backups'}`}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
178
app/admin/components/ConfirmDialog.tsx
Normal file
178
app/admin/components/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One confirmation for the admin (UX stage 3, #694 §3), replacing the
|
||||||
|
* browser's `confirm()`, which cannot be styled, blocks the tab and reads the
|
||||||
|
* same for "discard a draft" and "delete a file".
|
||||||
|
*
|
||||||
|
* Policy: ask only before something that is permanent or takes effect at
|
||||||
|
* once (deleting a message, a journal entry, the uploaded favicon). Removals
|
||||||
|
* of unsaved state are undone by not saving and do not need a dialog.
|
||||||
|
*
|
||||||
|
* const confirm = useConfirm();
|
||||||
|
* if (!(await confirm({ title: 'Delete message?', confirmLabel: 'Delete', danger: true }))) return;
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react';
|
||||||
|
import { useModalDialog } from '@/hooks/useModalDialog';
|
||||||
|
|
||||||
|
export interface ConfirmOptions {
|
||||||
|
title: string;
|
||||||
|
/** What happens, in one or two sentences. */
|
||||||
|
message?: ReactNode;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
/** Destructive: the confirm button uses the error colour. */
|
||||||
|
danger?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Confirm = (options: ConfirmOptions) => Promise<boolean>;
|
||||||
|
|
||||||
|
/** One button of a choice; `id` is what the promise resolves to. */
|
||||||
|
export interface ChoiceAction<Id extends string> {
|
||||||
|
id: Id;
|
||||||
|
label: string;
|
||||||
|
danger?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* More than yes or no — the concurrent-edit prompt (#601) offers three ways
|
||||||
|
* out. The cancel button (and Esc, and the backdrop) resolves to null.
|
||||||
|
*/
|
||||||
|
export interface ChoiceOptions<Id extends string> {
|
||||||
|
title: string;
|
||||||
|
message?: ReactNode;
|
||||||
|
/** Left to right; the last one is the primary button. */
|
||||||
|
actions: ChoiceAction<Id>[];
|
||||||
|
cancelLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Choose = <Id extends string>(options: ChoiceOptions<Id>) => Promise<Id | null>;
|
||||||
|
|
||||||
|
interface DialogContext {
|
||||||
|
confirm: Confirm;
|
||||||
|
choose: Choose;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Outside the provider (a component tested on its own) it falls back to window.confirm. */
|
||||||
|
const ConfirmContext = createContext<DialogContext>({
|
||||||
|
confirm: async (o) => (typeof window === 'undefined' ? false : window.confirm(o.title)),
|
||||||
|
choose: async (o) =>
|
||||||
|
typeof window !== 'undefined' && window.confirm(o.title)
|
||||||
|
? (o.actions[o.actions.length - 1]?.id ?? null)
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useConfirm(): Confirm {
|
||||||
|
return useContext(ConfirmContext).confirm;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChoice(): Choose {
|
||||||
|
return useContext(ConfirmContext).choose;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONFIRM_ID = 'confirm';
|
||||||
|
|
||||||
|
export function ConfirmProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [pending, setPending] = useState<ChoiceOptions<string> | null>(null);
|
||||||
|
const resolver = useRef<((id: string | null) => void) | null>(null);
|
||||||
|
|
||||||
|
const choose = useCallback(
|
||||||
|
(options: ChoiceOptions<string>) =>
|
||||||
|
new Promise<string | null>((resolve) => {
|
||||||
|
resolver.current?.(null);
|
||||||
|
resolver.current = resolve;
|
||||||
|
setPending(options);
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
) as Choose;
|
||||||
|
|
||||||
|
const confirm = useCallback<Confirm>(
|
||||||
|
async (options) =>
|
||||||
|
(await choose({
|
||||||
|
title: options.title,
|
||||||
|
message: options.message,
|
||||||
|
cancelLabel: options.cancelLabel,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: CONFIRM_ID,
|
||||||
|
label: options.confirmLabel ?? 'Confirm',
|
||||||
|
danger: options.danger,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})) === CONFIRM_ID,
|
||||||
|
[choose],
|
||||||
|
);
|
||||||
|
|
||||||
|
const settle = useCallback((id: string | null) => {
|
||||||
|
resolver.current?.(id);
|
||||||
|
resolver.current = null;
|
||||||
|
setPending(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmContext.Provider value={{ confirm, choose }}>
|
||||||
|
{children}
|
||||||
|
{pending && <Dialog options={pending} onSettle={settle} />}
|
||||||
|
</ConfirmContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
options,
|
||||||
|
onSettle,
|
||||||
|
}: {
|
||||||
|
options: ChoiceOptions<string>;
|
||||||
|
onSettle: (id: string | null) => void;
|
||||||
|
}) {
|
||||||
|
// Esc cancels, focus is trapped and returned (the same hook the modals use).
|
||||||
|
const cardRef = useModalDialog(() => onSettle(null));
|
||||||
|
const last = options.actions.length - 1;
|
||||||
|
return (
|
||||||
|
<div className="confirm-backdrop" onClick={() => onSettle(null)}>
|
||||||
|
<div
|
||||||
|
ref={cardRef}
|
||||||
|
className="confirm-card"
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="confirm-title"
|
||||||
|
aria-describedby={options.message ? 'confirm-message' : undefined}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 id="confirm-title" className="confirm-title">
|
||||||
|
{options.title}
|
||||||
|
</h2>
|
||||||
|
{options.message && (
|
||||||
|
<p id="confirm-message" className="confirm-message">
|
||||||
|
{options.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="confirm-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-btn admin-btn-ghost"
|
||||||
|
onClick={() => onSettle(null)}
|
||||||
|
>
|
||||||
|
{options.cancelLabel ?? 'Cancel'}
|
||||||
|
</button>
|
||||||
|
{options.actions.map((action, i) => (
|
||||||
|
<button
|
||||||
|
key={action.id}
|
||||||
|
type="button"
|
||||||
|
className={`admin-btn ${
|
||||||
|
action.danger
|
||||||
|
? 'confirm-danger'
|
||||||
|
: i === last
|
||||||
|
? 'admin-btn-primary'
|
||||||
|
: 'admin-btn-ghost'
|
||||||
|
}`}
|
||||||
|
onClick={() => onSettle(action.id)}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,26 @@ 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: 'content', title: 'Content', checks: ['albums-shared', 'content-writable', 'alt-text'] },
|
id: 'security',
|
||||||
|
title: 'Security',
|
||||||
|
checks: ['auth-secret', 'passwords', 'proxy-hops', 'cdn', 'download-metadata'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'content',
|
||||||
|
title: 'Content',
|
||||||
|
checks: [
|
||||||
|
'albums-shared',
|
||||||
|
'content-pages',
|
||||||
|
'content-writable',
|
||||||
|
'alt-text',
|
||||||
|
'legal',
|
||||||
|
'contact',
|
||||||
|
'privacy',
|
||||||
|
'settings-values',
|
||||||
|
'zoom-renditions',
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const LEVEL_LABEL: Record<DoctorLevel, string> = { ok: 'OK', warn: 'Check', error: 'Problem' };
|
const LEVEL_LABEL: Record<DoctorLevel, string> = { ok: 'OK', warn: 'Check', error: 'Problem' };
|
||||||
@@ -56,10 +75,22 @@ const FIXES: Record<string, { label: string; href: string }> = {
|
|||||||
href: `${DOCS}/README.md#environment-variables-envlocal`,
|
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' },
|
||||||
|
'content-pages': { label: 'Open pages', href: '/admin/pages' },
|
||||||
|
'download-metadata': { label: 'Open pages', href: '/admin/pages' },
|
||||||
|
legal: { label: 'Open settings', href: '/admin/settings/legal' },
|
||||||
|
contact: { label: 'Open settings', href: '/admin/settings/legal' },
|
||||||
|
privacy: { label: 'Open settings', href: '/admin/settings/legal' },
|
||||||
|
// The href is replaced by the finding's own settingsSection.
|
||||||
|
'settings-values': { label: 'Open settings', href: '/admin/settings/general' },
|
||||||
'content-writable': { label: 'How to fix', href: `${DOCS}/docs/admin-panel.md#docker-usage` },
|
'content-writable': { label: 'How to fix', href: `${DOCS}/docs/admin-panel.md#docker-usage` },
|
||||||
|
'zoom-renditions': {
|
||||||
|
label: 'How to set it up',
|
||||||
|
href: `${DOCS}/docs/gallery-config.md#photo-zoom`,
|
||||||
|
},
|
||||||
'immich-api': {
|
'immich-api': {
|
||||||
label: 'API key permissions',
|
label: 'API key permissions',
|
||||||
href: `${DOCS}/README.md#immich-api-key-permissions`,
|
href: `${DOCS}/README.md#immich-api-key-permissions`,
|
||||||
@@ -311,8 +342,12 @@ export default function DiagnosticsView() {
|
|||||||
}
|
}
|
||||||
return null; // The photo list toggle is rendered separately.
|
return null; // The photo list toggle is rendered separately.
|
||||||
}
|
}
|
||||||
const fix = FIXES[id];
|
const known = FIXES[id];
|
||||||
if (!fix) return null;
|
if (!known) return null;
|
||||||
|
// A finding that knows which settings section holds its fix goes there.
|
||||||
|
const fix = f.settingsSection
|
||||||
|
? { ...known, href: `/admin/settings/${f.settingsSection}` }
|
||||||
|
: known;
|
||||||
return fix.href.startsWith('/') ? (
|
return fix.href.startsWith('/') ? (
|
||||||
<Link href={fix.href} className="admin-btn admin-btn-sm">
|
<Link href={fix.href} className="admin-btn admin-btn-sm">
|
||||||
{fix.label}
|
{fix.label}
|
||||||
@@ -371,27 +406,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>}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
IconX,
|
IconX,
|
||||||
} from './Icons';
|
} from './Icons';
|
||||||
import { BlockBadge } from './BlockBadge';
|
import { BlockBadge } from './BlockBadge';
|
||||||
|
import { InlineMarkdownField } from './InlineMarkdownField';
|
||||||
|
|
||||||
interface EssayBlockEditorProps {
|
interface EssayBlockEditorProps {
|
||||||
markdown: string;
|
markdown: string;
|
||||||
@@ -291,19 +292,17 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
|||||||
|
|
||||||
{/* 2. PARAGRAPH BLOCK
|
{/* 2. PARAGRAPH BLOCK
|
||||||
|
|
||||||
The textarea shows `html` raw, as the journal editor does. It used
|
The field shows the markdown source and writes the rendered HTML
|
||||||
to strip the tags for display — `<strong>` to `**`, everything else
|
back, as the journal editor does (InlineMarkdownField). An older
|
||||||
to nothing — while `onChange` wrote the stripped text straight back
|
version stripped the tags for display and wrote the stripped text
|
||||||
into `html`. Italics, links, anything the markdown had produced was
|
straight back into `html`, destroying italics and links; the one
|
||||||
destroyed by the act of editing the paragraph, and the two editors
|
after that showed the raw HTML. */}
|
||||||
disagreed about the same file. serializeJournalMarkdown() turns the
|
|
||||||
HTML back into markdown on save and handles `<em>` too, so there
|
|
||||||
was nothing to convert here in the first place. */}
|
|
||||||
{block.type === 'paragraph' && (
|
{block.type === 'paragraph' && (
|
||||||
<textarea
|
<InlineMarkdownField
|
||||||
|
multiline
|
||||||
rows={3}
|
rows={3}
|
||||||
value={block.html}
|
html={block.html}
|
||||||
onChange={(e) => handleUpdateBlock(idx, { ...block, html: e.target.value })}
|
onChange={(html) => handleUpdateBlock(idx, { ...block, html })}
|
||||||
placeholder="Write text paragraph... (**bold**, *italic* markdown supported)"
|
placeholder="Write text paragraph... (**bold**, *italic* markdown supported)"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -319,10 +318,11 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
|||||||
paddingLeft: '10px',
|
paddingLeft: '10px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<textarea
|
<InlineMarkdownField
|
||||||
|
multiline
|
||||||
rows={2}
|
rows={2}
|
||||||
value={block.text}
|
html={block.text}
|
||||||
onChange={(e) => handleUpdateBlock(idx, { ...block, text: e.target.value })}
|
onChange={(text) => handleUpdateBlock(idx, { ...block, text })}
|
||||||
placeholder="Quote text..."
|
placeholder="Quote text..."
|
||||||
style={{ fontStyle: 'italic', fontFamily: 'serif', fontSize: '1rem' }}
|
style={{ fontStyle: 'italic', fontFamily: 'serif', fontSize: '1rem' }}
|
||||||
/>
|
/>
|
||||||
@@ -411,11 +411,10 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<InlineMarkdownField
|
||||||
type="text"
|
html={block.caption}
|
||||||
value={block.caption || ''}
|
onChange={(caption) =>
|
||||||
onChange={(e) =>
|
handleUpdateBlock(idx, { ...block, caption: caption || undefined })
|
||||||
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
|
|
||||||
}
|
}
|
||||||
placeholder="Photo caption (optional)"
|
placeholder="Photo caption (optional)"
|
||||||
/>
|
/>
|
||||||
@@ -573,11 +572,10 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<InlineMarkdownField
|
||||||
type="text"
|
html={block.caption}
|
||||||
value={block.caption || ''}
|
onChange={(caption) =>
|
||||||
onChange={(e) =>
|
handleUpdateBlock(idx, { ...block, caption: caption || undefined })
|
||||||
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
|
|
||||||
}
|
}
|
||||||
placeholder="Side-by-side caption (optional)"
|
placeholder="Side-by-side caption (optional)"
|
||||||
/>
|
/>
|
||||||
@@ -693,11 +691,10 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
|||||||
<IconPlus size={12} /> Add photo
|
<IconPlus size={12} /> Add photo
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<input
|
<InlineMarkdownField
|
||||||
type="text"
|
html={block.caption}
|
||||||
value={block.caption || ''}
|
onChange={(caption) =>
|
||||||
onChange={(e) =>
|
handleUpdateBlock(idx, { ...block, caption: caption || undefined })
|
||||||
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
|
|
||||||
}
|
}
|
||||||
placeholder="Grid caption (optional)"
|
placeholder="Grid caption (optional)"
|
||||||
/>
|
/>
|
||||||
@@ -729,16 +726,13 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<input
|
<InlineMarkdownField
|
||||||
type="text"
|
html={item.value}
|
||||||
value={item.value}
|
|
||||||
placeholder="Value, e.g. 21 km"
|
placeholder="Value, e.g. 21 km"
|
||||||
onChange={(e) =>
|
onChange={(value) =>
|
||||||
handleUpdateBlock(idx, {
|
handleUpdateBlock(idx, {
|
||||||
...block,
|
...block,
|
||||||
items: block.items.map((it, i) =>
|
items: block.items.map((it, i) => (i === fIdx ? { ...it, value } : it)),
|
||||||
i === fIdx ? { ...it, value: e.target.value } : it,
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -828,13 +822,12 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
|||||||
<option value="wide">Wide (one per row)</option>
|
<option value="wide">Wide (one per row)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<InlineMarkdownField
|
||||||
type="text"
|
html={block.caption}
|
||||||
value={block.caption || ''}
|
onChange={(caption) =>
|
||||||
placeholder="Caption for the set (optional)"
|
handleUpdateBlock(idx, { ...block, caption: caption || undefined })
|
||||||
onChange={(e) =>
|
|
||||||
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
|
|
||||||
}
|
}
|
||||||
|
placeholder="Caption for the set (optional)"
|
||||||
/>
|
/>
|
||||||
<span style={{ fontSize: '0.75rem', color: 'var(--admin-text-secondary)' }}>
|
<span style={{ fontSize: '0.75rem', color: 'var(--admin-text-secondary)' }}>
|
||||||
Expanded into photo blocks when the page renders — the album's order, a
|
Expanded into photo blocks when the page renders — the album's order, a
|
||||||
@@ -846,11 +839,10 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
|||||||
{/* 8. MAP BLOCK */}
|
{/* 8. MAP BLOCK */}
|
||||||
{block.type === 'map' && (
|
{block.type === 'map' && (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
<input
|
<InlineMarkdownField
|
||||||
type="text"
|
html={block.caption}
|
||||||
value={block.caption || ''}
|
onChange={(caption) =>
|
||||||
onChange={(e) =>
|
handleUpdateBlock(idx, { ...block, caption: caption || undefined })
|
||||||
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
|
|
||||||
}
|
}
|
||||||
placeholder="Caption, e.g. Busan → Seoul (optional)"
|
placeholder="Caption, e.g. Busan → Seoul (optional)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
63
app/admin/components/InlineMarkdownField.tsx
Normal file
63
app/admin/components/InlineMarkdownField.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react';
|
||||||
|
import { inlineHtmlToMarkdown, renderInlineMarkdown } from '@/lib/journal';
|
||||||
|
|
||||||
|
type SharedProps = {
|
||||||
|
/**
|
||||||
|
* The block field as the parser stores it: HTML from renderInlineMarkdown
|
||||||
|
* (a paragraph's `html`, a quote's `text`, a caption, a fact value).
|
||||||
|
*/
|
||||||
|
html: string | undefined;
|
||||||
|
/** Receives the field as HTML again, rendered from what was typed. */
|
||||||
|
onChange: (html: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type InputProps = SharedProps & {
|
||||||
|
multiline?: false;
|
||||||
|
} & Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'type'>;
|
||||||
|
|
||||||
|
type TextareaProps = SharedProps & {
|
||||||
|
multiline: true;
|
||||||
|
} & Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'value' | 'onChange'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A text field for an inline-markdown block field (A-5).
|
||||||
|
*
|
||||||
|
* Blocks hold rendered HTML, since that is what the public page and the live
|
||||||
|
* preview show. The editors used to put that HTML straight into the field: a
|
||||||
|
* reloaded entry showed `<strong>` and `<a href="…">` in the textarea, text
|
||||||
|
* typed into a tag was lost on save, and typed `**bold**` stayed literal in
|
||||||
|
* the preview until a save re-parsed the file.
|
||||||
|
*
|
||||||
|
* The field shows the markdown source instead and hands every edit back
|
||||||
|
* rendered, so the preview is right while typing and the serializer writes
|
||||||
|
* the same markdown for an untouched block. What was typed is kept as typed:
|
||||||
|
* deriving the field from the HTML on every keystroke would rewrite `__x__`
|
||||||
|
* to `**x**`, or drop a half-typed link, under the cursor. The field re-reads
|
||||||
|
* the HTML only when it changes from outside (markdown mode, a reorder, a
|
||||||
|
* restore).
|
||||||
|
*/
|
||||||
|
export function InlineMarkdownField(props: InputProps | TextareaProps) {
|
||||||
|
const html = props.html ?? '';
|
||||||
|
const [text, setText] = useState(() => inlineHtmlToMarkdown(html));
|
||||||
|
const [shownHtml, setShownHtml] = useState(html);
|
||||||
|
if (html !== shownHtml) {
|
||||||
|
setShownHtml(html);
|
||||||
|
setText(inlineHtmlToMarkdown(html));
|
||||||
|
}
|
||||||
|
|
||||||
|
const update = (next: string) => {
|
||||||
|
const nextHtml = renderInlineMarkdown(next);
|
||||||
|
setText(next);
|
||||||
|
setShownHtml(nextHtml);
|
||||||
|
props.onChange(nextHtml);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (props.multiline) {
|
||||||
|
const { html: _html, onChange: _onChange, multiline: _multiline, ...rest } = props;
|
||||||
|
return <textarea {...rest} value={text} onChange={(e) => update(e.target.value)} />;
|
||||||
|
}
|
||||||
|
const { html: _html, onChange: _onChange, multiline: _multiline, ...rest } = props;
|
||||||
|
return <input type="text" {...rest} value={text} onChange={(e) => update(e.target.value)} />;
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
228
app/admin/components/PrivacyEditor.tsx
Normal file
228
app/admin/components/PrivacyEditor.tsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editor for content/privacy.md (#699), in Settings → Legal.
|
||||||
|
*
|
||||||
|
* Folio writes no legal text. Next to the editor it lists what this
|
||||||
|
* installation processes, read off the configuration, and "Insert headings"
|
||||||
|
* adds the section headings that list implies, nothing more.
|
||||||
|
*
|
||||||
|
* Saves on its own, like the About page: the policy is a Markdown file, not a
|
||||||
|
* key in settings.yaml.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import * as Icons from './Icons';
|
||||||
|
import { reportIfSessionExpired } from './sessionExpiry';
|
||||||
|
import { useUnsavedGuard } from './useUnsavedGuard';
|
||||||
|
import { useContentRestored } from './contentRestored';
|
||||||
|
import { useVersionedSave } from './useVersionedSave';
|
||||||
|
import { useLatest } from './useLatest';
|
||||||
|
import { useDraft } from './useDraft';
|
||||||
|
import DraftNotice from './DraftNotice';
|
||||||
|
import type { ProcessingFact } from '@/lib/privacy';
|
||||||
|
|
||||||
|
interface PrivacyData {
|
||||||
|
body: string;
|
||||||
|
enabled: boolean;
|
||||||
|
facts: ProcessingFact[];
|
||||||
|
starter: string;
|
||||||
|
version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Status = { kind: 'success' | 'error'; message: string } | null;
|
||||||
|
|
||||||
|
export default function PrivacyEditor() {
|
||||||
|
const [data, setData] = useState<PrivacyData | null>(null);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const [body, setBody] = useState('');
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [status, setStatus] = useState<Status>(null);
|
||||||
|
|
||||||
|
useUnsavedGuard(dirty);
|
||||||
|
// The editor lives in the Legal section and unmounts when another section
|
||||||
|
// opens; without a draft an unsaved policy was simply gone (#554, #592).
|
||||||
|
const draft = useDraft<string>('privacy', body, dirty);
|
||||||
|
const loadDraft = draft.load;
|
||||||
|
|
||||||
|
/** privacy.md as loaded, sent back on save so a change elsewhere is caught (#601). */
|
||||||
|
const versionRef = useRef<string | null>(null);
|
||||||
|
const versionedSave = useVersionedSave();
|
||||||
|
/** The text as last rendered: tells a finished save whether typing went on meanwhile. */
|
||||||
|
const latestBody = useLatest(body);
|
||||||
|
const dirtyRef = useRef(dirty);
|
||||||
|
useEffect(() => {
|
||||||
|
dirtyRef.current = dirty;
|
||||||
|
});
|
||||||
|
|
||||||
|
const load = useCallback(async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/privacy');
|
||||||
|
if (!res.ok) {
|
||||||
|
reportIfSessionExpired(res);
|
||||||
|
throw new Error(`HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
const d = (await res.json()) as PrivacyData;
|
||||||
|
versionRef.current = d.version ?? null;
|
||||||
|
setData(d);
|
||||||
|
const restored = loadDraft(d.body);
|
||||||
|
setBody(restored ?? d.body);
|
||||||
|
setDirty(restored !== null);
|
||||||
|
} catch (err) {
|
||||||
|
setLoadError(err instanceof Error ? err.message : 'Could not load.');
|
||||||
|
}
|
||||||
|
}, [loadDraft]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
// A restored privacy.md replaces what this editor shows — unless there are
|
||||||
|
// unsaved edits, which stay; their save then meets the conflict prompt.
|
||||||
|
useContentRestored(({ target }) => {
|
||||||
|
if (target === 'privacy' && !dirtyRef.current) void load();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true);
|
||||||
|
setStatus(null);
|
||||||
|
const sent = body;
|
||||||
|
try {
|
||||||
|
const result = await versionedSave(
|
||||||
|
'/api/admin/privacy',
|
||||||
|
{ body: sent },
|
||||||
|
versionRef,
|
||||||
|
'The privacy policy',
|
||||||
|
);
|
||||||
|
if (result.kind === 'reload') {
|
||||||
|
draft.discard();
|
||||||
|
await load();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.kind === 'keep') {
|
||||||
|
setStatus({ kind: 'error', message: 'Not saved — the policy changed elsewhere.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.kind === 'failed') {
|
||||||
|
if (!reportIfSessionExpired(result.res)) {
|
||||||
|
setStatus({ kind: 'error', message: `Could not save (HTTP ${result.res.status}).` });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Typed while the request was out: that part is not saved yet.
|
||||||
|
const editedMeanwhile = latestBody.current !== sent;
|
||||||
|
setData((d) => (d ? { ...d, body: sent } : d));
|
||||||
|
draft.saved(sent, editedMeanwhile);
|
||||||
|
if (!editedMeanwhile) setDirty(false);
|
||||||
|
setStatus({ kind: 'success', message: 'Privacy policy saved.' });
|
||||||
|
} catch {
|
||||||
|
setStatus({ kind: 'error', message: 'Could not reach the server.' });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertHeadings() {
|
||||||
|
if (!data) return;
|
||||||
|
setBody((b) => (b.trim() ? `${b.trimEnd()}\n\n${data.starter}\n` : `${data.starter}\n`));
|
||||||
|
setDirty(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadError) {
|
||||||
|
// No editor over an unknown file: saving an empty one would erase the policy.
|
||||||
|
return (
|
||||||
|
<p className="admin-field-hint admin-field-hint--error">
|
||||||
|
The privacy policy could not be loaded ({loadError}). Reload the page to try again.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!data) return <div className="admin-spinner" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="privacy-editor">
|
||||||
|
<DraftNotice
|
||||||
|
status={draft.status}
|
||||||
|
subject="privacy policy"
|
||||||
|
onDiscard={() => {
|
||||||
|
draft.discard();
|
||||||
|
setBody(data.body);
|
||||||
|
setDirty(false);
|
||||||
|
setStatus(null);
|
||||||
|
}}
|
||||||
|
onRestore={() => {
|
||||||
|
const value = draft.takeConflicting();
|
||||||
|
if (value === null) return;
|
||||||
|
setBody(value);
|
||||||
|
setDirty(true);
|
||||||
|
}}
|
||||||
|
onDismiss={draft.dismiss}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="privacy-editor__facts">
|
||||||
|
<h4>What this site processes</h4>
|
||||||
|
<p className="admin-field-hint">
|
||||||
|
Read off your current configuration. A checklist for writing the policy, not text for the
|
||||||
|
page.
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
{data.facts.map((f) => (
|
||||||
|
<li key={f.topic}>
|
||||||
|
<strong>{f.topic}</strong>
|
||||||
|
{f.thirdParty && <span className="privacy-editor__badge">third party</span>}
|
||||||
|
<span>{f.detail}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="privacy-body">Privacy policy (Markdown)</label>
|
||||||
|
<textarea
|
||||||
|
id="privacy-body"
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => {
|
||||||
|
setBody(e.target.value);
|
||||||
|
setDirty(true);
|
||||||
|
}}
|
||||||
|
rows={18}
|
||||||
|
placeholder="## Controller 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
511
app/admin/components/ProofingView.tsx
Normal file
511
app/admin/components/ProofingView.tsx
Normal file
@@ -0,0 +1,511 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin → Proofing: client links, their selections and the export.
|
||||||
|
*
|
||||||
|
* One card per link. Creating a link picks a client name and an album — any
|
||||||
|
* Immich album, published or not — plus optional expiry and download rights.
|
||||||
|
* A card expands into the selected photos and the export buttons.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import PageHeader from './PageHeader';
|
||||||
|
import * as Icons from './Icons';
|
||||||
|
import { reportIfSessionExpired } from './sessionExpiry';
|
||||||
|
import type { AdminProofPick, AdminProofSession } from '@/lib/admin/proofing-view';
|
||||||
|
|
||||||
|
interface AlbumOption {
|
||||||
|
id: string;
|
||||||
|
albumName: string;
|
||||||
|
assetCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Download = AdminProofSession['download'];
|
||||||
|
|
||||||
|
const STATE_LABEL: Record<AdminProofSession['state'], string> = {
|
||||||
|
open: 'Open',
|
||||||
|
submitted: 'Submitted',
|
||||||
|
expired: 'Expired',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DOWNLOAD_LABEL: Record<Download, string> = {
|
||||||
|
none: 'No downloads',
|
||||||
|
selection: 'Selection as ZIP',
|
||||||
|
album: 'Whole album as ZIP',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(iso: string | null): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const date = iso.length === 10 ? new Date(`${iso}T12:00:00`) : new Date(iso);
|
||||||
|
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(url, init);
|
||||||
|
if (reportIfSessionExpired(res)) throw new Error('Session expired');
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error((data as { error?: string }).error || `HTTP ${res.status}`);
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateForm({ onCreated }: { onCreated: () => void }) {
|
||||||
|
const [albums, setAlbums] = useState<AlbumOption[] | null>(null);
|
||||||
|
const [clientName, setClientName] = useState('');
|
||||||
|
const [albumId, setAlbumId] = useState('');
|
||||||
|
const [alias, setAlias] = useState('');
|
||||||
|
const [expiresOn, setExpiresOn] = useState('');
|
||||||
|
const [download, setDownload] = useState<Download>('none');
|
||||||
|
const [limit, setLimit] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api<{ albums: AlbumOption[] }>('/api/admin/albums')
|
||||||
|
.then((data) =>
|
||||||
|
setAlbums([...data.albums].sort((a, b) => a.albumName.localeCompare(b.albumName))),
|
||||||
|
)
|
||||||
|
.catch((err: Error) => setError(err.message));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const submit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api('/api/admin/proofing', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
clientName,
|
||||||
|
alias: alias.trim() || null,
|
||||||
|
albumId,
|
||||||
|
expiresOn: expiresOn || null,
|
||||||
|
download,
|
||||||
|
downloadLimit: download !== 'none' && limit ? Number(limit) : null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setClientName('');
|
||||||
|
setAlias('');
|
||||||
|
setAlbumId('');
|
||||||
|
setExpiresOn('');
|
||||||
|
setDownload('none');
|
||||||
|
setLimit('');
|
||||||
|
onCreated();
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="analytics-panel proofing-create" onSubmit={submit}>
|
||||||
|
<div className="analytics-section-title">
|
||||||
|
<h3>
|
||||||
|
<Icons.IconPlus size={16} /> New client link
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="proofing-create-grid">
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="proof-client">Client</label>
|
||||||
|
<input
|
||||||
|
id="proof-client"
|
||||||
|
value={clientName}
|
||||||
|
onChange={(e) => setClientName(e.target.value)}
|
||||||
|
placeholder="Anna & Ben"
|
||||||
|
maxLength={120}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="proof-alias">Client URL alias (optional)</label>
|
||||||
|
<input
|
||||||
|
id="proof-alias"
|
||||||
|
value={alias}
|
||||||
|
onChange={(e) =>
|
||||||
|
setAlias(
|
||||||
|
e.target.value
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/[^A-Za-z0-9-]/g, ''),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="Client"
|
||||||
|
maxLength={64}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="proof-album">Album</label>
|
||||||
|
<select
|
||||||
|
id="proof-album"
|
||||||
|
value={albumId}
|
||||||
|
onChange={(e) => setAlbumId(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={!albums}
|
||||||
|
>
|
||||||
|
<option value="">{albums ? 'Choose an Immich album…' : 'Loading albums…'}</option>
|
||||||
|
{albums?.map((album) => (
|
||||||
|
<option key={album.id} value={album.id}>
|
||||||
|
{album.albumName} ({album.assetCount})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="proof-expires">Valid until (optional)</label>
|
||||||
|
<input
|
||||||
|
id="proof-expires"
|
||||||
|
type="date"
|
||||||
|
value={expiresOn}
|
||||||
|
onChange={(e) => setExpiresOn(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="proof-download">Downloads</label>
|
||||||
|
<select
|
||||||
|
id="proof-download"
|
||||||
|
value={download}
|
||||||
|
onChange={(e) => setDownload(e.target.value as Download)}
|
||||||
|
>
|
||||||
|
{(Object.keys(DOWNLOAD_LABEL) as Download[]).map((key) => (
|
||||||
|
<option key={key} value={key}>
|
||||||
|
{DOWNLOAD_LABEL[key]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{download !== 'none' && (
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="proof-limit">Download limit (optional)</label>
|
||||||
|
<input
|
||||||
|
id="proof-limit"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={1000}
|
||||||
|
value={limit}
|
||||||
|
onChange={(e) => setLimit(e.target.value)}
|
||||||
|
placeholder="Unlimited"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && <p className="admin-error">{error}</p>}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="admin-btn admin-btn-primary"
|
||||||
|
disabled={busy || !clientName.trim() || !albumId}
|
||||||
|
>
|
||||||
|
<Icons.IconLink size={14} /> {busy ? 'Creating…' : 'Create link'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionCard({
|
||||||
|
session,
|
||||||
|
expanded,
|
||||||
|
onToggle,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
session: AdminProofSession;
|
||||||
|
expanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
onChanged: () => void;
|
||||||
|
}) {
|
||||||
|
const [picks, setPicks] = useState<AdminProofPick[] | null>(null);
|
||||||
|
const [copied, setCopied] = useState<'link' | 'lightroom' | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const linkUrl = typeof window !== 'undefined' ? `${window.location.origin}${session.link}` : '';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!expanded) return;
|
||||||
|
api<{ picks: AdminProofPick[] }>(`/api/admin/proofing/${session.id}`)
|
||||||
|
.then((data) => setPicks(data.picks))
|
||||||
|
.catch((err: Error) => setError(err.message));
|
||||||
|
}, [expanded, session.id, session.selected, session.submittedAt]);
|
||||||
|
|
||||||
|
const copy = (kind: 'link' | 'lightroom', text: string) => {
|
||||||
|
if (!navigator.clipboard) {
|
||||||
|
window.prompt('Copy:', text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.clipboard.writeText(text).then(
|
||||||
|
() => {
|
||||||
|
setCopied(kind);
|
||||||
|
setTimeout(() => setCopied(null), 2000);
|
||||||
|
},
|
||||||
|
() => window.prompt('Copy:', text),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const patch = async (body: Record<string, unknown>) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api(`/api/admin/proofing/${session.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
onChanged();
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
if (!window.confirm(`Delete the link for ${session.clientName}? It stops working at once.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api(`/api/admin/proofing/${session.id}`, { method: 'DELETE' });
|
||||||
|
onChanged();
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyLightroom = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/proofing/${session.id}/export?format=lightroom`);
|
||||||
|
if (reportIfSessionExpired(res)) return;
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
copy('lightroom', await res.text());
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadsText =
|
||||||
|
session.download === 'none'
|
||||||
|
? DOWNLOAD_LABEL.none
|
||||||
|
: `${DOWNLOAD_LABEL[session.download]} · ${session.downloadsUsed}${
|
||||||
|
session.downloadLimit !== null ? ` / ${session.downloadLimit}` : ''
|
||||||
|
} used`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`analytics-panel proofing-card proofing-card--${session.state}`}>
|
||||||
|
<div className="proofing-card-head">
|
||||||
|
<div>
|
||||||
|
<h3 className="proofing-card-title">
|
||||||
|
{session.clientName}
|
||||||
|
<span className={`proofing-badge proofing-badge--${session.state}`}>
|
||||||
|
{STATE_LABEL[session.state]}
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<p className="analytics-subtitle">
|
||||||
|
{session.albumName ?? 'Album not found in Immich'} · {session.selected}
|
||||||
|
{session.albumAssetCount !== null ? ` of ${session.albumAssetCount}` : ''} selected ·
|
||||||
|
created {formatDate(session.createdAt)}
|
||||||
|
{session.expiresOn ? ` · valid until ${formatDate(session.expiresOn)}` : ''}
|
||||||
|
</p>
|
||||||
|
<p className="analytics-subtitle">
|
||||||
|
{downloadsText}
|
||||||
|
{session.submittedAt ? ` · submitted ${formatDate(session.submittedAt)}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="proofing-card-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-btn admin-btn-ghost admin-btn-sm"
|
||||||
|
onClick={() => copy('link', linkUrl)}
|
||||||
|
>
|
||||||
|
{copied === 'link' ? <Icons.IconCheck size={14} /> : <Icons.IconCopy size={14} />}{' '}
|
||||||
|
{copied === 'link' ? 'Copied' : 'Copy link'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-btn admin-btn-ghost admin-btn-sm"
|
||||||
|
onClick={() => {
|
||||||
|
const next = window.prompt(
|
||||||
|
'Client URL alias (leave blank to remove):',
|
||||||
|
session.alias ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (next === null) return;
|
||||||
|
|
||||||
|
const cleaned = next
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/[^A-Za-z0-9-]/g, '');
|
||||||
|
|
||||||
|
void patch({
|
||||||
|
alias: cleaned || null,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icons.IconLink size={14} />
|
||||||
|
{session.alias ? ' Change alias' : ' Set alias'}
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
className="admin-btn admin-btn-ghost admin-btn-sm"
|
||||||
|
href={session.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<Icons.IconEye size={14} /> Open
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-btn admin-btn-ghost admin-btn-sm"
|
||||||
|
onClick={onToggle}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
>
|
||||||
|
{expanded ? <Icons.IconChevronUp size={14} /> : <Icons.IconChevronDown size={14} />}{' '}
|
||||||
|
Selection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="admin-error">{error}</p>}
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="proofing-card-body">
|
||||||
|
<div className="proofing-card-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||||
|
onClick={copyLightroom}
|
||||||
|
disabled={session.selected === 0}
|
||||||
|
title="File names without extension, comma-separated — paste into Lightroom's or Capture One's filename filter to find the RAW files too"
|
||||||
|
>
|
||||||
|
{copied === 'lightroom' ? (
|
||||||
|
<Icons.IconCheck size={14} />
|
||||||
|
) : (
|
||||||
|
<Icons.IconCopy size={14} />
|
||||||
|
)}{' '}
|
||||||
|
{copied === 'lightroom' ? 'Copied' : 'Copy file names (Lightroom)'}
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
className="admin-btn admin-btn-ghost admin-btn-sm"
|
||||||
|
href={`/api/admin/proofing/${session.id}/export?format=csv`}
|
||||||
|
>
|
||||||
|
<Icons.IconFileText size={14} /> CSV
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className="admin-btn admin-btn-ghost admin-btn-sm"
|
||||||
|
href={`/api/admin/proofing/${session.id}/export?format=txt`}
|
||||||
|
>
|
||||||
|
<Icons.IconFileText size={14} /> TXT
|
||||||
|
</a>
|
||||||
|
{session.submittedAt && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-btn admin-btn-ghost admin-btn-sm"
|
||||||
|
onClick={() => patch({ reopen: true })}
|
||||||
|
>
|
||||||
|
<Icons.IconRefresh size={14} /> Reopen for changes
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{session.download !== 'none' && session.downloadsUsed > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-btn admin-btn-ghost admin-btn-sm"
|
||||||
|
onClick={() => patch({ resetDownloads: true })}
|
||||||
|
>
|
||||||
|
<Icons.IconRefresh size={14} /> Reset downloads
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-btn admin-btn-ghost admin-btn-sm admin-btn-icon-danger"
|
||||||
|
onClick={remove}
|
||||||
|
>
|
||||||
|
<Icons.IconTrash size={14} /> Delete link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{picks === null ? (
|
||||||
|
<div className="admin-spinner" />
|
||||||
|
) : picks.length === 0 ? (
|
||||||
|
<p className="analytics-empty">No photos selected yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="proofing-picks">
|
||||||
|
{picks.map((pick) => (
|
||||||
|
<li key={pick.assetId} className="proofing-pick">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element -- admin thumbnails, served by our own proxy */}
|
||||||
|
<img src={pick.thumbUrl} alt="" loading="lazy" />
|
||||||
|
<span className="proofing-pick-name" title={pick.fileName}>
|
||||||
|
#{pick.position} · {pick.fileName}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProofingView() {
|
||||||
|
const [sessions, setSessions] = useState<AdminProofSession[] | null>(null);
|
||||||
|
const [webhookConfigured, setWebhookConfigured] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [expanded, setExpanded] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await api<{ sessions: AdminProofSession[]; webhookConfigured: boolean }>(
|
||||||
|
'/api/admin/proofing',
|
||||||
|
);
|
||||||
|
setSessions(data.sessions);
|
||||||
|
setWebhookConfigured(data.webhookConfigured);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// The webhook's admin link lands here with ?session=<id>: open that card.
|
||||||
|
const wanted = new URLSearchParams(window.location.search).get('session');
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- one-time read of the URL on mount
|
||||||
|
if (wanted) setExpanded(wanted);
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="analytics-view proofing-view">
|
||||||
|
<PageHeader
|
||||||
|
kicker="Visitors"
|
||||||
|
title="Client proofing"
|
||||||
|
description="A private link per client. Picks are saved as they go; you see them here once they submit."
|
||||||
|
actions={
|
||||||
|
<button className="admin-btn admin-btn-ghost admin-btn-sm" onClick={() => void load()}>
|
||||||
|
<Icons.IconRefresh size={14} /> Refresh
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!webhookConfigured && (
|
||||||
|
<p className="analytics-subtitle">
|
||||||
|
Tip: set <code>PROOFING_WEBHOOK_URL</code> to be notified (Discord, Slack, Gotify, n8n, …)
|
||||||
|
when a client submits.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CreateForm onCreated={() => void load()} />
|
||||||
|
|
||||||
|
{error && <p className="admin-error">{error}</p>}
|
||||||
|
|
||||||
|
{sessions === null && !error ? (
|
||||||
|
<div className="admin-loading-container">
|
||||||
|
<div className="admin-spinner" />
|
||||||
|
</div>
|
||||||
|
) : sessions && sessions.length === 0 ? (
|
||||||
|
<p className="analytics-empty">No client links yet.</p>
|
||||||
|
) : (
|
||||||
|
sessions?.map((session) => (
|
||||||
|
<SessionCard
|
||||||
|
key={session.id}
|
||||||
|
session={session}
|
||||||
|
expanded={expanded === session.id}
|
||||||
|
onToggle={() => setExpanded((cur) => (cur === session.id ? null : session.id))}
|
||||||
|
onChanged={() => void load()}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { JournalEditor } from '../journal/JournalEditor';
|
||||||
|
import { NotificationProvider } from '../Notifications';
|
||||||
|
import { ConfirmProvider } from '../ConfirmDialog';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page editor's Publish button flips the Draft flag and saves at once
|
||||||
|
* (#722). When that save fails, the flipped flag is an unsaved edit: the editor
|
||||||
|
* has to say so and keep it saveable, not show "Saved" over a page that is
|
||||||
|
* still a draft on disk.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MARKDOWN = '---\ntitle: "FAQ"\ndraft: true\n---\n\nHello\n';
|
||||||
|
|
||||||
|
let putStatus = 500;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
putStatus = 500;
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async (_url: string, init?: RequestInit) => {
|
||||||
|
if (init?.method === 'PUT') {
|
||||||
|
if (putStatus !== 200) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Failed to save page' }), {
|
||||||
|
status: putStatus,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const sent = JSON.parse(init.body as string) as { rawMarkdown: string };
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
page: { slug: 'faq', rawMarkdown: sent.rawMarkdown },
|
||||||
|
version: 'v2',
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ page: { slug: 'faq', rawMarkdown: MARKDOWN }, version: 'v1' }),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Node 26 defines its own global localStorage (undefined without
|
||||||
|
// --localstorage-file), which shadows jsdom's; give the editor a working one.
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
vi.stubGlobal('localStorage', {
|
||||||
|
getItem: (k: string) => store.get(k) ?? null,
|
||||||
|
setItem: (k: string, v: string) => void store.set(k, String(v)),
|
||||||
|
removeItem: (k: string) => void store.delete(k),
|
||||||
|
clear: () => store.clear(),
|
||||||
|
});
|
||||||
|
// Drafts live in sessionStorage; each test starts without one.
|
||||||
|
window.sessionStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderEditor() {
|
||||||
|
return render(
|
||||||
|
<NotificationProvider>
|
||||||
|
<ConfirmProvider>
|
||||||
|
<JournalEditor slug="faq" kind="page" onBack={() => {}} />
|
||||||
|
</ConfirmProvider>
|
||||||
|
</NotificationProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('JournalEditor page mode: Publish', () => {
|
||||||
|
it('leaves a failed publish as an unsaved change', async () => {
|
||||||
|
renderEditor();
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'Publish' }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole('alert').textContent).toContain('Failed to save page'),
|
||||||
|
);
|
||||||
|
const save = screen.getByRole('button', { name: 'Save Changes' });
|
||||||
|
expect((save as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads Saved after a publish that went through', async () => {
|
||||||
|
putStatus = 200;
|
||||||
|
renderEditor();
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'Publish' }));
|
||||||
|
|
||||||
|
await screen.findByRole('button', { name: 'Unpublish' });
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: /Saved/ })).toBeTruthy());
|
||||||
|
});
|
||||||
|
});
|
||||||
91
app/admin/components/__tests__/PrivacyEditor-draft.test.tsx
Normal file
91
app/admin/components/__tests__/PrivacyEditor-draft.test.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import PrivacyEditor from '../PrivacyEditor';
|
||||||
|
import { NotificationProvider } from '../Notifications';
|
||||||
|
import { ConfirmProvider } from '../ConfirmDialog';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The privacy editor sits in Settings → Legal and unmounts when another
|
||||||
|
* section opens. An unsaved policy used to go with it (A-7); it now keeps a
|
||||||
|
* draft like the About editor (#554, #592).
|
||||||
|
*/
|
||||||
|
|
||||||
|
let serverBody = 'Saved policy';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
serverBody = 'Saved policy';
|
||||||
|
window.sessionStorage.clear();
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
body: serverBody,
|
||||||
|
enabled: true,
|
||||||
|
facts: [],
|
||||||
|
starter: '## Controller',
|
||||||
|
version: 'v1',
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function mount() {
|
||||||
|
return render(
|
||||||
|
<NotificationProvider>
|
||||||
|
<ConfirmProvider>
|
||||||
|
<PrivacyEditor />
|
||||||
|
</ConfirmProvider>
|
||||||
|
</NotificationProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const editor = async () =>
|
||||||
|
(await screen.findByLabelText('Privacy policy (Markdown)')) as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
describe('PrivacyEditor draft (A-7)', () => {
|
||||||
|
it('brings back unsaved text after the editor was unmounted', async () => {
|
||||||
|
const first = mount();
|
||||||
|
fireEvent.change(await editor(), { target: { value: 'Unsaved policy' } });
|
||||||
|
first.unmount();
|
||||||
|
|
||||||
|
mount();
|
||||||
|
expect((await editor()).value).toBe('Unsaved policy');
|
||||||
|
expect(screen.getByText(/unsaved changes to the privacy policy were restored/)).toBeTruthy();
|
||||||
|
const save = screen.getByRole('button', { name: 'Save privacy policy' }) as HTMLButtonElement;
|
||||||
|
expect(save.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discarding the draft shows the saved policy again', async () => {
|
||||||
|
const first = mount();
|
||||||
|
fireEvent.change(await editor(), { target: { value: 'Unsaved policy' } });
|
||||||
|
first.unmount();
|
||||||
|
|
||||||
|
mount();
|
||||||
|
await editor();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Discard changes' }));
|
||||||
|
expect((await editor()).value).toBe('Saved policy');
|
||||||
|
await waitFor(() => expect(window.sessionStorage.getItem('folio_draft_privacy')).toBeNull());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds a draft back when the file changed since, and asks', async () => {
|
||||||
|
const first = mount();
|
||||||
|
fireEvent.change(await editor(), { target: { value: 'Unsaved policy' } });
|
||||||
|
first.unmount();
|
||||||
|
|
||||||
|
serverBody = 'Changed in another tab';
|
||||||
|
mount();
|
||||||
|
expect((await editor()).value).toBe('Changed in another tab');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Restore my changes' }));
|
||||||
|
expect((await editor()).value).toBe('Unsaved policy');
|
||||||
|
});
|
||||||
|
});
|
||||||
176
app/admin/components/__tests__/admin-dialogs-a11y.test.tsx
Normal file
176
app/admin/components/__tests__/admin-dialogs-a11y.test.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
|
||||||
|
import { useModalDialog } from '@/hooks/useModalDialog';
|
||||||
|
import AlbumPicker from '../AlbumPicker';
|
||||||
|
import AlbumDrawer from '../page-builder/AlbumDrawer';
|
||||||
|
import { StorySettingsModal } from '../journal/StorySettingsModal';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
name,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
onClose: () => void;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const ref = useModalDialog(onClose);
|
||||||
|
return (
|
||||||
|
<div ref={ref} role="dialog" aria-modal="true" aria-label={name}>
|
||||||
|
<button type="button">{name} first</button>
|
||||||
|
{children}
|
||||||
|
<button type="button">{name} last</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const escape = () => fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
|
||||||
|
|
||||||
|
describe('useModalDialog with nested dialogs (A-9)', () => {
|
||||||
|
it('Escape closes only the innermost dialog', () => {
|
||||||
|
const closeOuter = vi.fn();
|
||||||
|
const closeInner = vi.fn();
|
||||||
|
const { rerender } = render(<Dialog name="outer" onClose={closeOuter} />);
|
||||||
|
rerender(
|
||||||
|
<Dialog name="outer" onClose={closeOuter}>
|
||||||
|
<Dialog name="inner" onClose={closeInner} />
|
||||||
|
</Dialog>,
|
||||||
|
);
|
||||||
|
escape();
|
||||||
|
expect(closeInner).toHaveBeenCalledTimes(1);
|
||||||
|
expect(closeOuter).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Once the inner one is gone, the outer one answers again.
|
||||||
|
rerender(<Dialog name="outer" onClose={closeOuter} />);
|
||||||
|
escape();
|
||||||
|
expect(closeOuter).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves Escape to an expanded combobox', () => {
|
||||||
|
const close = vi.fn();
|
||||||
|
render(
|
||||||
|
<Dialog name="d" onClose={close}>
|
||||||
|
<button type="button" role="combobox" aria-expanded="true" aria-controls="x">
|
||||||
|
sort
|
||||||
|
</button>
|
||||||
|
</Dialog>,
|
||||||
|
);
|
||||||
|
screen.getByRole('combobox').focus();
|
||||||
|
escape();
|
||||||
|
expect(close).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves Escape to an item picked up with the keyboard (dnd-kit)', () => {
|
||||||
|
const close = vi.fn();
|
||||||
|
render(
|
||||||
|
<Dialog name="d" onClose={close}>
|
||||||
|
<div role="button" tabIndex={0} aria-roledescription="sortable" aria-pressed="true">
|
||||||
|
tile
|
||||||
|
</div>
|
||||||
|
</Dialog>,
|
||||||
|
);
|
||||||
|
screen.getByText('tile').focus();
|
||||||
|
escape();
|
||||||
|
expect(close).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AlbumPicker from the keyboard (A-10)', () => {
|
||||||
|
const albums = [
|
||||||
|
{
|
||||||
|
id: 'a1',
|
||||||
|
albumName: 'Alps',
|
||||||
|
description: '',
|
||||||
|
thumbnailAssetId: null,
|
||||||
|
assetCount: 3,
|
||||||
|
isConfigured: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'a2',
|
||||||
|
albumName: 'Baltic',
|
||||||
|
description: '',
|
||||||
|
thumbnailAssetId: null,
|
||||||
|
assetCount: 5,
|
||||||
|
isConfigured: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it('offers each free album as a button and disables the ones in use', () => {
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
render(
|
||||||
|
<AlbumPicker
|
||||||
|
albums={albums}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onClose={() => {}}
|
||||||
|
usedAlbumIds={new Set(['a2'])}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const alps = screen.getByRole('button', { name: /Alps/ });
|
||||||
|
const baltic = screen.getByRole('button', { name: /Baltic/ }) as HTMLButtonElement;
|
||||||
|
expect(baltic.disabled).toBe(true);
|
||||||
|
fireEvent.click(alps);
|
||||||
|
expect(onSelect).toHaveBeenCalledWith('a1');
|
||||||
|
expect(screen.getByRole('button', { name: 'Close' })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes on Escape', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<AlbumPicker
|
||||||
|
albums={albums}
|
||||||
|
onSelect={() => {}}
|
||||||
|
onClose={onClose}
|
||||||
|
usedAlbumIds={new Set()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
escape();
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AlbumDrawer as a dialog (A-9)', () => {
|
||||||
|
it('is a labelled modal dialog that closes on Escape', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<AlbumDrawer
|
||||||
|
album={{ id: 'a1' }}
|
||||||
|
name="Alps"
|
||||||
|
count={3}
|
||||||
|
thumbnailId={null}
|
||||||
|
onUpdate={() => {}}
|
||||||
|
onRemove={() => {}}
|
||||||
|
onClose={onClose}
|
||||||
|
onPickHero={() => {}}
|
||||||
|
onEditOrder={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const dialog = screen.getByRole('dialog', { name: /Edit Album Details/ });
|
||||||
|
expect(dialog.getAttribute('aria-modal')).toBe('true');
|
||||||
|
expect(dialog.contains(document.activeElement)).toBe(true);
|
||||||
|
escape();
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('StorySettingsModal as a dialog (A-9)', () => {
|
||||||
|
it('is a labelled modal dialog that closes on Escape', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<StorySettingsModal
|
||||||
|
frontmatter={{ title: 'Trip' }}
|
||||||
|
onChange={() => {}}
|
||||||
|
onPickAsset={() => {}}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const dialog = screen.getByRole('dialog', { name: /Story Settings/ });
|
||||||
|
expect(dialog.getAttribute('aria-modal')).toBe('true');
|
||||||
|
escape();
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
266
app/admin/components/__tests__/inflight-save-edits.test.tsx
Normal file
266
app/admin/components/__tests__/inflight-save-edits.test.tsx
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, cleanup, fireEvent, waitFor, act } from '@testing-library/react';
|
||||||
|
import { JournalEditor } from '../journal/JournalEditor';
|
||||||
|
import PrivacyEditor from '../PrivacyEditor';
|
||||||
|
import SettingsEditor from '../SettingsEditor';
|
||||||
|
import PageBuilder from '../PageBuilder';
|
||||||
|
import { NotificationProvider } from '../Notifications';
|
||||||
|
import { ConfirmProvider } from '../ConfirmDialog';
|
||||||
|
|
||||||
|
const nav = vi.hoisted(() => ({ section: 'general' }));
|
||||||
|
vi.mock('next/navigation', () => ({
|
||||||
|
useRouter: () => ({ refresh: () => {}, push: () => {}, replace: () => {} }),
|
||||||
|
useParams: () => ({ section: nav.section }),
|
||||||
|
usePathname: () => '/admin/settings/general',
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A save handler closes over the state it started with. Typing on while the
|
||||||
|
* request is out used to be marked saved when the answer came back: the Save
|
||||||
|
* button went to "Saved" and disabled, the unsaved-changes guard and the draft
|
||||||
|
* let go, and leaving the editor threw those edits away. Where the server
|
||||||
|
* rewrote the text (a hashed password line), the newer text was also replaced
|
||||||
|
* by the file as written.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A PUT that answers only when the test says so. */
|
||||||
|
let releasePut: ((written?: string) => void) | null = null;
|
||||||
|
let putBodies: unknown[] = [];
|
||||||
|
|
||||||
|
function stubFetch(
|
||||||
|
loadResponse: unknown,
|
||||||
|
putResponse: (sent: Record<string, unknown>, written?: string) => unknown,
|
||||||
|
) {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async (_url: string, init?: RequestInit) => {
|
||||||
|
if (init?.method === 'PUT') {
|
||||||
|
const sent = JSON.parse(init.body as string) as Record<string, unknown>;
|
||||||
|
putBodies.push(sent);
|
||||||
|
const written = await new Promise<string | undefined>((resolve) => {
|
||||||
|
releasePut = resolve;
|
||||||
|
});
|
||||||
|
return new Response(JSON.stringify(putResponse(sent, written)), { status: 200 });
|
||||||
|
}
|
||||||
|
const body = typeof loadResponse === 'function' ? loadResponse(_url) : loadResponse;
|
||||||
|
return new Response(JSON.stringify(body), { status: 200 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
nav.section = 'general';
|
||||||
|
releasePut = null;
|
||||||
|
putBodies = [];
|
||||||
|
// Node 26 defines its own global localStorage (undefined without
|
||||||
|
// --localstorage-file), which shadows jsdom's; give the editor a working one.
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
vi.stubGlobal('localStorage', {
|
||||||
|
getItem: (k: string) => store.get(k) ?? null,
|
||||||
|
setItem: (k: string, v: string) => void store.set(k, String(v)),
|
||||||
|
removeItem: (k: string) => void store.delete(k),
|
||||||
|
clear: () => store.clear(),
|
||||||
|
});
|
||||||
|
window.sessionStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
const ENTRY = '---\ntitle: "Trip"\n---\n\nHello\n';
|
||||||
|
|
||||||
|
async function renderJournal() {
|
||||||
|
stubFetch({ entry: { slug: 'trip', rawMarkdown: ENTRY }, version: 'v1' }, (sent, written) => ({
|
||||||
|
success: true,
|
||||||
|
entry: { slug: 'trip', rawMarkdown: written ?? sent.rawMarkdown },
|
||||||
|
version: 'v2',
|
||||||
|
}));
|
||||||
|
render(
|
||||||
|
<NotificationProvider>
|
||||||
|
<ConfirmProvider>
|
||||||
|
<JournalEditor slug="trip" onBack={() => {}} />
|
||||||
|
</ConfirmProvider>
|
||||||
|
</NotificationProvider>,
|
||||||
|
);
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'Raw Markdown' }));
|
||||||
|
return screen.getByPlaceholderText('Write Markdown here...') as HTMLTextAreaElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('JournalEditor: edits made while a save is in flight', () => {
|
||||||
|
it('stay unsaved and saveable', async () => {
|
||||||
|
const editor = await renderJournal();
|
||||||
|
fireEvent.change(editor, { target: { value: `${ENTRY}First\n` } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
|
||||||
|
await waitFor(() => expect(releasePut).not.toBeNull());
|
||||||
|
|
||||||
|
fireEvent.change(editor, { target: { value: `${ENTRY}First\nSecond\n` } });
|
||||||
|
await act(async () => releasePut!());
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByRole('button', { name: 'Saving...' })).toBeNull());
|
||||||
|
expect(editor.value).toBe(`${ENTRY}First\nSecond\n`);
|
||||||
|
const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement;
|
||||||
|
expect(save.disabled).toBe(false);
|
||||||
|
// Still kept as a draft, so leaving the editor does not lose it.
|
||||||
|
expect(window.sessionStorage.getItem('folio_draft_journal-trip')).toContain('Second');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('are not replaced by the file as written', async () => {
|
||||||
|
const editor = await renderJournal();
|
||||||
|
fireEvent.change(editor, { target: { value: `${ENTRY}First\n` } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
|
||||||
|
await waitFor(() => expect(releasePut).not.toBeNull());
|
||||||
|
|
||||||
|
fireEvent.change(editor, { target: { value: `${ENTRY}First\nSecond\n` } });
|
||||||
|
// The server rewrote what it was sent, as it does when it hashes a password.
|
||||||
|
await act(async () => releasePut!(`${ENTRY}First (as written)\n`));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByRole('button', { name: 'Saving...' })).toBeNull());
|
||||||
|
expect(editor.value).toBe(`${ENTRY}First\nSecond\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a save with no edits meanwhile still ends clean, showing the file as written', async () => {
|
||||||
|
const editor = await renderJournal();
|
||||||
|
fireEvent.change(editor, { target: { value: `${ENTRY}First\n` } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
|
||||||
|
await waitFor(() => expect(releasePut).not.toBeNull());
|
||||||
|
await act(async () => releasePut!(`${ENTRY}First (as written)\n`));
|
||||||
|
|
||||||
|
await screen.findByRole('button', { name: /Saved/ });
|
||||||
|
expect(editor.value).toBe(`${ENTRY}First (as written)\n`);
|
||||||
|
expect(window.sessionStorage.getItem('folio_draft_journal-trip')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PrivacyEditor: edits made while a save is in flight', () => {
|
||||||
|
it('stay unsaved and saveable', async () => {
|
||||||
|
stubFetch(
|
||||||
|
{ body: 'Old', enabled: true, facts: [], starter: '## Controller', version: 'v1' },
|
||||||
|
() => ({ success: true, version: 'v2' }),
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
<NotificationProvider>
|
||||||
|
<ConfirmProvider>
|
||||||
|
<PrivacyEditor />
|
||||||
|
</ConfirmProvider>
|
||||||
|
</NotificationProvider>,
|
||||||
|
);
|
||||||
|
const editor = (await screen.findByLabelText(
|
||||||
|
'Privacy policy (Markdown)',
|
||||||
|
)) as HTMLTextAreaElement;
|
||||||
|
fireEvent.change(editor, { target: { value: 'First' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save privacy policy' }));
|
||||||
|
await waitFor(() => expect(releasePut).not.toBeNull());
|
||||||
|
|
||||||
|
fireEvent.change(editor, { target: { value: 'First and second' } });
|
||||||
|
await act(async () => releasePut!());
|
||||||
|
|
||||||
|
await screen.findByText('Privacy policy saved.');
|
||||||
|
const save = screen.getByRole('button', { name: 'Save privacy policy' }) as HTMLButtonElement;
|
||||||
|
expect(save.disabled).toBe(false);
|
||||||
|
expect(putBodies).toEqual([{ body: 'First' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SettingsEditor: edits made while a save is in flight', () => {
|
||||||
|
it('stay unsaved and saveable', async () => {
|
||||||
|
stubFetch({ settings: { title: 'Old' }, version: 'v1', envLocks: {} }, () => ({
|
||||||
|
success: true,
|
||||||
|
version: 'v2',
|
||||||
|
}));
|
||||||
|
render(
|
||||||
|
<NotificationProvider>
|
||||||
|
<ConfirmProvider>
|
||||||
|
<SettingsEditor />
|
||||||
|
</ConfirmProvider>
|
||||||
|
</NotificationProvider>,
|
||||||
|
);
|
||||||
|
const title = (await screen.findByLabelText('Site Title')) as HTMLInputElement;
|
||||||
|
fireEvent.change(title, { target: { value: 'First' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
|
||||||
|
await waitFor(() => expect(releasePut).not.toBeNull());
|
||||||
|
|
||||||
|
fireEvent.change(title, { target: { value: 'First and second' } });
|
||||||
|
await act(async () => releasePut!());
|
||||||
|
|
||||||
|
await screen.findByText('Saved!');
|
||||||
|
expect(title.value).toBe('First and second');
|
||||||
|
const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement;
|
||||||
|
expect(save.disabled).toBe(false);
|
||||||
|
expect(window.sessionStorage.getItem('folio_draft_settings')).toContain('First and second');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('About editor: edits made while a save is in flight', () => {
|
||||||
|
it('stay unsaved and saveable', async () => {
|
||||||
|
nav.section = 'about';
|
||||||
|
stubFetch(
|
||||||
|
(url: string) =>
|
||||||
|
url === '/api/admin/about'
|
||||||
|
? { meta: { name: 'Ada' }, body: 'Old', version: 'v1' }
|
||||||
|
: { settings: {}, version: 'v1', envLocks: {} },
|
||||||
|
() => ({ success: true, version: 'v2' }),
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
<NotificationProvider>
|
||||||
|
<ConfirmProvider>
|
||||||
|
<SettingsEditor />
|
||||||
|
</ConfirmProvider>
|
||||||
|
</NotificationProvider>,
|
||||||
|
);
|
||||||
|
const bio = (await screen.findByLabelText('Biography (Markdown)')) as HTMLTextAreaElement;
|
||||||
|
fireEvent.change(bio, { target: { value: 'First' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
|
||||||
|
await waitFor(() => expect(releasePut).not.toBeNull());
|
||||||
|
|
||||||
|
fireEvent.change(bio, { target: { value: 'First and second' } });
|
||||||
|
await act(async () => releasePut!());
|
||||||
|
|
||||||
|
await screen.findByText('Saved!');
|
||||||
|
expect(bio.value).toBe('First and second');
|
||||||
|
const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement;
|
||||||
|
expect(save.disabled).toBe(false);
|
||||||
|
expect(window.sessionStorage.getItem('folio_draft_about')).toContain('First and second');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PageBuilder: edits made while a save is in flight', () => {
|
||||||
|
it('stay unsaved and saveable, and are not replaced by the file as written', async () => {
|
||||||
|
stubFetch(
|
||||||
|
(url: string) =>
|
||||||
|
url === '/api/admin/gallery'
|
||||||
|
? { gallery: { hero: [], albums: [], subpages: [] }, version: 'v1' }
|
||||||
|
: url === '/api/admin/albums'
|
||||||
|
? { albums: [] }
|
||||||
|
: { pages: [], taken: { subpages: [], albums: [], journal: [] } },
|
||||||
|
// As written: hashed passwords make the server copy differ from the one sent.
|
||||||
|
(sent) => ({
|
||||||
|
success: true,
|
||||||
|
version: 'v2',
|
||||||
|
gallery: { ...(sent.gallery as object), hero: ['written-by-server'] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
<NotificationProvider>
|
||||||
|
<ConfirmProvider>
|
||||||
|
<PageBuilder />
|
||||||
|
</ConfirmProvider>
|
||||||
|
</NotificationProvider>,
|
||||||
|
);
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'New subpage' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
|
||||||
|
await waitFor(() => expect(releasePut).not.toBeNull());
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'New subpage' }));
|
||||||
|
await act(async () => releasePut!());
|
||||||
|
|
||||||
|
await screen.findByText('Saved successfully!');
|
||||||
|
const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement;
|
||||||
|
expect(save.disabled).toBe(false);
|
||||||
|
const kept = JSON.parse(window.sessionStorage.getItem('folio_draft_page-builder') ?? 'null');
|
||||||
|
expect(kept?.value?.subpages).toHaveLength(2);
|
||||||
|
expect((putBodies[0] as { gallery: { subpages: unknown[] } }).gallery.subpages).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
216
app/admin/components/__tests__/inline-markdown-fields.test.tsx
Normal file
216
app/admin/components/__tests__/inline-markdown-fields.test.tsx
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
|
||||||
|
import { JournalEditor } from '../journal/JournalEditor';
|
||||||
|
import { EssayBlockEditor } from '../EssayBlockEditor';
|
||||||
|
import { InlineMarkdownField } from '../InlineMarkdownField';
|
||||||
|
import { NotificationProvider } from '../Notifications';
|
||||||
|
import { ConfirmProvider } from '../ConfirmDialog';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text blocks are edited as markdown, not as the HTML the parser stores (A-5).
|
||||||
|
* A reloaded entry used to show `<strong>` and `<a href=…>` in the textarea,
|
||||||
|
* text typed into a tag was lost on save, and typed `**bold**` stayed literal
|
||||||
|
* in the live preview until the first save.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ID = '3f1c9a52-8d4e-4b7a-9c1d-0e2f3a4b5c6d';
|
||||||
|
const ENTRY = [
|
||||||
|
'---',
|
||||||
|
'title: "Trip"',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'Hello **bold** and [Immich](https://immich.app).',
|
||||||
|
'',
|
||||||
|
'> A *quoted* line -- Someone',
|
||||||
|
'',
|
||||||
|
``,
|
||||||
|
'',
|
||||||
|
'::facts',
|
||||||
|
'Distance: **21** km',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// The live preview's FadeIn reveals at once under reduced motion instead of
|
||||||
|
// needing IntersectionObserver.
|
||||||
|
window.matchMedia = ((query: string) => ({
|
||||||
|
matches: query.includes('reduce'),
|
||||||
|
media: query,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
})) as unknown as typeof window.matchMedia;
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async (_url: string, init?: RequestInit) => {
|
||||||
|
if (init?.method === 'PUT') {
|
||||||
|
const sent = JSON.parse(init.body as string) as { rawMarkdown: string };
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
entry: { slug: 'trip', rawMarkdown: sent.rawMarkdown },
|
||||||
|
version: 'v2',
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ entry: { slug: 'trip', rawMarkdown: ENTRY }, version: 'v1' }),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Node 26 defines its own global localStorage (undefined without
|
||||||
|
// --localstorage-file), which shadows jsdom's; give the editor a working one.
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
vi.stubGlobal('localStorage', {
|
||||||
|
getItem: (k: string) => store.get(k) ?? null,
|
||||||
|
setItem: (k: string, v: string) => void store.set(k, String(v)),
|
||||||
|
removeItem: (k: string) => void store.delete(k),
|
||||||
|
clear: () => store.clear(),
|
||||||
|
});
|
||||||
|
window.sessionStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function renderJournal() {
|
||||||
|
render(
|
||||||
|
<NotificationProvider>
|
||||||
|
<ConfirmProvider>
|
||||||
|
<JournalEditor slug="trip" onBack={() => {}} />
|
||||||
|
</ConfirmProvider>
|
||||||
|
</NotificationProvider>,
|
||||||
|
);
|
||||||
|
return (await screen.findByLabelText('Text')) as HTMLTextAreaElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The entry as the editor would save it, read from the Raw Markdown view. */
|
||||||
|
function rawMarkdown(): string {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Raw Markdown' }));
|
||||||
|
const value = (screen.getByPlaceholderText('Write Markdown here...') as HTMLTextAreaElement)
|
||||||
|
.value;
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Visual Blocks' }));
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('journal editor: text blocks as markdown (A-5)', () => {
|
||||||
|
it('shows the markdown source, not the stored HTML, after a load', async () => {
|
||||||
|
const text = await renderJournal();
|
||||||
|
expect(text.value).toBe('Hello **bold** and [Immich](https://immich.app).');
|
||||||
|
expect(screen.getByDisplayValue('A *quoted* line')).toBeTruthy();
|
||||||
|
expect(screen.getByDisplayValue('A **nice** view')).toBeTruthy();
|
||||||
|
expect(screen.getByDisplayValue('**21** km')).toBeTruthy();
|
||||||
|
expect(document.body.querySelector('textarea, input')).toBeTruthy();
|
||||||
|
for (const field of document.body.querySelectorAll('textarea, input')) {
|
||||||
|
expect((field as HTMLInputElement).value).not.toMatch(/<(strong|em|a)\b/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders typed markdown in the live preview before any save', async () => {
|
||||||
|
const text = await renderJournal();
|
||||||
|
fireEvent.change(text, { target: { value: 'Now **strong** text.' } });
|
||||||
|
const preview = document.querySelector('.journal-preview-frame')!;
|
||||||
|
expect(preview.innerHTML).toContain('<strong>strong</strong>');
|
||||||
|
expect(preview.textContent).not.toContain('**strong**');
|
||||||
|
// What was typed stays as typed.
|
||||||
|
expect(text.value).toBe('Now **strong** text.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps text typed inside a link label or bold run', async () => {
|
||||||
|
const text = await renderJournal();
|
||||||
|
fireEvent.change(text, {
|
||||||
|
target: { value: 'Hello **bold and more** and [Immich Photos](https://immich.app).' },
|
||||||
|
});
|
||||||
|
expect(rawMarkdown()).toContain(
|
||||||
|
'Hello **bold and more** and [Immich Photos](https://immich.app).',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes the same file when the content is unchanged', async () => {
|
||||||
|
const text = await renderJournal();
|
||||||
|
const original = text.value;
|
||||||
|
fireEvent.change(text, { target: { value: `${original} extra` } });
|
||||||
|
fireEvent.change(screen.getByLabelText('Text'), { target: { value: original } });
|
||||||
|
expect(rawMarkdown()).toBe(ENTRY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps `__` as typed while the file gets the canonical form', async () => {
|
||||||
|
const text = await renderJournal();
|
||||||
|
fireEvent.change(text, { target: { value: 'An __underscored__ word' } });
|
||||||
|
expect(text.value).toBe('An __underscored__ word');
|
||||||
|
expect(rawMarkdown()).toContain('An **underscored** word');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('journal editor: mode toggles (A-18)', () => {
|
||||||
|
// Rendered as admin-btn-xs, the active mode carried admin-btn-primary, whose
|
||||||
|
// background the size class overrode: both modes looked the same.
|
||||||
|
it('mark the active mode, starting on Visual Blocks and Desktop', async () => {
|
||||||
|
await renderJournal();
|
||||||
|
const pressed = (name: string) =>
|
||||||
|
screen.getByRole('button', { name }).getAttribute('aria-pressed');
|
||||||
|
|
||||||
|
expect(pressed('Visual Blocks')).toBe('true');
|
||||||
|
expect(pressed('Raw Markdown')).toBe('false');
|
||||||
|
expect(pressed('Desktop')).toBe('true');
|
||||||
|
expect(pressed('Mobile')).toBe('false');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Raw Markdown' }));
|
||||||
|
expect(pressed('Visual Blocks')).toBe('false');
|
||||||
|
expect(pressed('Raw Markdown')).toBe('true');
|
||||||
|
expect(screen.getByRole('button', { name: 'Raw Markdown' }).className).toContain('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('InlineMarkdownField', () => {
|
||||||
|
function Harness({ initial }: { initial: string }) {
|
||||||
|
const [html, setHtml] = useState(initial);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<InlineMarkdownField aria-label="Field" html={html} onChange={setHtml} />
|
||||||
|
<button type="button" onClick={() => setHtml('<em>replaced</em>')}>
|
||||||
|
Replace
|
||||||
|
</button>
|
||||||
|
<output data-testid="html">{html}</output>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('hands back HTML and re-reads it only on an outside change', () => {
|
||||||
|
render(<Harness initial="<strong>x</strong>" />);
|
||||||
|
const field = screen.getByLabelText('Field') as HTMLInputElement;
|
||||||
|
expect(field.value).toBe('**x**');
|
||||||
|
fireEvent.change(field, { target: { value: 'a [half](http' } });
|
||||||
|
expect(field.value).toBe('a [half](http');
|
||||||
|
fireEvent.change(field, { target: { value: 'a & b <c>' } });
|
||||||
|
expect(screen.getByTestId('html').textContent).toBe('a & b <c>');
|
||||||
|
expect(field.value).toBe('a & b <c>');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Replace' }));
|
||||||
|
expect(field.value).toBe('*replaced*');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('essay block editor: text blocks as markdown (A-5)', () => {
|
||||||
|
it('shows markdown and serializes an edit through the renderer', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<EssayBlockEditor
|
||||||
|
markdown={'Some *italic* and [a link](https://immich.app).'}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const text = screen.getByDisplayValue(
|
||||||
|
'Some *italic* and [a link](https://immich.app).',
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
fireEvent.change(text, {
|
||||||
|
target: { value: 'Some *italic* and [a longer link](https://immich.app).' },
|
||||||
|
});
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(
|
||||||
|
'Some *italic* and [a longer link](https://immich.app).',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
|
||||||
|
import SettingsEditor from '../SettingsEditor';
|
||||||
|
import { NotificationProvider } from '../Notifications';
|
||||||
|
import { ConfirmProvider } from '../ConfirmDialog';
|
||||||
|
|
||||||
|
const nav = vi.hoisted(() => ({ section: 'grid' }));
|
||||||
|
vi.mock('next/navigation', () => ({
|
||||||
|
useRouter: () => ({ refresh: () => {}, push: () => {}, replace: () => {} }),
|
||||||
|
useParams: () => ({ section: nav.section }),
|
||||||
|
usePathname: () => `/admin/settings/${nav.section}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A value the site would ignore is named next to its input as it is typed,
|
||||||
|
* not only after a refused save (QA A-14).
|
||||||
|
*/
|
||||||
|
beforeEach(() => {
|
||||||
|
window.sessionStorage.clear();
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ settings: { title: 'Folio' }, version: 'v1', envLocks: {} }),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function mount() {
|
||||||
|
return render(
|
||||||
|
<NotificationProvider>
|
||||||
|
<ConfirmProvider>
|
||||||
|
<SettingsEditor />
|
||||||
|
</ConfirmProvider>
|
||||||
|
</NotificationProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Settings form: values the site would ignore', () => {
|
||||||
|
it('marks grid columns outside 1–6 at the input', async () => {
|
||||||
|
nav.section = 'grid';
|
||||||
|
mount();
|
||||||
|
const columns = (await screen.findByLabelText(/Columns/)) as HTMLInputElement;
|
||||||
|
expect(columns.getAttribute('aria-invalid')).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.change(columns, { target: { value: '9' } });
|
||||||
|
expect(columns.getAttribute('aria-invalid')).toBe('true');
|
||||||
|
const error = document.getElementById(columns.getAttribute('aria-describedby')!);
|
||||||
|
expect(error?.textContent).toBe('Must be a whole number from 1 to 6.');
|
||||||
|
|
||||||
|
fireEvent.change(columns, { target: { value: '4' } });
|
||||||
|
expect(columns.getAttribute('aria-invalid')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks an accent that is not hex', async () => {
|
||||||
|
nav.section = 'theme';
|
||||||
|
mount();
|
||||||
|
const accent = (await screen.findByLabelText('Accent colour (hex)')) as HTMLInputElement;
|
||||||
|
fireEvent.change(accent, { target: { value: 'rot' } });
|
||||||
|
expect(accent.getAttribute('aria-invalid')).toBe('true');
|
||||||
|
expect(screen.getByText('Must be a hex colour such as #e60012.')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
67
app/admin/components/__tests__/zoom-settings.test.tsx
Normal file
67
app/admin/components/__tests__/zoom-settings.test.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
|
||||||
|
import GeneralSection from '../settings/GeneralSection';
|
||||||
|
import ZoomOverrideField from '../fields/ZoomOverrideField';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The admin switches for the lightbox zoom (#467): one site-wide toggle in
|
||||||
|
* Settings › General, and an inherit / on / off choice per page and album.
|
||||||
|
* The YAML they produce is covered by the page builder round-trip tests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe('Settings › General › Photo zoom', () => {
|
||||||
|
const renderWith = (settings: Record<string, unknown>) => {
|
||||||
|
const update = vi.fn();
|
||||||
|
render(<GeneralSection settings={settings} update={update} updateMany={vi.fn()} />);
|
||||||
|
return { update, row: screen.getByRole('button', { name: /Photo zoom/ }) };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('is shown off when settings.yaml says nothing, and switches it on', () => {
|
||||||
|
const { update, row } = renderWith({});
|
||||||
|
expect(row.getAttribute('aria-pressed')).toBe('false');
|
||||||
|
fireEvent.click(row);
|
||||||
|
expect(update).toHaveBeenCalledWith('zoom', true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches it off again', () => {
|
||||||
|
const { update, row } = renderWith({ zoom: true });
|
||||||
|
expect(row.getAttribute('aria-pressed')).toBe('true');
|
||||||
|
fireEvent.click(row);
|
||||||
|
expect(update).toHaveBeenCalledWith('zoom', false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ZoomOverrideField', () => {
|
||||||
|
const renderField = (value: boolean | undefined) => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<ZoomOverrideField
|
||||||
|
id="z"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
inheritLabel="Inherit"
|
||||||
|
hint="hint"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
return { onChange, select: screen.getByLabelText('Photo zoom') as HTMLSelectElement };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('shows the three states', () => {
|
||||||
|
expect(renderField(undefined).select.value).toBe('');
|
||||||
|
cleanup();
|
||||||
|
expect(renderField(true).select.value).toBe('on');
|
||||||
|
cleanup();
|
||||||
|
expect(renderField(false).select.value).toBe('off');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports true, false, and undefined for inherit', () => {
|
||||||
|
const { onChange, select } = renderField(undefined);
|
||||||
|
fireEvent.change(select, { target: { value: 'on' } });
|
||||||
|
fireEvent.change(select, { target: { value: 'off' } });
|
||||||
|
fireEvent.change(select, { target: { value: '' } });
|
||||||
|
expect(onChange.mock.calls).toEqual([[true], [false], [undefined]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
24
app/admin/components/analyticsDays.ts
Normal file
24
app/admin/components/analyticsDays.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* The days the analytics chart shows: the last `count` calendar days up to
|
||||||
|
* `today`, each one present, with 0 for a day nobody visited.
|
||||||
|
*
|
||||||
|
* The chart used to take the last 14 *recorded* days, so a quiet week simply
|
||||||
|
* vanished and "Last 14 Days" could span a month (QA A-16).
|
||||||
|
*
|
||||||
|
* Keys are UTC dates (`YYYY-MM-DD`), the way /api/analytics/track writes them,
|
||||||
|
* so `today` is read in UTC too — a local date would be a day off either side
|
||||||
|
* of midnight.
|
||||||
|
*/
|
||||||
|
export function lastDays(
|
||||||
|
days: Record<string, { pageviews?: number } | undefined>,
|
||||||
|
count: number,
|
||||||
|
today: Date,
|
||||||
|
): Array<{ date: string; pageviews: number }> {
|
||||||
|
const end = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate());
|
||||||
|
const out: Array<{ date: string; pageviews: number }> = [];
|
||||||
|
for (let i = count - 1; i >= 0; i--) {
|
||||||
|
const date = new Date(end - i * 86_400_000).toISOString().slice(0, 10);
|
||||||
|
out.push({ date, pageviews: days[date]?.pageviews ?? 0 });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
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);
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
28
app/admin/components/dndAnnouncements.ts
Normal file
28
app/admin/components/dndAnnouncements.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { Announcements, UniqueIdentifier } from '@dnd-kit/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Screen reader announcements for a sortable list, in words (QA A-20).
|
||||||
|
*
|
||||||
|
* dnd-kit's defaults read out the sortable ids — "Picked up draggable item
|
||||||
|
* subpage-3. Draggable item page-pricing was moved over droppable area
|
||||||
|
* subpage-1." Those ids are internal. `label` turns one into what the list
|
||||||
|
* shows, e.g. `page "Pricing"`.
|
||||||
|
*/
|
||||||
|
export function dndAnnouncements(label: (id: UniqueIdentifier) => string): Announcements {
|
||||||
|
return {
|
||||||
|
onDragStart: ({ active }) => `Picked up ${label(active.id)}.`,
|
||||||
|
onDragOver: ({ active, over }) =>
|
||||||
|
over
|
||||||
|
? `${capitalise(label(active.id))} is at the position of ${label(over.id)}.`
|
||||||
|
: `${capitalise(label(active.id))} is no longer over the list.`,
|
||||||
|
onDragEnd: ({ active, over }) =>
|
||||||
|
over
|
||||||
|
? `${capitalise(label(active.id))} was dropped at the position of ${label(over.id)}.`
|
||||||
|
: `${capitalise(label(active.id))} was dropped.`,
|
||||||
|
onDragCancel: ({ active }) => `Moving ${label(active.id)} was cancelled.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function capitalise(text: string): string {
|
||||||
|
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||||
|
}
|
||||||
20
app/admin/components/fields/EnvLockNote.tsx
Normal file
20
app/admin/components/fields/EnvLockNote.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { IconLock } from '../Icons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The note under a field an environment variable overrides (#605). The field
|
||||||
|
* itself is rendered disabled by its section; this names the variable, so the
|
||||||
|
* operator knows where the value actually lives. Never shows the value.
|
||||||
|
*/
|
||||||
|
export default function EnvLockNote({ id, variable }: { id?: string; variable: string }) {
|
||||||
|
return (
|
||||||
|
<p id={id} className="admin-env-lock">
|
||||||
|
<span className="admin-env-lock-pill">
|
||||||
|
<IconLock size={11} /> Locked
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Set by <code>{variable}</code> in the environment. Change or unset it there; edits here
|
||||||
|
would have no effect.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
app/admin/components/fields/ZoomOverrideField.tsx
Normal file
46
app/admin/components/fields/ZoomOverrideField.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightbox zoom for one page or album (#467): inherit, on, or off.
|
||||||
|
*
|
||||||
|
* Three states rather than a switch, because `zoom: false` is a setting of its
|
||||||
|
* own — it switches zoom off here while the site (or the page) has it on —
|
||||||
|
* and a switch can only say true or absent. A select, like the layout
|
||||||
|
* override beside it, with inheriting as the empty value, so the YAML keeps no
|
||||||
|
* key until someone picks one.
|
||||||
|
*/
|
||||||
|
export default function ZoomOverrideField({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
inheritLabel,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
value: boolean | undefined;
|
||||||
|
onChange: (value: boolean | undefined) => void;
|
||||||
|
inheritLabel: string;
|
||||||
|
hint: ReactNode;
|
||||||
|
}) {
|
||||||
|
const current = value === true ? 'on' : value === false ? 'off' : '';
|
||||||
|
return (
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor={id}>Photo zoom</label>
|
||||||
|
<select
|
||||||
|
id={id}
|
||||||
|
value={current}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.value;
|
||||||
|
onChange(next === 'on' ? true : next === 'off' ? false : undefined);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{inheritLabel}</option>
|
||||||
|
<option value="on">On — visitors can zoom to full resolution</option>
|
||||||
|
<option value="off">Off</option>
|
||||||
|
</select>
|
||||||
|
<span className="admin-field-hint">{hint}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,12 +13,17 @@ import {
|
|||||||
IconFolder,
|
IconFolder,
|
||||||
IconX,
|
IconX,
|
||||||
} from '../Icons';
|
} from '../Icons';
|
||||||
import { isLegacyAssetRef } from './blockOps';
|
import { InlineMarkdownField } from '../InlineMarkdownField';
|
||||||
|
import { fillAssetSlots, isLegacyAssetRef } from './blockOps';
|
||||||
|
|
||||||
/** What the asset picker is opened for: its title, and where the pick goes. */
|
/** What the asset picker is opened for: its title, and where the pick goes. */
|
||||||
export interface AssetPickTarget {
|
export interface AssetPickTarget {
|
||||||
title: string;
|
title: string;
|
||||||
onSelect: (assetId: string) => void;
|
onSelect?: (assetId: string) => void;
|
||||||
|
/** Multi-select: several picks confirmed at once (#602). */
|
||||||
|
onSelectMany?: (assetIds: string[]) => void;
|
||||||
|
/** Most assets `onSelectMany` takes. */
|
||||||
|
max?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BlockFieldsProps {
|
interface BlockFieldsProps {
|
||||||
@@ -71,22 +76,26 @@ export function BlockFields({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{block.type === 'paragraph' && (
|
{block.type === 'paragraph' && (
|
||||||
<textarea
|
<InlineMarkdownField
|
||||||
|
multiline
|
||||||
|
aria-label="Text"
|
||||||
className="admin-input"
|
className="admin-input"
|
||||||
rows={3}
|
rows={3}
|
||||||
value={block.html}
|
placeholder="Text (**bold**, *italic*, [link](https://…))"
|
||||||
onChange={(e) => onChange({ ...block, html: e.target.value })}
|
html={block.html}
|
||||||
|
onChange={(html) => onChange({ ...block, html })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{block.type === 'quote' && (
|
{block.type === 'quote' && (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
<textarea
|
<InlineMarkdownField
|
||||||
|
multiline
|
||||||
className="admin-input"
|
className="admin-input"
|
||||||
rows={2}
|
rows={2}
|
||||||
value={block.text}
|
html={block.text}
|
||||||
placeholder="Quote text..."
|
placeholder="Quote text..."
|
||||||
onChange={(e) => onChange({ ...block, text: e.target.value })}
|
onChange={(text) => onChange({ ...block, text })}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -107,12 +116,19 @@ export function BlockFields({
|
|||||||
|
|
||||||
{block.type === 'photo' && (
|
{block.type === 'photo' && (
|
||||||
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
||||||
<div
|
{/* A button, not a clickable div, so the keyboard reaches it. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={block.assetId ? 'Change photo' : 'Pick a photo'}
|
||||||
style={{
|
style={{
|
||||||
width: '140px',
|
width: '140px',
|
||||||
height: '96px',
|
height: '96px',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
background: 'rgba(0,0,0,0.3)',
|
background: 'rgba(0,0,0,0.3)',
|
||||||
|
border: 'none',
|
||||||
|
padding: 0,
|
||||||
|
color: 'inherit',
|
||||||
|
font: 'inherit',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -131,13 +147,13 @@ export function BlockFields({
|
|||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
src={`/api/admin/thumbnail/${block.assetId}`}
|
src={`/api/admin/thumbnail/${block.assetId}`}
|
||||||
alt="Thumb"
|
alt=""
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ fontSize: '0.8rem', opacity: 0.6 }}>+ Pick</span>
|
<span style={{ fontSize: '0.8rem', opacity: 0.6 }}>+ Pick</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -176,12 +192,11 @@ export function BlockFields({
|
|||||||
Change Photo
|
Change Photo
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<InlineMarkdownField
|
||||||
type="text"
|
|
||||||
className="admin-input"
|
className="admin-input"
|
||||||
placeholder="Caption (optional)"
|
placeholder="Caption (optional)"
|
||||||
value={block.caption || ''}
|
html={block.caption}
|
||||||
onChange={(e) => onChange({ ...block, caption: e.target.value })}
|
onChange={(caption) => onChange({ ...block, caption })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -205,10 +220,16 @@ export function BlockFields({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{[0, 1].map((pIdx) => (
|
{[0, 1].map((pIdx) => (
|
||||||
<div
|
<button
|
||||||
|
type="button"
|
||||||
key={pIdx}
|
key={pIdx}
|
||||||
|
aria-label={`${block.assetIds[pIdx] ? 'Change' : 'Pick'} photo ${pIdx + 1} of the pair`}
|
||||||
style={{
|
style={{
|
||||||
height: '90px',
|
height: '90px',
|
||||||
|
width: '100%',
|
||||||
|
padding: 0,
|
||||||
|
color: 'inherit',
|
||||||
|
font: 'inherit',
|
||||||
background: 'rgba(0,0,0,0.3)',
|
background: 'rgba(0,0,0,0.3)',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@@ -233,21 +254,40 @@ export function BlockFields({
|
|||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
src={`/api/admin/thumbnail/${block.assetIds[pIdx]}`}
|
src={`/api/admin/thumbnail/${block.assetIds[pIdx]}`}
|
||||||
alt="Thumb"
|
alt=""
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ fontSize: '0.8rem', opacity: 0.6 }}>+ Pick Photo #{pIdx + 1}</span>
|
<span style={{ fontSize: '0.8rem', opacity: 0.6 }}>+ Pick Photo #{pIdx + 1}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<div>
|
||||||
type="text"
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-btn admin-btn-xs"
|
||||||
|
onClick={() =>
|
||||||
|
onPickAsset({
|
||||||
|
title: 'Select Photos for Pair',
|
||||||
|
max: 2,
|
||||||
|
onSelectMany: (ids) => {
|
||||||
|
// Two picks replace the pair; a single pick fills a gap.
|
||||||
|
const base = ids.length >= 2 ? ['', ''] : [...block.assetIds];
|
||||||
|
const [a, b] = fillAssetSlots(base, ids, 2);
|
||||||
|
onChange({ ...block, assetIds: [a ?? '', b ?? ''] });
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconCamera size={12} /> Pick both photos
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<InlineMarkdownField
|
||||||
className="admin-input"
|
className="admin-input"
|
||||||
placeholder="Shared caption for pair (optional)"
|
placeholder="Shared caption for pair (optional)"
|
||||||
value={block.caption || ''}
|
html={block.caption}
|
||||||
onChange={(e) => onChange({ ...block, caption: e.target.value })}
|
onChange={(caption) => onChange({ ...block, caption })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -316,16 +356,28 @@ export function BlockFields({
|
|||||||
>
|
>
|
||||||
<IconPlus size={12} /> Add photo
|
<IconPlus size={12} /> Add photo
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-btn admin-btn-xs"
|
||||||
|
onClick={() =>
|
||||||
|
onPickAsset({
|
||||||
|
title: 'Select Photos for Grid',
|
||||||
|
onSelectMany: (ids) =>
|
||||||
|
onChange({ ...block, assetIds: fillAssetSlots(block.assetIds, ids) }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconCamera size={12} /> Pick several
|
||||||
|
</button>
|
||||||
<span style={{ fontSize: '0.75rem', opacity: 0.6 }}>
|
<span style={{ fontSize: '0.75rem', opacity: 0.6 }}>
|
||||||
Three or more photos, laid out in rows of three.
|
Three or more photos, laid out in rows of three.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<InlineMarkdownField
|
||||||
type="text"
|
|
||||||
className="admin-input"
|
className="admin-input"
|
||||||
placeholder="Shared caption for grid (optional)"
|
placeholder="Shared caption for grid (optional)"
|
||||||
value={block.caption || ''}
|
html={block.caption}
|
||||||
onChange={(e) => onChange({ ...block, caption: e.target.value })}
|
onChange={(caption) => onChange({ ...block, caption })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -348,17 +400,14 @@ export function BlockFields({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<input
|
<InlineMarkdownField
|
||||||
type="text"
|
|
||||||
className="admin-input"
|
className="admin-input"
|
||||||
placeholder="Value, e.g. 21 km"
|
placeholder="Value, e.g. 21 km"
|
||||||
value={item.value}
|
html={item.value}
|
||||||
onChange={(e) =>
|
onChange={(value) =>
|
||||||
onChange({
|
onChange({
|
||||||
...block,
|
...block,
|
||||||
items: block.items.map((it, i) =>
|
items: block.items.map((it, i) => (i === fIdx ? { ...it, value } : it)),
|
||||||
i === fIdx ? { ...it, value: e.target.value } : it,
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -467,17 +516,11 @@ export function BlockFields({
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<InlineMarkdownField
|
||||||
type="text"
|
|
||||||
className="admin-input"
|
className="admin-input"
|
||||||
placeholder="Caption for the set (optional)"
|
placeholder="Caption for the set (optional)"
|
||||||
value={block.caption || ''}
|
html={block.caption}
|
||||||
onChange={(e) =>
|
onChange={(caption) => onChange({ ...block, caption: caption || undefined })}
|
||||||
onChange({
|
|
||||||
...block,
|
|
||||||
caption: e.target.value || undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<span style={{ fontSize: '0.75rem', opacity: 0.6 }}>
|
<span style={{ fontSize: '0.75rem', opacity: 0.6 }}>
|
||||||
Photos follow the album's order (a manual order in the gallery comes first). Count
|
Photos follow the album's order (a manual order in the gallery comes first). Count
|
||||||
@@ -494,12 +537,11 @@ export function BlockFields({
|
|||||||
enabled.
|
enabled.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<input
|
<InlineMarkdownField
|
||||||
type="text"
|
|
||||||
className="admin-input"
|
className="admin-input"
|
||||||
placeholder="Caption, e.g. Busan → Seoul (optional)"
|
placeholder="Caption, e.g. Busan → Seoul (optional)"
|
||||||
value={block.caption || ''}
|
html={block.caption}
|
||||||
onChange={(e) => onChange({ ...block, caption: e.target.value })}
|
onChange={(caption) => onChange({ ...block, caption })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{block.items.map((item, mIdx) => {
|
{block.items.map((item, mIdx) => {
|
||||||
|
|||||||
@@ -28,22 +28,37 @@ import type { AlbumAssetRef } from '@/lib/journalAlbum';
|
|||||||
import { BlockBadge } from '../BlockBadge';
|
import { BlockBadge } from '../BlockBadge';
|
||||||
import { useUnsavedGuard } from '../useUnsavedGuard';
|
import { useUnsavedGuard } from '../useUnsavedGuard';
|
||||||
import { useDraft } from '../useDraft';
|
import { useDraft } from '../useDraft';
|
||||||
|
import { useLatest } from '../useLatest';
|
||||||
import DraftNotice from '../DraftNotice';
|
import DraftNotice from '../DraftNotice';
|
||||||
import { reportIfSessionExpired } from '../sessionExpiry';
|
import { reportIfSessionExpired } from '../sessionExpiry';
|
||||||
|
import { useContentRestored } from '../contentRestored';
|
||||||
|
import { useVersionedSave } from '../useVersionedSave';
|
||||||
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 { PageSettingsPanel } from './PageSettingsPanel';
|
||||||
import { JournalPreview } from './JournalPreview';
|
import { JournalPreview } from './JournalPreview';
|
||||||
import { createBlock, moveBlock } from './blockOps';
|
import { createBlock, createPhotoBlocks, 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;
|
||||||
mapEnabled?: boolean;
|
mapEnabled?: boolean;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
|
/**
|
||||||
|
* `page` edits a content page (#722) instead of a journal entry: its own
|
||||||
|
* API and live URL, no map block, a settings side panel instead of the
|
||||||
|
* story settings, and a Publish button that saves the Draft flag at once.
|
||||||
|
*/
|
||||||
|
kind?: 'journal' | 'page';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps) {
|
export function JournalEditor({ slug, mapEnabled, onBack, kind = 'journal' }: JournalEditorProps) {
|
||||||
|
const isPage = kind === 'page';
|
||||||
|
const apiUrl = isPage ? `/api/admin/pages/${slug}` : `/api/admin/journal/${slug}`;
|
||||||
|
const liveUrl = isPage ? `/${slug}` : `/journal/${slug}`;
|
||||||
|
const notify = useNotify();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
@@ -60,6 +75,8 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
|||||||
} = useSplitPane();
|
} = useSplitPane();
|
||||||
|
|
||||||
const [rawMarkdown, setRawMarkdown] = useState('');
|
const [rawMarkdown, setRawMarkdown] = useState('');
|
||||||
|
/** The markdown as last rendered: tells a finished save whether typing went on meanwhile. */
|
||||||
|
const latestMarkdown = useLatest(rawMarkdown);
|
||||||
const [parsed, setParsed] = useState<ParsedJournal>(() => ({
|
const [parsed, setParsed] = useState<ParsedJournal>(() => ({
|
||||||
frontmatter: {},
|
frontmatter: {},
|
||||||
blocks: [],
|
blocks: [],
|
||||||
@@ -78,9 +95,18 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
|||||||
// Unsaved edits survive leaving the editor — the entry list, a tab link,
|
// Unsaved edits survive leaving the editor — the entry list, a tab link,
|
||||||
// back, Reload (#592). The markdown is the whole entry; blocks and
|
// back, Reload (#592). The markdown is the whole entry; blocks and
|
||||||
// frontmatter are parsed from it. `serverMarkdown` is what a discard restores.
|
// frontmatter are parsed from it. `serverMarkdown` is what a discard restores.
|
||||||
const draft = useDraft<string>(`journal-${slug}`, rawMarkdown, dirty);
|
const draft = useDraft<string>(`${isPage ? 'page' : 'journal'}-${slug}`, rawMarkdown, dirty);
|
||||||
const loadDraft = draft.load;
|
const loadDraft = draft.load;
|
||||||
const serverMarkdown = useRef('');
|
const serverMarkdown = useRef('');
|
||||||
|
/** The file as loaded, sent back on save so a change elsewhere is caught (#601). */
|
||||||
|
const versionRef = useRef<string | null>(null);
|
||||||
|
const versionedSave = useVersionedSave();
|
||||||
|
|
||||||
|
// Bumped when this entry is restored from a backup, to load it again.
|
||||||
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
|
useContentRestored(({ target, slug: restored }) => {
|
||||||
|
if (target === (isPage ? 'pages' : 'journal') && restored === slug) setReloadKey((k) => k + 1);
|
||||||
|
});
|
||||||
|
|
||||||
// Load entry
|
// Load entry
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -88,7 +114,7 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setLoadError(null);
|
setLoadError(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/journal/${slug}`);
|
const res = await fetch(apiUrl);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
res.status === 401
|
res.status === 401
|
||||||
@@ -97,7 +123,8 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const md: string = data.entry.rawMarkdown;
|
versionRef.current = typeof data.version === 'string' ? data.version : null;
|
||||||
|
const md: string = (isPage ? data.page : data.entry).rawMarkdown;
|
||||||
serverMarkdown.current = md;
|
serverMarkdown.current = md;
|
||||||
const restored = loadDraft(md);
|
const restored = loadDraft(md);
|
||||||
setRawMarkdown(restored ?? md);
|
setRawMarkdown(restored ?? md);
|
||||||
@@ -113,7 +140,7 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
load();
|
load();
|
||||||
}, [slug, loadDraft]);
|
}, [slug, apiUrl, isPage, loadDraft, reloadKey]);
|
||||||
|
|
||||||
// Update markdown and sync blocks
|
// Update markdown and sync blocks
|
||||||
const handleMarkdownChange = (newMd: string) => {
|
const handleMarkdownChange = (newMd: string) => {
|
||||||
@@ -150,31 +177,61 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
|||||||
setDirty(true);
|
setDirty(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save
|
// Save. `override` is markdown to save instead of the editor's — the page
|
||||||
const handleSave = async () => {
|
// editor's Publish button changes the Draft flag and saves in one go.
|
||||||
|
const handleSave = async (override?: string) => {
|
||||||
// The editor is not rendered in this state, but Cmd+S still reaches here.
|
// 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 && override === undefined) || saving) return;
|
||||||
|
const toSave = override ?? rawMarkdown;
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/journal/${slug}`, {
|
const result = await versionedSave(
|
||||||
method: 'PUT',
|
apiUrl,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
{ rawMarkdown: toSave },
|
||||||
body: JSON.stringify({
|
versionRef,
|
||||||
rawMarkdown,
|
isPage ? 'This page' : 'This entry',
|
||||||
}),
|
);
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
if (result.kind === 'reload') {
|
||||||
serverMarkdown.current = rawMarkdown;
|
draft.discard();
|
||||||
draft.saved(rawMarkdown);
|
setReloadKey((k) => k + 1);
|
||||||
setDirty(false);
|
} else if (result.kind === 'keep') {
|
||||||
} else if (!reportIfSessionExpired(res)) {
|
notify('error', 'Not saved — it changed elsewhere. Your edits are still here.');
|
||||||
const data = await res.json();
|
} else if (result.kind === 'saved') {
|
||||||
alert(data.error || 'Failed to save');
|
// The file as written, not as sent: a password line is hashed on the
|
||||||
|
// way to disk (#690). Taking the sent text as the base would make the
|
||||||
|
// next draft look like a conflicting edit from elsewhere.
|
||||||
|
const data = result.data as {
|
||||||
|
page?: { rawMarkdown?: unknown };
|
||||||
|
entry?: { rawMarkdown?: unknown };
|
||||||
|
};
|
||||||
|
const record = isPage ? data.page : data.entry;
|
||||||
|
const written: string =
|
||||||
|
typeof record?.rawMarkdown === 'string' ? record.rawMarkdown : toSave;
|
||||||
|
serverMarkdown.current = written;
|
||||||
|
// Edited while the request was out: those edits are not saved yet, so
|
||||||
|
// they stay dirty and are not replaced by the file as written.
|
||||||
|
const editedMeanwhile = latestMarkdown.current !== toSave;
|
||||||
|
draft.saved(written, editedMeanwhile);
|
||||||
|
if (!editedMeanwhile) {
|
||||||
|
if (written !== toSave) {
|
||||||
|
setRawMarkdown(written);
|
||||||
|
setParsed(parseJournalMarkdown(written));
|
||||||
|
}
|
||||||
|
setDirty(false);
|
||||||
|
}
|
||||||
|
} else if (!reportIfSessionExpired(result.res)) {
|
||||||
|
notify('error', result.data?.error || 'Failed to save');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
alert('Error saving entry');
|
notify(
|
||||||
|
'error',
|
||||||
|
`Could not save the ${isPage ? 'page' : 'entry'}. Check the connection and try again.`,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -256,6 +313,22 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Block manipulation
|
// Block manipulation
|
||||||
|
/** Page mode: publish or unpublish, saved at once. Same flag as the Draft pill. */
|
||||||
|
const handleTogglePublish = () => {
|
||||||
|
const updated: ParsedJournal = {
|
||||||
|
...parsed,
|
||||||
|
frontmatter: { ...parsed.frontmatter, draft: !parsed.frontmatter.draft },
|
||||||
|
};
|
||||||
|
const serialized = serializeJournalMarkdown(updated);
|
||||||
|
setParsed(updated);
|
||||||
|
setRawMarkdown(serialized);
|
||||||
|
// An edit like any other until the save lands: a failed or refused save
|
||||||
|
// must leave the flipped flag unsaved, not shown as "Saved" (a success
|
||||||
|
// clears it again).
|
||||||
|
setDirty(true);
|
||||||
|
handleSave(serialized);
|
||||||
|
};
|
||||||
|
|
||||||
const handleAddBlock = (type: JournalBlock['type']) => {
|
const handleAddBlock = (type: JournalBlock['type']) => {
|
||||||
handleBlocksChange([...parsed.blocks, createBlock(type)]);
|
handleBlocksChange([...parsed.blocks, createBlock(type)]);
|
||||||
};
|
};
|
||||||
@@ -282,7 +355,7 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '4rem', textAlign: 'center', opacity: 0.6 }}>
|
<div style={{ padding: '4rem', textAlign: 'center', opacity: 0.6 }}>
|
||||||
Opening Journal Studio...
|
{isPage ? 'Opening page editor...' : 'Opening Journal Studio...'}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -294,13 +367,13 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
|||||||
if (loadError) {
|
if (loadError) {
|
||||||
return (
|
return (
|
||||||
<div className="admin-error" role="alert">
|
<div className="admin-error" role="alert">
|
||||||
<strong>This entry could not be loaded.</strong> {loadError}
|
<strong>This {isPage ? 'page' : 'entry'} could not be loaded.</strong> {loadError}
|
||||||
<p>
|
<p>
|
||||||
Nothing has been changed. Saving stays disabled until it loads, so an empty editor cannot
|
Nothing has been changed. Saving stays disabled until it loads, so an empty editor cannot
|
||||||
overwrite the entry.
|
overwrite it.
|
||||||
</p>
|
</p>
|
||||||
<button className="admin-btn admin-btn-secondary" onClick={onBack}>
|
<button className="admin-btn admin-btn-secondary" onClick={onBack}>
|
||||||
Back to entries
|
{isPage ? 'Back to pages' : 'Back to entries'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -316,36 +389,38 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
|||||||
className="admin-btn admin-btn-sm admin-btn-secondary"
|
className="admin-btn admin-btn-sm admin-btn-secondary"
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
>
|
>
|
||||||
← All Entries
|
{isPage ? '← Pages' : '← All Entries'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="journal-editor-title-input"
|
className="journal-editor-title-input"
|
||||||
value={parsed.frontmatter.title || ''}
|
value={parsed.frontmatter.title || ''}
|
||||||
placeholder="Story Title..."
|
placeholder={isPage ? 'Page title...' : 'Story Title...'}
|
||||||
|
aria-label={isPage ? 'Page title' : 'Story title'}
|
||||||
onChange={(e) => handleFrontmatterChange({ title: e.target.value })}
|
onChange={(e) => handleFrontmatterChange({ title: e.target.value })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* The admin's segmented control (QA A-18). As `admin-btn-xs` buttons the
|
||||||
|
active one never showed: `.admin-btn-xs` sets its own background
|
||||||
|
after `.admin-btn-primary`, so both modes looked identical. */}
|
||||||
<div
|
<div
|
||||||
style={{
|
className="segmented-control segmented-control--sm"
|
||||||
display: 'flex',
|
role="group"
|
||||||
gap: '4px',
|
aria-label="Editor mode"
|
||||||
background: 'rgba(255,255,255,0.06)',
|
|
||||||
borderRadius: '6px',
|
|
||||||
padding: '2px',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`admin-btn admin-btn-xs ${editorMode === 'blocks' ? 'admin-btn-primary' : ''}`}
|
className={`segment-btn ${editorMode === 'blocks' ? 'active' : ''}`}
|
||||||
|
aria-pressed={editorMode === 'blocks'}
|
||||||
onClick={() => setEditorMode('blocks')}
|
onClick={() => setEditorMode('blocks')}
|
||||||
>
|
>
|
||||||
Visual Blocks
|
Visual Blocks
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`admin-btn admin-btn-xs ${editorMode === 'markdown' ? 'admin-btn-primary' : ''}`}
|
className={`segment-btn ${editorMode === 'markdown' ? 'active' : ''}`}
|
||||||
|
aria-pressed={editorMode === 'markdown'}
|
||||||
onClick={() => setEditorMode('markdown')}
|
onClick={() => setEditorMode('markdown')}
|
||||||
>
|
>
|
||||||
Raw Markdown
|
Raw Markdown
|
||||||
@@ -359,11 +434,27 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
|||||||
className="admin-btn admin-btn-sm admin-btn-secondary"
|
className="admin-btn admin-btn-sm admin-btn-secondary"
|
||||||
onClick={() => setShowMetaModal(true)}
|
onClick={() => setShowMetaModal(true)}
|
||||||
>
|
>
|
||||||
<IconGear size={14} /> Story Settings
|
<IconGear size={14} /> {isPage ? 'Page Settings' : 'Story Settings'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{isPage && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-btn admin-btn-sm admin-btn-secondary"
|
||||||
|
onClick={handleTogglePublish}
|
||||||
|
disabled={saving}
|
||||||
|
title={
|
||||||
|
parsed.frontmatter.draft
|
||||||
|
? 'Publish the page and save it now'
|
||||||
|
: 'Turn the page back into a draft and save it now'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{parsed.frontmatter.draft ? 'Publish' : 'Unpublish'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href={`/journal/${slug}`}
|
href={liveUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="admin-btn admin-btn-sm admin-btn-secondary"
|
className="admin-btn admin-btn-sm admin-btn-secondary"
|
||||||
@@ -374,8 +465,8 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
|||||||
<button
|
<button
|
||||||
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...'
|
||||||
@@ -392,7 +483,7 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
|||||||
|
|
||||||
<DraftNotice
|
<DraftNotice
|
||||||
status={draft.status}
|
status={draft.status}
|
||||||
subject="entry"
|
subject={isPage ? 'page' : 'entry'}
|
||||||
onDiscard={discardDraft}
|
onDiscard={discardDraft}
|
||||||
onRestore={restoreConflictingDraft}
|
onRestore={restoreConflictingDraft}
|
||||||
onDismiss={draft.dismiss}
|
onDismiss={draft.dismiss}
|
||||||
@@ -454,6 +545,19 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
|||||||
>
|
>
|
||||||
<IconCamera size={13} /> + Photo
|
<IconCamera size={13} /> + Photo
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-btn admin-btn-xs admin-btn-primary"
|
||||||
|
onClick={() =>
|
||||||
|
setAssetPickerTarget({
|
||||||
|
title: 'Add Photos (one block each)',
|
||||||
|
onSelectMany: (ids) =>
|
||||||
|
handleBlocksChange([...parsed.blocks, ...createPhotoBlocks(ids)]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconCamera size={13} /> + Photos
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="admin-btn admin-btn-xs admin-btn-primary"
|
className="admin-btn admin-btn-xs admin-btn-primary"
|
||||||
@@ -475,13 +579,16 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
|||||||
>
|
>
|
||||||
<IconFolder size={13} /> + Album
|
<IconFolder size={13} /> + Album
|
||||||
</button>
|
</button>
|
||||||
<button
|
{/* No map block on content pages in v1 (#722). */}
|
||||||
type="button"
|
{!isPage && (
|
||||||
className="admin-btn admin-btn-xs"
|
<button
|
||||||
onClick={() => handleAddBlock('map')}
|
type="button"
|
||||||
>
|
className="admin-btn admin-btn-xs"
|
||||||
<IconMap size={13} /> + Map
|
onClick={() => handleAddBlock('map')}
|
||||||
</button>
|
>
|
||||||
|
<IconMap size={13} /> + Map
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Blocks List */}
|
{/* Blocks List */}
|
||||||
@@ -568,8 +675,17 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
|||||||
<JournalPreview parsed={parsed} albumAssets={albumAssets} />
|
<JournalPreview parsed={parsed} albumAssets={albumAssets} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Page settings: a side panel, so editing never means going back (#722). */}
|
||||||
|
{showMetaModal && isPage && (
|
||||||
|
<PageSettingsPanel
|
||||||
|
frontmatter={parsed.frontmatter}
|
||||||
|
onChange={handleFrontmatterChange}
|
||||||
|
onClose={() => setShowMetaModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Metadata Modal */}
|
{/* Metadata Modal */}
|
||||||
{showMetaModal && (
|
{showMetaModal && !isPage && (
|
||||||
<StorySettingsModal
|
<StorySettingsModal
|
||||||
frontmatter={parsed.frontmatter}
|
frontmatter={parsed.frontmatter}
|
||||||
onChange={handleFrontmatterChange}
|
onChange={handleFrontmatterChange}
|
||||||
@@ -595,10 +711,18 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
|||||||
{assetPickerTarget && (
|
{assetPickerTarget && (
|
||||||
<AssetPicker
|
<AssetPicker
|
||||||
title={assetPickerTarget.title}
|
title={assetPickerTarget.title}
|
||||||
|
max={assetPickerTarget.max}
|
||||||
onSelect={(id) => {
|
onSelect={(id) => {
|
||||||
assetPickerTarget.onSelect(id);
|
assetPickerTarget.onSelect?.(id);
|
||||||
setAssetPickerTarget(null);
|
setAssetPickerTarget(null);
|
||||||
}}
|
}}
|
||||||
|
onSelectMany={
|
||||||
|
assetPickerTarget.onSelectMany &&
|
||||||
|
((ids) => {
|
||||||
|
assetPickerTarget.onSelectMany?.(ids);
|
||||||
|
setAssetPickerTarget(null);
|
||||||
|
})
|
||||||
|
}
|
||||||
onClose={() => setAssetPickerTarget(null)}
|
onClose={() => setAssetPickerTarget(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -92,17 +92,24 @@ export function JournalPreview({ parsed, albumAssets }: JournalPreviewProps) {
|
|||||||
<span className="journal-preview-bar-label">
|
<span className="journal-preview-bar-label">
|
||||||
<IconEye size={13} /> Realtime Theme Preview
|
<IconEye size={13} /> Realtime Theme Preview
|
||||||
</span>
|
</span>
|
||||||
<div style={{ display: 'flex', gap: '4px' }}>
|
{/* Same control and same fix as the editor's mode toggle. */}
|
||||||
|
<div
|
||||||
|
className="segmented-control segmented-control--sm"
|
||||||
|
role="group"
|
||||||
|
aria-label="Preview width"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`admin-btn admin-btn-xs ${viewport === 'desktop' ? 'admin-btn-primary' : ''}`}
|
className={`segment-btn ${viewport === 'desktop' ? 'active' : ''}`}
|
||||||
|
aria-pressed={viewport === 'desktop'}
|
||||||
onClick={() => setViewport('desktop')}
|
onClick={() => setViewport('desktop')}
|
||||||
>
|
>
|
||||||
Desktop
|
Desktop
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`admin-btn admin-btn-xs ${viewport === 'mobile' ? 'admin-btn-primary' : ''}`}
|
className={`segment-btn ${viewport === 'mobile' ? 'active' : ''}`}
|
||||||
|
aria-pressed={viewport === 'mobile'}
|
||||||
onClick={() => setViewport('mobile')}
|
onClick={() => setViewport('mobile')}
|
||||||
>
|
>
|
||||||
Mobile
|
Mobile
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
'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, slugFieldValue } from '@/lib/journal';
|
||||||
import { JOURNAL_TEMPLATES } from '@/lib/journalTemplates';
|
import { JOURNAL_TEMPLATES } from '@/lib/journalTemplates';
|
||||||
import {
|
import {
|
||||||
IconTrash,
|
IconTrash,
|
||||||
@@ -16,6 +16,13 @@ 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';
|
||||||
|
import { useModalDialog } from '@/hooks/useModalDialog';
|
||||||
|
|
||||||
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 +32,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);
|
||||||
@@ -37,28 +51,11 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
|
|||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
/** 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 closeCreateModal = () => {
|
||||||
const fetchEntries = useCallback(async () => {
|
setShowCreateModal(false);
|
||||||
setLoading(true);
|
setSelectedTemplateId(null);
|
||||||
setError(null);
|
};
|
||||||
try {
|
const createCardRef = useModalDialog(closeCreateModal, showCreateModal);
|
||||||
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();
|
||||||
@@ -98,41 +95,43 @@ 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, title?: string) => {
|
||||||
if (
|
// Named by title, as the page delete is; the slug only when there is none.
|
||||||
!confirm(
|
const ok = await confirm({
|
||||||
`Delete "${slug}"?\n\nA copy is kept and can be restored from Backups on the dashboard.`,
|
title: `Delete “${title || slug}”?`,
|
||||||
)
|
message: 'A copy is kept and can be restored under Backups.',
|
||||||
)
|
confirmLabel: 'Delete entry',
|
||||||
return;
|
danger: true,
|
||||||
|
});
|
||||||
|
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 +147,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>
|
||||||
@@ -248,7 +245,7 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="admin-btn admin-btn-sm admin-btn-danger"
|
className="admin-btn admin-btn-sm admin-btn-danger"
|
||||||
onClick={() => handleDelete(entry.slug)}
|
onClick={() => handleDelete(entry.slug, entry.frontmatter.title)}
|
||||||
title="Delete entry"
|
title="Delete entry"
|
||||||
style={{ marginLeft: 'auto' }}
|
style={{ marginLeft: 'auto' }}
|
||||||
>
|
>
|
||||||
@@ -264,11 +261,20 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
|
|||||||
{/* Create Modal */}
|
{/* Create Modal */}
|
||||||
{showCreateModal && (
|
{showCreateModal && (
|
||||||
<div className="journal-modal-overlay">
|
<div className="journal-modal-overlay">
|
||||||
<div className="journal-modal-card">
|
<div
|
||||||
<h3 style={{ margin: '0 0 1rem' }}>Create New Journal Entry</h3>
|
className="journal-modal-card"
|
||||||
|
ref={createCardRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="journal-new-dialog-title"
|
||||||
|
>
|
||||||
|
<h3 id="journal-new-dialog-title" style={{ margin: '0 0 1rem' }}>
|
||||||
|
Create New Journal Entry
|
||||||
|
</h3>
|
||||||
<form onSubmit={handleCreate}>
|
<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 +285,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 +303,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,17 +314,18 @@ 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"
|
||||||
value={newSlug}
|
value={newSlug}
|
||||||
onChange={(e) => setNewSlug(sanitizeSlug(e.target.value))}
|
onChange={(e) => setNewSlug(slugFieldValue(e.target.value))}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
style={{ fontSize: '0.75rem', opacity: 0.6, marginTop: '2px', display: 'block' }}
|
style={{ fontSize: '0.75rem', opacity: 0.6, marginTop: '2px', display: 'block' }}
|
||||||
>
|
>
|
||||||
Will be accessible at /journal/{newSlug || 'slug'}
|
Will be accessible at /journal/{newSlug ? sanitizeSlug(newSlug) : 'slug'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -358,10 +367,7 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="admin-btn admin-btn-secondary"
|
className="admin-btn admin-btn-secondary"
|
||||||
onClick={() => {
|
onClick={closeCreateModal}
|
||||||
setShowCreateModal(false);
|
|
||||||
setSelectedTemplateId(null);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
82
app/admin/components/journal/PageSettingsPanel.tsx
Normal file
82
app/admin/components/journal/PageSettingsPanel.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import type { ParsedJournal } from '@/lib/journal';
|
||||||
|
import PasswordField from '../fields/PasswordField';
|
||||||
|
import { IconX } from '../Icons';
|
||||||
|
|
||||||
|
type Frontmatter = ParsedJournal['frontmatter'];
|
||||||
|
|
||||||
|
interface PageSettingsPanelProps {
|
||||||
|
frontmatter: Frontmatter;
|
||||||
|
onChange: (updates: Partial<Frontmatter>) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A content page's settings inside the editor (#722): a side panel rather than
|
||||||
|
* the journal's modal, so the preview stays in view. The slug and the menu
|
||||||
|
* place are edited in Pages, where the collision check and the menu live.
|
||||||
|
*/
|
||||||
|
export function PageSettingsPanel({ frontmatter, onChange, onClose }: PageSettingsPanelProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="page-settings-panel" aria-label="Page settings">
|
||||||
|
<div className="page-settings-panel-head">
|
||||||
|
<h3>Page settings</h3>
|
||||||
|
<button type="button" className="admin-btn-icon" onClick={onClose} title="Close">
|
||||||
|
<IconX size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="page-settings-description">SEO description</label>
|
||||||
|
<textarea
|
||||||
|
id="page-settings-description"
|
||||||
|
rows={3}
|
||||||
|
value={frontmatter.description || ''}
|
||||||
|
placeholder="One or two sentences for search engines and link previews"
|
||||||
|
// One line in the frontmatter: a line break would end the value.
|
||||||
|
onChange={(e) => onChange({ description: e.target.value.replace(/\s*\n\s*/g, ' ') })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="setting-row"
|
||||||
|
onClick={() => onChange({ draft: !frontmatter.draft })}
|
||||||
|
aria-pressed={!!frontmatter.draft}
|
||||||
|
>
|
||||||
|
<span className="setting-row-info">
|
||||||
|
<span className="setting-row-title">Draft</span>
|
||||||
|
<span className="setting-row-desc">
|
||||||
|
Only you can see a draft, and it stays out of the menu and the sitemap.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className={`switch-toggle switch-toggle--sm ${frontmatter.draft ? 'on' : ''}`}>
|
||||||
|
<span className="switch-slider" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="admin-field">
|
||||||
|
<span className="admin-field-label">Password (optional)</span>
|
||||||
|
<PasswordField
|
||||||
|
value={frontmatter.password || undefined}
|
||||||
|
onChange={(password) => onChange({ password: password ?? '' })}
|
||||||
|
label="Page password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="page-settings-panel-note">
|
||||||
|
Settings are saved with the page. The URL and the menu place are set in Pages.
|
||||||
|
</p>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user