diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c71d599..215d248 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,10 @@ jobs: steps: - uses: actions/checkout@v7 + with: + # The formatting step diffs against the PR's base, which needs the + # base branch's history — the default shallow clone has none of it. + fetch-depth: 0 - name: Set up Node.js uses: actions/setup-node@v7 @@ -45,3 +49,42 @@ jobs: IMMICH_API_URL: http://localhost:2283 IMMICH_API_KEY: placeholder-key-for-ci AUTH_SECRET: placeholder-secret-for-ci-builds-must-be-32-chars-long + + # Advisory only — this step never fails the job. + # + # Prettier is configured as an eslint error but has never been run in CI, + # so a chunk of the tree predates it. Blocking on that would make a PR red + # for merely touching an old file, and reformatting the tree wholesale + # would bury every future diff in unrelated churn. Annotating the files a + # PR already changes puts the information in front of the author without + # putting a gate in the way. Runs last so real failures surface first. + - name: Formatting – changed files (advisory) + if: github.event_name == 'pull_request' + continue-on-error: true + run: | + files=$(git diff --name-only --diff-filter=d \ + "${{ github.event.pull_request.base.sha }}...HEAD" \ + -- '*.ts' '*.tsx' '*.js' '*.mjs' '*.css' '*.json' '*.md' '*.yml' '*.yaml') + if [ -z "$files" ]; then + echo "No formattable files changed." + exit 0 + fi + + # --list-different prints only the offenders and exits non-zero when + # there are any; `|| true` keeps that from ending the step. + unformatted=$(echo "$files" | xargs -d '\n' npx prettier --list-different || true) + + if [ -z "$unformatted" ]; then + echo "All changed files match Prettier's style." + exit 0 + fi + + count=$(echo "$unformatted" | wc -l) + echo "### Prettier: $count changed file(s) not formatted" >> "$GITHUB_STEP_SUMMARY" + echo '' >> "$GITHUB_STEP_SUMMARY" + while IFS= read -r f; do + echo "::warning file=$f::Not formatted. Run: npx prettier --write $f" + echo "- \`$f\`" >> "$GITHUB_STEP_SUMMARY" + done <<< "$unformatted" + echo '' >> "$GITHUB_STEP_SUMMARY" + echo 'Advisory only — this does not block the merge.' >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 2d0c9da..21a74fb 100644 --- a/.gitignore +++ b/.gitignore @@ -68,7 +68,12 @@ content/about.md content/.backups/ content/analytics.json content/*.screenshot-bak +# Credentials written by the /install wizard (never committed) +content/install.json +# One-time setup token, written on first access and removed once install completes +content/.setup-token # ── Internal Dev Docs ──────────────────────────────── docs/dev/ -docs/ideas.md \ No newline at end of file +docs/ideas.md +/design_handoff_*/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d80f22..42e5be2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,44 @@ Releases up to and including v0.9.2 are documented in the ## [Unreleased] +### Added + +- **First-run setup wizard at `/install`** + ([#419](https://github.com/ralksta/immich-folio/pull/419)). A fresh deployment + can now be configured from the browser: connect to Immich, optionally pick + albums, name the site. Credentials are verified against the Immich server + before anything is written, so a typo cannot produce an "installed" site that + loads no photos. The wizard is gated by a one-time token printed to the server + log — a deployment is reachable before it has any configuration, and without + the gate whoever found the URL first could configure it. Credentials land in + `content/install.json` (mode `0600`, admin password stored as an scrypt hash); + environment variables continue to take precedence, so rotation still works by + setting a variable. Configuring everything by hand remains fully supported. +- **Custom favicon upload** in admin Settings › General + ([#422](https://github.com/ralksta/immich-folio/pull/422)). SVG, PNG, ICO or + JPEG, stored in the writable `content/` volume and served through + `/api/favicon` with a policy that stops an uploaded SVG executing script. A + Reset button restores the bundled default. + +### Changed + +- **A gallery with no albums is now a valid, rendered state** + ([#421](https://github.com/ralksta/immich-folio/pull/421)) instead of an error + — the setup wizard can finish without picking one, and albums can be added + later in `/admin`. +- **The Immich URL may end in `/api`** without breaking every request + ([#421](https://github.com/ralksta/immich-folio/pull/421)). The suffix was + appended unconditionally, producing `.../api/api`. + +### Documentation + +- **README documents the setup wizard**, what it writes, and that + `content/install.json` holds credentials — so a backup of `content/` is + understood to include your Immich API key. The Docker examples no longer mount + `content/` read-only: the wizard, the admin panel and the backup rotation all + write into it, and `:ro` silently breaks all three. +- **Contributors are credited in the README.** + ### Security - **Next.js 16.3.0** — picks up the fix for CVE-2025-13465 in Next's vendored @@ -18,6 +56,13 @@ Releases up to and including v0.9.2 are documented in the Nothing user-facing; recorded so the next release notes are complete. +- **CI annotates unformatted files in a PR without blocking it** + ([#424](https://github.com/ralksta/immich-folio/pull/424)). Prettier is + configured as an eslint error but had never run in CI, so part of the tree + predates it. Failing on that would make a PR red for merely touching an old + file; reformatting the tree in one commit would bury every future diff in + churn. The check runs over the files a PR changes and warns. + - **Dependency PRs now target `dev`**, and CI runs on pull requests to `dev` as well ([#411](https://github.com/ralksta/immich-folio/pull/411)). The workflow previously triggered on `main` only, so a PR against `dev` carried no checks diff --git a/README.md b/README.md index e8dfc5f..464470a 100644 --- a/README.md +++ b/README.md @@ -128,22 +128,85 @@ Full history in the [GitHub releases](https://github.com/ralksta/immich-folio/re ## Quick Start ```bash -# Clone and install git clone https://github.com/ralksta/immich-folio.git cd immich-folio npm install +npm run dev +``` -# Configure +Then open `http://localhost:3000/install` and let the setup wizard connect you to +Immich — see [First-Run Setup](#first-run-setup) below. It needs a token from the +server log, which is printed on first access. + +
+Prefer to configure by hand? + +The wizard writes the same files you would write yourself, so the manual route +remains fully supported: + +```bash cp .env.local.example .env.local # Edit .env.local with your Immich server URL and API key cp content/gallery.yaml.example content/gallery.yaml # Edit gallery.yaml with your album UUIDs -# Run npm run dev ``` +
+ +## First-Run Setup + +A deployment with no `gallery.yaml` and no Immich credentials serves a setup +screen. The wizard at **`/install`** fills both in from the browser: it connects +to Immich, lets you pick albums (optional — you can add them later in `/admin`), +and names the site. Nothing is written until your credentials have been verified +against your Immich server, so a typo cannot leave you with an "installed" site +that loads no photos. + +**The wizard is gated by a one-time token** printed to the server log on first +access, because a fresh deployment is reachable before it has any configuration — +and without the gate, whoever finds the URL first could configure it: + +``` +══════════════════════════════════════════════════════ + Immich Folio — First-run setup token +══════════════════════════════════════════════════════ + Token: PTmUKFIdce2DwuqBG71RAExH7tVxceXX +``` + +Read it from your container logs (`docker compose logs immich-folio`) and append +it to the URL: + +``` +http://your-site/install?token=PTmUKFIdce2DwuqBG71RAExH7tVxceXX +``` + +The token is stored in `content/.setup-token` (mode `0600`) so it survives a +restart, and is deleted once setup completes. From then on `/install` redirects +to the gallery and its API routes refuse to run again. + +### What the wizard writes + +| File | Contents | +| ----------------------- | -------------------------------------------------------------------- | +| `content/gallery.yaml` | The albums you picked | +| `content/settings.yaml` | Site title, subtitle, theme — only if it does not exist yet | +| `content/install.json` | Immich URL and API key, a generated site secret, admin password hash | + +> **`content/install.json` holds credentials.** It is written with mode `0600`, +> and it is the reason a backup of your `content/` directory is now a backup of +> your Immich API key as well — treat it accordingly. The admin password is +> stored as an scrypt hash, not as you typed it; the API key and site secret have +> to stay readable to be usable. + +**Environment variables always win.** `IMMICH_API_URL`, `IMMICH_API_KEY`, +`AUTH_SECRET` and `ADMIN_PASSWORD` override anything in `install.json`, so you +can rotate any of them by setting the variable — no need to touch the file. Set +all of them up front and the wizard never appears, which is the usual choice for +an infrastructure-as-code deployment. + ## Configuration ### Environment Variables (`.env.local`) @@ -213,7 +276,7 @@ services: env_file: - .env.local volumes: - - ./content:/app/content:ro + - ./content:/app/content ``` Run with: @@ -239,11 +302,14 @@ docker run -d \ --restart unless-stopped \ -p 7211:7211 \ --env-file .env.local \ - -v ./content:/app/content:ro \ + -v ./content:/app/content \ immich-folio ``` -> **Note:** The `content/` volume mount lets you update `gallery.yaml` and `about.md` without rebuilding the image. +> **Note:** The `content/` volume mount lets you update `gallery.yaml` and +> `about.md` without rebuilding the image. It must be **read-write**: the setup +> wizard, the admin panel and the backup rotation all write into it. A `:ro` +> mount leaves the wizard unable to complete and the admin panel unable to save. ### Health Check @@ -285,6 +351,10 @@ A built-in visual editor at `/admin` lets you manage your gallery without editin ADMIN_PASSWORD=your-secure-admin-password ``` +Or set the password in the [setup wizard](#first-run-setup), which stores it as +an scrypt hash rather than in your environment. An `ADMIN_PASSWORD` variable +takes precedence over the stored one if both are present. + Then navigate to `http://your-site/admin` and log in. The panel is protected by its own password, separate from any album passwords, and writes straight to `gallery.yaml` / `settings.yaml` with automatic backups. @@ -308,6 +378,27 @@ own password, separate from any album passwords, and writes straight to - **TypeScript** - **Vanilla CSS** (no framework) +## Contributors + +Immich Folio is maintained by [@ralksta](https://github.com/ralksta) and made +better by the people below. Thank you — every one of these made the project +easier to live with. + +- **[@lancetm714](https://github.com/lancetm714)** — built the setup wizard that + turns a fresh install into a few clicks in the browser instead of hand-written + config files. Also added custom favicons, so your portfolio gets its own icon + in the browser tab, and made a gallery with no albums yet a perfectly valid + starting point rather than an error. +- **[Jules](https://jules.google.com)** — an automated reviewer that has been + quietly hardening the project in the background: better screen-reader support + in the photo grid, and a series of fixes keeping the public endpoints from + being overwhelmed by traffic. +- **[Dependabot](https://github.com/dependabot)** — keeps every dependency + current, which is most of the reason security fixes land here quickly. + +Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) to get +started. Pull requests target the `dev` branch. + ## License MIT License — free to use and modify for the Immich community. diff --git a/app/[...path]/EssayView.tsx b/app/[...path]/EssayView.tsx index 522cb53..2e97e5b 100644 --- a/app/[...path]/EssayView.tsx +++ b/app/[...path]/EssayView.tsx @@ -16,9 +16,22 @@ interface EssayViewProps { title?: string; subtitle?: string; watermark?: LightboxWatermark; + /** + * Client proofing — favorite hearts, the selection bar and the send-off modal. + * Off by default: proofing is a delivery workflow for album handovers, and a + * published story is not one. Album views opt in. + */ + proofing?: boolean; } -function EssayViewContent({ essay, assets, title, subtitle, watermark }: EssayViewProps) { +function EssayViewContent({ + essay, + assets, + title, + subtitle, + watermark, + proofing: proofingEnabled = false, +}: EssayViewProps) { const proofing = useProofing(); const [lightboxIndex, setLightboxIndex] = useState(null); @@ -54,11 +67,7 @@ function EssayViewContent({ essay, assets, title, subtitle, watermark }: EssayVi case 'paragraph': return ( -
+
); case 'quote': @@ -94,8 +103,8 @@ function EssayViewContent({ essay, assets, title, subtitle, watermark }: EssayVi block.layout === 'fullbleed' ? '100vw' : block.layout === 'wide' - ? '(max-width: 1100px) 100vw, 1100px' - : '(max-width: 768px) 100vw, 680px' + ? '(max-width: 1100px) 100vw, 1100px' + : '(max-width: 768px) 100vw, 680px' } loading={idx < 4 ? 'eager' : 'lazy'} {...(item.blurDataURL @@ -129,7 +138,14 @@ function EssayViewContent({ essay, assets, title, subtitle, watermark }: EssayVi filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.6))', }} > - + @@ -164,7 +180,9 @@ function EssayViewContent({ essay, assets, title, subtitle, watermark }: EssayVi onClick={() => setLightboxIndex(index)} style={{ ...(item.dominantColor ? { backgroundColor: item.dominantColor } : {}), - ...(item.aspectRatio ? { aspectRatio: `${item.aspectRatio}` } : {}), + // Width proportional to the ratio — see .essay-pair-grid. + flexGrow: item.aspectRatio ?? 1.5, + aspectRatio: `${item.aspectRatio ?? 1.5}`, }} > - + @@ -294,7 +319,9 @@ function EssayViewContent({ essay, assets, title, subtitle, watermark }: EssayVi type="button" onClick={() => proofing.setIsFilterActive((prev) => !prev)} style={{ - background: proofing.isFilterActive ? 'var(--accent, #e60012)' : 'rgba(255,255,255,0.15)', + background: proofing.isFilterActive + ? 'var(--accent, #e60012)' + : 'rgba(255,255,255,0.15)', color: '#fff', border: 'none', padding: '6px 14px', @@ -324,16 +351,23 @@ function EssayViewContent({ essay, assets, title, subtitle, watermark }: EssayVi
)} - + {proofingEnabled && } {lightboxIndex !== null && ( setLightboxIndex(null)} - onNext={() => setLightboxIndex((prev) => (prev !== null ? (prev + 1) % assets.length : null))} - onPrev={() => setLightboxIndex((prev) => (prev !== null ? (prev - 1 + assets.length) % assets.length : null))} + onNext={() => + setLightboxIndex((prev) => (prev !== null ? (prev + 1) % assets.length : null)) + } + onPrev={() => + setLightboxIndex((prev) => + prev !== null ? (prev - 1 + assets.length) % assets.length : null, + ) + } watermark={watermark} + showExifToggle={false} /> )}
@@ -343,6 +377,12 @@ function EssayViewContent({ essay, assets, title, subtitle, watermark }: EssayVi export function EssayView(props: EssayViewProps) { const albumTokens = useMemo(() => props.assets.map((a) => a.id), [props.assets]); + // Without the provider useProofing() returns null, and every proofing control + // (hearts in the grid and in the lightbox, selection bar) drops out on its own. + if (!props.proofing) { + return ; + } + return ( diff --git a/app/[...path]/PhotoGrid.tsx b/app/[...path]/PhotoGrid.tsx index e887005..a11b33e 100644 --- a/app/[...path]/PhotoGrid.tsx +++ b/app/[...path]/PhotoGrid.tsx @@ -116,32 +116,7 @@ function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: Ph setLightboxIndex((prev) => (prev !== null ? (prev - 1 + displayedAssets.length) % displayedAssets.length : null)); }, [displayedAssets.length]); - // Keyboard navigation - useEffect(() => { - if (lightboxIndex === null) return; - - const handleKeyDown = (e: KeyboardEvent) => { - switch (e.key) { - case 'Escape': - closeLightbox(); - break; - case 'ArrowRight': - goNext(); - break; - case 'ArrowLeft': - goPrev(); - break; - } - }; - - document.addEventListener('keydown', handleKeyDown); - document.body.style.overflow = 'hidden'; - - return () => { - document.removeEventListener('keydown', handleKeyDown); - document.body.style.overflow = ''; - }; - }, [lightboxIndex, closeLightbox, goNext, goPrev]); + // Keyboard navigation and the scroll lock live in . const gridItems = useMemo(() => { return displayedAssets.map((asset, index) => { diff --git a/app/[...path]/essay.css b/app/[...path]/essay.css index 0b5560f..5427ffb 100644 --- a/app/[...path]/essay.css +++ b/app/[...path]/essay.css @@ -1,6 +1,23 @@ /* Photo Essay & Storytelling Mode Styles */ .essay-container { + /* + * One shared measure for every block, so headings, prose and figures line up + * on the same left edge. Deliberately in rem, not the `68ch` this replaces: + * `ch` is relative to each element's own font, so a serif heading resolved to + * a wider column than the body text and started further left. 40rem matches + * what 68ch produced for the body font. Custom properties are substituted as + * raw tokens, so a `ch` value here would be re-resolved per element and bring + * the bug straight back — keep this font-independent. + * + * `--essay-bleed` / `--essay-wide` are viewport-based by default and get + * overridden where the essay renders inside a narrower box (e.g. the admin + * Journal Studio preview pane). + */ + --essay-measure: 40rem; + --essay-wide: 1100px; + --essay-bleed: 100vw; + width: 100%; max-width: 100%; margin: 0 auto; @@ -9,7 +26,7 @@ /* Header & Cover */ .essay-header { - max-width: 68ch; + max-width: var(--essay-measure); margin: 0 auto 3.5rem auto; text-align: center; } @@ -41,7 +58,7 @@ /* Reading column */ .essay-prose { - max-width: 68ch; + max-width: var(--essay-measure); margin: 0 auto 2.5rem auto; font-family: var(--font-body, sans-serif); font-size: clamp(1.05rem, 2vw, 1.2rem); @@ -55,19 +72,25 @@ .essay-heading { font-family: var(--font-heading, serif); - max-width: 68ch; + max-width: var(--essay-measure); margin: 3.5rem auto 1.5rem auto; font-weight: 600; line-height: 1.25; } -.essay-heading--1 { font-size: clamp(1.8rem, 3.5vw, 2.5rem); } -.essay-heading--2 { font-size: clamp(1.5rem, 3vw, 2rem); } -.essay-heading--3 { font-size: clamp(1.25rem, 2.5vw, 1.6rem); } +.essay-heading--1 { + font-size: clamp(1.8rem, 3.5vw, 2.5rem); +} +.essay-heading--2 { + font-size: clamp(1.5rem, 3vw, 2rem); +} +.essay-heading--3 { + font-size: clamp(1.25rem, 2.5vw, 1.6rem); +} /* Pullquote */ .essay-quote { - max-width: 68ch; + max-width: var(--essay-measure); margin: 3.5rem auto; padding: 1.5rem 2rem; border-left: 3px solid var(--accent, #e60012); @@ -96,17 +119,23 @@ } .essay-figure--contained { - max-width: 68ch; + max-width: var(--essay-measure); } .essay-figure--wide { - max-width: 1100px; + max-width: var(--essay-wide); } +/* + * `width` has to be set too, not just max-width: .essay-figure is width: 100%, + * which over-constrains the box so the negative right margin is dropped and only + * the left side bleeds. + */ .essay-figure--fullbleed { - max-width: 100vw; - margin-left: calc(-50vw + 50%); - margin-right: calc(-50vw + 50%); + width: var(--essay-bleed); + max-width: var(--essay-bleed); + margin-left: calc(-0.5 * var(--essay-bleed) + 50%); + margin-right: calc(-0.5 * var(--essay-bleed) + 50%); } .essay-image-wrapper { @@ -131,7 +160,7 @@ } .essay-figcaption { - max-width: 68ch; + max-width: var(--essay-measure); margin: 0.75rem auto 0 auto; text-align: center; font-family: var(--font-caption, monospace); @@ -140,18 +169,34 @@ } /* Side by side photo pair */ +/* + * Justified row: each photo keeps its own aspect ratio, and its width is given + * by `flex-grow: ` (set inline from the asset). Since width ∝ ratio + * and height = width / ratio, both photos end up exactly the same height without + * a single pixel being cropped — a shared baseline instead of one portrait + * towering over a landscape. + */ .essay-pair-grid { - display: grid; - grid-template-columns: 1fr 1fr; + display: flex; gap: 1.5rem; width: 100%; - max-width: 1100px; + max-width: var(--essay-wide); margin: 3.5rem auto; } +.essay-pair-grid > .essay-image-wrapper { + flex-basis: 0; + min-width: 0; +} + @media (max-width: 640px) { + /* Stacked: full width each, height from the photo's own ratio. */ .essay-pair-grid { - grid-template-columns: 1fr; + flex-direction: column; gap: 1.5rem; } + + .essay-pair-grid > .essay-image-wrapper { + flex: none; + } } diff --git a/app/[...path]/page.tsx b/app/[...path]/page.tsx index 2e7ba82..4cfc241 100644 --- a/app/[...path]/page.tsx +++ b/app/[...path]/page.tsx @@ -32,7 +32,8 @@ import { AdminDiagnosticBanner } from '@/components/AdminDiagnosticBanner'; import { AlbumDetailView } from './AlbumDetailView'; import { SubpageGridView } from './SubpageGridView'; import { EssayView } from './EssayView'; -import { parseEssayMarkdown, loadEssayFromFile } from '@/lib/essay'; +import { parseEssayMarkdown } from '@/lib/essay'; +import { loadEssayFromFile } from '@/lib/admin/journal-service'; // Render at request time — requires live Immich connection export const dynamic = 'force-dynamic'; diff --git a/app/admin/AdminShell.tsx b/app/admin/AdminShell.tsx new file mode 100644 index 0000000..5a9cfd0 --- /dev/null +++ b/app/admin/AdminShell.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { useState, useEffect, type ReactNode } from 'react'; +import AdminLogin from './components/AdminLogin'; +import AdminDashboard from './components/AdminDashboard'; +import './admin.css'; + +/** + * Auth gate and panel chrome for every /admin route. Lives in the admin layout + * rather than in each page, so navigating between tabs keeps the session check + * and the header mounted instead of re-running them per route. + */ +export default function AdminShell({ children }: { children: ReactNode }) { + const [authenticated, setAuthenticated] = useState(null); + const [enabled, setEnabled] = useState(true); + + useEffect(() => { + async function checkAuth() { + try { + const res = await fetch('/api/admin/auth'); + const data = await res.json(); + setAuthenticated(data.authenticated); + setEnabled(data.enabled); + } catch { + setAuthenticated(false); + } + } + checkAuth(); + }, []); + + if (authenticated === null) { + return ( +
+
+
+ ); + } + + if (!enabled) { + return ( +
+
+

Admin Panel

+

+ The admin panel is not enabled. Set ADMIN_PASSWORD in your environment + variables to activate it. +

+
+
+ ); + } + + if (!authenticated) { + return setAuthenticated(true)} />; + } + + return setAuthenticated(false)}>{children}; +} diff --git a/app/admin/admin.css b/app/admin/admin.css index 503f9a0..fc59c52 100644 --- a/app/admin/admin.css +++ b/app/admin/admin.css @@ -3,7 +3,11 @@ /* Supports light/dark mode via data-theme attribute on . */ /* ── Theme Variables ───────────────────────────────────────── */ -.admin-layout { +/* .admin-listbox-popup is listed alongside .admin-layout because it is + portalled to , outside the layout subtree. Without its own copy every + var below resolves to nothing there, and the popup paints transparent. */ +.admin-layout, +.admin-listbox-popup { /* Dark mode (default) */ --admin-bg: #111111; --admin-bg-raised: #1a1a1a; @@ -16,6 +20,8 @@ --admin-text-secondary: #a0a0a0; --admin-text-muted: #707070; --admin-accent: #f59e0b; + /* One height for every control in the header row. */ + --admin-control-height: 36px; --admin-success: #4ade80; --admin-error: #f87171; --admin-error-bg: #2d1111; @@ -26,7 +32,8 @@ --admin-overlay: rgba(0, 0, 0, 0.7); } -[data-theme='light'] .admin-layout { +[data-theme='light'] .admin-layout, +[data-theme='light'] .admin-listbox-popup { --admin-bg: #f8f8f8; --admin-bg-raised: #ffffff; --admin-bg-elevated: #f0f0f0; @@ -182,6 +189,132 @@ min-height: 60px; } +/* ── Listbox (select replacement that can render SVG icons) ────── */ + +.admin-listbox { + position: relative; + width: 100%; +} + +.admin-listbox-trigger { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + width: 100%; + padding: 0.6rem 0.75rem; + background: var(--admin-bg-input); + border: 1px solid var(--admin-border); + border-radius: 6px; + color: var(--admin-text); + font-size: 0.875rem; + font-family: inherit; + text-align: left; + cursor: pointer; + transition: border-color 0.15s; +} + +.admin-listbox-trigger:hover:not(:disabled) { + border-color: var(--admin-border-hover); +} + +.admin-listbox-trigger:focus-visible { + outline: none; + border-color: var(--admin-border-focus); +} + +.admin-listbox-trigger:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.admin-listbox-value { + display: inline-flex; + align-items: center; + gap: 0.5rem; + min-width: 0; +} + +.admin-listbox-value > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-listbox-value .svg-icon, +.admin-listbox-option > .svg-icon { + color: var(--admin-text-secondary); +} + +.admin-listbox-caret { + color: var(--admin-text-secondary); + transition: transform 0.15s ease; +} + +.admin-listbox-trigger[aria-expanded='true'] .admin-listbox-caret { + transform: rotate(180deg); +} + +/* Portalled to , so it escapes the overflow of any scrolling ancestor. + left/top/bottom/width/max-height are measured from the trigger and set inline + by the component; the z-index clears the modal overlay (1000). */ +.admin-listbox-popup { + position: fixed; + z-index: 1100; + overflow-y: auto; + /* A long list scrolls internally; without this, reaching its end hands the + wheel to whatever is behind the modal. */ + overscroll-behavior: contain; + margin: 0; + padding: 0.25rem; + list-style: none; + /* Also outside .admin-layout, so the font does not inherit either. */ + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + background: var(--admin-bg-elevated); + border: 1px solid var(--admin-border); + border-radius: 8px; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35); +} + +[data-theme='light'] .admin-listbox-popup { + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12); +} + +.admin-listbox-option { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.45rem 0.5rem; + border-radius: 5px; + color: var(--admin-text); + font-size: 0.875rem; + cursor: pointer; +} + +.admin-listbox-option.is-active { + background: var(--admin-bg-raised); +} + +.admin-listbox-option.is-selected { + color: var(--admin-accent); +} + +.admin-listbox-option.is-selected > .svg-icon { + color: var(--admin-accent); +} + +.admin-listbox-option-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-listbox-check { + color: var(--admin-accent); +} + .admin-field-row { display: grid; grid-template-columns: 1fr 1fr; @@ -231,11 +364,22 @@ align-items: center; justify-content: center; gap: 0.4rem; - padding: 0.6rem 1.2rem; + padding: 0 1.2rem; + min-height: var(--admin-control-height); + /* - - + {TABS.map((t) => ( + + {t.label} + + ))}
@@ -195,12 +192,14 @@ export default function AdminDashboard({ onLogout }: Props) { )}
+
Site @@ -225,11 +224,7 @@ export default function AdminDashboard({ onLogout }: Props) { -
- {activeTab === 'pages' && } - {activeTab === 'settings' && } - {activeTab === 'analytics' && } -
+
{children}
{ @@ -75,7 +78,9 @@ export default function AlbumPicker({ albums, onSelect, onClose, usedAlbumIds }: loading="lazy" /> ) : ( -
📁
+
+ +
)}
diff --git a/app/admin/components/AssetOrderEditor.tsx b/app/admin/components/AssetOrderEditor.tsx index d998045..2e2691d 100644 --- a/app/admin/components/AssetOrderEditor.tsx +++ b/app/admin/components/AssetOrderEditor.tsx @@ -18,6 +18,7 @@ import { rectSortingStrategy, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; +import { useScrollLock } from './useScrollLock'; interface AssetInfo { id: string; @@ -92,6 +93,7 @@ export default function AssetOrderEditor({ onSave, onClose, }: Props) { + useScrollLock(true); const [assets, setAssets] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); diff --git a/app/admin/components/AssetPicker.tsx b/app/admin/components/AssetPicker.tsx index 8f2226f..a5004c6 100644 --- a/app/admin/components/AssetPicker.tsx +++ b/app/admin/components/AssetPicker.tsx @@ -1,7 +1,8 @@ 'use client'; import { useState, useEffect, useCallback, useRef } from 'react'; -import { IconFolder, IconImage, IconStar } from './Icons'; +import { IconCheck, IconFolder, IconImage, IconStar } from './Icons'; +import { useScrollLock } from './useScrollLock'; interface AssetInfo { id: string; @@ -27,6 +28,7 @@ export default function AssetPicker({ albumId, title, }: Props) { + useScrollLock(true); const [tab, setTab] = useState(albumId ? 'album' : 'favorites'); const [assets, setAssets] = useState([]); const [loading, setLoading] = useState(true); @@ -175,7 +177,11 @@ export default function AssetPicker({ alt={asset.originalFileName} loading="lazy" /> - {isUsed &&
✓
} + {isUsed && ( +
+ +
+ )} {asset.isFavorite && !isUsed && (
diff --git a/app/admin/components/BackupManagerModal.tsx b/app/admin/components/BackupManagerModal.tsx index b3f3489..5eece9d 100644 --- a/app/admin/components/BackupManagerModal.tsx +++ b/app/admin/components/BackupManagerModal.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useCallback } from 'react'; import type { BackupItem } from '@/app/api/admin/backups/route'; import * as Icons from './Icons'; +import { useScrollLock } from './useScrollLock'; interface Props { isOpen: boolean; @@ -49,6 +50,8 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess } } }, [isOpen, fetchBackups]); + useScrollLock(isOpen); + if (!isOpen) return null; async function handleRestore(filename: string) { @@ -96,7 +99,7 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }

diff --git a/app/admin/components/BlockBadge.tsx b/app/admin/components/BlockBadge.tsx new file mode 100644 index 0000000..0d681d5 --- /dev/null +++ b/app/admin/components/BlockBadge.tsx @@ -0,0 +1,42 @@ +'use client'; + +import type { JournalBlock } from '@/lib/journal'; +import { IconFileText, IconSparkles, IconQuote, IconCamera, IconArrowLeftRight } from './Icons'; + +type BlockType = JournalBlock['type']; + +/** + * Block type chips for the Journal Studio and the Pages essay builder. + * + * Deliberately monochrome: the panel's colour language is neutral surfaces plus + * a single accent, and these chips label a block rather than reporting a state, + * so they carry no colour of their own. The block type is told apart by its + * icon — the same icon the "Add Block" toolbar uses for it. + */ +const BLOCK_META: Record = { + paragraph: { label: 'Text', Icon: IconFileText }, + heading: { label: 'Heading', Icon: IconSparkles }, + quote: { label: 'Quote', Icon: IconQuote }, + photo: { label: 'Photo', Icon: IconCamera }, + 'photo-pair': { label: 'Photo Pair', Icon: IconArrowLeftRight }, +}; + +interface BlockBadgeProps { + type: BlockType; + /** Optional position prefix, e.g. "3" renders as "3 · Quote". */ + index?: number; +} + +export function BlockBadge({ type, index }: BlockBadgeProps) { + const meta = BLOCK_META[type]; + if (!meta) return {type}; + + const { label, Icon } = meta; + return ( + + + {index != null && {index}} + {label} + + ); +} diff --git a/app/admin/components/EssayBlockEditor.tsx b/app/admin/components/EssayBlockEditor.tsx index ce39150..0813202 100644 --- a/app/admin/components/EssayBlockEditor.tsx +++ b/app/admin/components/EssayBlockEditor.tsx @@ -18,6 +18,7 @@ import { IconSparkles, IconArrowLeftRight, } from './Icons'; +import { BlockBadge } from './BlockBadge'; interface EssayBlockEditorProps { markdown: string; @@ -119,7 +120,7 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
{/* Sticky Dark Glass Toolbar */}
- ✨ Add Story Block: + Add Story Block: +
+ + {loading ? ( +
+ Loading journal entries... +
+ ) : error ? ( +
{error}
+ ) : entries.length === 0 ? ( +
+

No journal entries yet

+

+ Create your first story to share travel journals or wedding reportages. +

+ +
+ ) : ( +
+ {entries.map((entry) => ( +
+
+ {entry.frontmatter.coverAssetId ? ( + // eslint-disable-next-line @next/next/no-img-element + {entry.frontmatter.title + ) : ( + + )} +
+
+

+ {entry.frontmatter.title || entry.slug} +

+
/journal/{entry.slug}
+ +
+ {entry.frontmatter.date && ( + + {entry.frontmatter.date} + + )} + + {entry.readingTimeMinutes} min + + {entry.frontmatter.draft ? ( + Draft + ) : ( + Published + )} + {entry.frontmatter.password && ( + + Password + + )} +
+ +
+ + + + + +
+
+
+ ))} +
+ )} + + {/* Create Modal */} + {showCreateModal && ( +
+
+

Create New Journal Entry

+
+
+ + { + setNewTitle(e.target.value); + if (!newSlug || newSlug === sanitizeSlug(newTitle)) { + setNewSlug(sanitizeSlug(e.target.value)); + } + }} + autoFocus + required + /> +
+ +
+ + setNewSlug(sanitizeSlug(e.target.value))} + required + /> + + Will be accessible at /journal/{newSlug || 'slug'} + +
+ +
+ + +
+
+
+
+ )} +
+ ); +} + +// ── Studio Editor Component ────────────────────────────────────────── + +interface JournalEditorProps { + slug: string; + onBack: () => void; +} + +/** + * Photo blocks may carry a legacy positional reference ("1", "2") instead of an + * asset UUID. Those only ever resolved against a subpage's album; on a + * standalone journal page there is no album, so the photo silently disappears. + * Flag them in the editor so the author can re-pick before publishing. + */ +const ASSET_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function isLegacyAssetRef(assetId: string): boolean { + return assetId.length > 0 && !ASSET_UUID.test(assetId); +} + +/** Width of the authoring pane, in percent of the split view. */ +const SPLIT_STORAGE_KEY = 'folio-journal-split'; +const SPLIT_MIN = 25; +const SPLIT_MAX = 70; +const SPLIT_DEFAULT = 46; + +function JournalEditor({ slug, onBack }: JournalEditorProps) { + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [dirty, setDirty] = useState(false); + const [editorMode, setEditorMode] = useState<'blocks' | 'markdown'>('blocks'); + const [viewport, setViewport] = useState<'desktop' | 'mobile'>('desktop'); + + // Draggable divider between authoring pane and preview. + const splitRef = useRef(null); + const [splitPct, setSplitPct] = useState(SPLIT_DEFAULT); + const [dragging, setDragging] = useState(false); + + // Restore the last width after mount (localStorage is unavailable on the server). + useEffect(() => { + const stored = Number(window.localStorage.getItem(SPLIT_STORAGE_KEY)); + if (stored >= SPLIT_MIN && stored <= SPLIT_MAX) setSplitPct(stored); + }, []); + + const applySplit = useCallback((clientX: number) => { + const rect = splitRef.current?.getBoundingClientRect(); + if (!rect || rect.width === 0) return; + const pct = ((clientX - rect.left) / rect.width) * 100; + setSplitPct(Math.min(SPLIT_MAX, Math.max(SPLIT_MIN, pct))); + }, []); + + useEffect(() => { + if (!dragging) return; + const onMove = (e: MouseEvent) => { + e.preventDefault(); + applySplit(e.clientX); + }; + const onUp = () => setDragging(false); + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + return () => { + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + }; + }, [dragging, applySplit]); + + // Persist once the drag ends, not on every pixel. + useEffect(() => { + if (dragging) return; + window.localStorage.setItem(SPLIT_STORAGE_KEY, String(Math.round(splitPct))); + }, [dragging, splitPct]); + + // Keyboard access for the divider: arrows nudge, Home resets. + const handleSplitKeyDown = (e: React.KeyboardEvent) => { + const step = e.shiftKey ? 10 : 2; + if (e.key === 'ArrowLeft') { + e.preventDefault(); + setSplitPct((p) => Math.max(SPLIT_MIN, p - step)); + } else if (e.key === 'ArrowRight') { + e.preventDefault(); + setSplitPct((p) => Math.min(SPLIT_MAX, p + step)); + } else if (e.key === 'Home') { + e.preventDefault(); + setSplitPct(SPLIT_DEFAULT); + } + }; + + const [rawMarkdown, setRawMarkdown] = useState(''); + const [parsed, setParsed] = useState(() => ({ + frontmatter: {}, + blocks: [], + referencedAssetIds: [], + })); + + // Settings / Metadata Modal + const [showMetaModal, setShowMetaModal] = useState(false); + + /** Real width/height ratios of the referenced photos, measured from thumbnails. */ + const [assetRatios, setAssetRatios] = useState>({}); + + // Asset Picker State + const [assetPickerTarget, setAssetPickerTarget] = useState<{ + onSelect: (assetId: string) => void; + title: string; + } | null>(null); + + // Load entry + useEffect(() => { + async function load() { + setLoading(true); + try { + const res = await fetch(`/api/admin/journal/${slug}`); + if (res.ok) { + const data = await res.json(); + const md = data.entry.rawMarkdown; + setRawMarkdown(md); + setParsed(parseJournalMarkdown(md)); + setDirty(false); + } else { + alert('Failed to load journal entry'); + } + } catch { + alert('Error loading journal entry'); + } finally { + setLoading(false); + } + } + load(); + }, [slug]); + + // Update markdown and sync blocks + const handleMarkdownChange = (newMd: string) => { + setRawMarkdown(newMd); + setParsed(parseJournalMarkdown(newMd)); + setDirty(true); + }; + + // Update structured blocks and sync markdown + const handleBlocksChange = (newBlocks: JournalBlock[]) => { + const updated: ParsedJournal = { + ...parsed, + blocks: newBlocks, + referencedAssetIds: Array.from( + new Set( + newBlocks.flatMap((b) => { + if (b.type === 'photo') return [b.assetId]; + if (b.type === 'photo-pair') return b.assetIds; + return []; + }), + ), + ), + }; + const serialized = serializeJournalMarkdown(updated); + setParsed(updated); + setRawMarkdown(serialized); + setDirty(true); + }; + + // Update frontmatter + const handleFrontmatterChange = (updates: Partial) => { + const updated: ParsedJournal = { + ...parsed, + frontmatter: { + ...parsed.frontmatter, + ...updates, + }, + }; + const serialized = serializeJournalMarkdown(updated); + setParsed(updated); + setRawMarkdown(serialized); + setDirty(true); + }; + + // Save + const handleSave = async () => { + setSaving(true); + try { + const res = await fetch(`/api/admin/journal/${slug}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + rawMarkdown, + }), + }); + + if (res.ok) { + setDirty(false); + } else { + const data = await res.json(); + alert(data.error || 'Failed to save'); + } + } catch { + alert('Error saving entry'); + } finally { + setSaving(false); + } + }; + + // Keyboard shortcut: Cmd+S / Ctrl+S + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 's') { + e.preventDefault(); + handleSave(); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [handleSave]); // eslint-disable-line react-hooks/exhaustive-deps + + /* + * The preview used to hard-code 3:2 for every photo, so the studio showed a + * cropped, uniform grid while the published page laid the photos out by their + * real proportions. There is no admin endpoint that reports asset dimensions, + * so the thumbnails are measured once as they load. + */ + const measuredRef = useRef(new Set()); + + useEffect(() => { + let cancelled = false; + for (const id of parsed.referencedAssetIds) { + if (!id || measuredRef.current.has(id)) continue; + measuredRef.current.add(id); + + const probe = new window.Image(); + probe.onload = () => { + if (cancelled || !probe.naturalHeight) return; + setAssetRatios((prev) => ({ + ...prev, + [id]: probe.naturalWidth / probe.naturalHeight, + })); + }; + probe.onerror = () => { + // Unresolvable reference — keep the fallback ratio, the block editor + // already flags it. + measuredRef.current.delete(id); + }; + probe.src = `/api/admin/thumbnail/${id}`; + } + return () => { + cancelled = true; + }; + }, [parsed.referencedAssetIds]); + + // Block manipulation + const handleAddBlock = (type: JournalBlock['type']) => { + let newBlock: JournalBlock; + switch (type) { + case 'heading': + newBlock = { type: 'heading', level: 2, text: 'New Heading' }; + break; + case 'paragraph': + newBlock = { type: 'paragraph', html: 'Enter paragraph text here...' }; + break; + case 'quote': + newBlock = { type: 'quote', text: 'Enter quote text...', author: '' }; + break; + case 'photo': + newBlock = { type: 'photo', assetId: '', caption: '', layout: 'contained' }; + break; + case 'photo-pair': + newBlock = { type: 'photo-pair', assetIds: ['', ''], caption: '' }; + break; + } + handleBlocksChange([...parsed.blocks, newBlock]); + }; + + const handleMoveBlock = (index: number, direction: 'up' | 'down') => { + const target = direction === 'up' ? index - 1 : index + 1; + if (target < 0 || target >= parsed.blocks.length) return; + const blocks = [...parsed.blocks]; + const [moved] = blocks.splice(index, 1); + blocks.splice(target, 0, moved); + handleBlocksChange(blocks); + }; + + const handleDeleteBlock = (index: number) => { + handleBlocksChange(parsed.blocks.filter((_, i) => i !== index)); + }; + + const handleUpdateBlock = (index: number, updated: JournalBlock) => { + const blocks = [...parsed.blocks]; + blocks[index] = updated; + handleBlocksChange(blocks); + }; + + // Mock PhotoItems for preview + const previewAssets: PhotoItem[] = parsed.referencedAssetIds.map((id) => ({ + id, + type: 'image', + thumbUrl: `/api/admin/thumbnail/${id}`, + previewUrl: `/api/admin/thumbnail/${id}`, + exifUrl: `/api/exif/${id}`, + // Falls back to 3:2 only until the real ratio has been measured. + aspectRatio: assetRatios[id] ?? 1.5, + })); + + if (loading) { + return ( +
+ Opening Journal Studio... +
+ ); + } + + return ( +
+ {/* Top Bar */} +
+
+ + + handleFrontmatterChange({ title: e.target.value })} + /> + +
+ + +
+
+ +
+ + + + Live + + + +
+
+ + {/* Split Screen */} +
+ {/* Left: Authoring Pane */} +
+ {editorMode === 'markdown' ? ( +