From e99d3f2f1b16a78e49d1a271ece1cc8f0efc1d3d Mon Sep 17 00:00:00 2001 From: Ralksta <16941050+ralksta@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:11:25 +0200 Subject: [PATCH] release: v0.14.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourteen commits since v0.13.0. See CHANGELOG.md for the full list. Two additions: `npm run doctor`, which runs the config doctor from a terminal — including inside the shipped image — and exits with the worst finding, so it can gate a deployment script; and a Playwright spec that loads all fourteen admin routes against a production build. One change, invisible on screen: the admin panel got a form-field layer (#533). Five fixes: grid.gap on both axes (#513), the dashboard status badge (#539), the journal editor's unsaved-changes guard (#538), essay paragraph markup (#537), and aria-pressed on the panel's switches and pickers. --- .github/workflows/ci.yml | 62 + CHANGELOG.md | 137 + CONTRIBUTING.md | 1 + Dockerfile | 12 + README.md | 4 + app/admin/__tests__/systemHealth.test.ts | 85 + app/admin/admin.css | 4880 +---------------- app/admin/components/AdminDashboard.tsx | 36 +- app/admin/components/EssayBlockEditor.tsx | 15 +- app/admin/components/JournalStudio.tsx | 3 + app/admin/components/PageBuilder.tsx | 1239 +---- app/admin/components/SettingsEditor.tsx | 1009 ++-- .../components/fields/GridOverrideFields.tsx | 79 + app/admin/components/fields/OptionGrid.tsx | 85 + app/admin/components/fields/ToggleCard.tsx | 48 + .../components/page-builder/AlbumCard.tsx | 138 + .../components/page-builder/AlbumDrawer.tsx | 250 + .../components/page-builder/SubpageDrawer.tsx | 576 ++ .../page-builder/SubpagePreview.tsx | 77 + .../components/page-builder/sortOptions.tsx | 43 + app/admin/components/page-builder/types.ts | 106 + app/admin/components/systemHealth.ts | 48 + app/admin/components/useUnsavedGuard.ts | 29 + app/admin/styles/album-picker.css | 159 + app/admin/styles/analytics.css | 211 + app/admin/styles/asset-order.css | 141 + app/admin/styles/asset-picker.css | 168 + app/admin/styles/base.css | 298 + app/admin/styles/buttons.css | 135 + app/admin/styles/drawer-live-preview.css | 89 + app/admin/styles/drawers.css | 742 +++ app/admin/styles/essay-builder.css | 193 + app/admin/styles/favicon.css | 19 + app/admin/styles/help.css | 196 + app/admin/styles/page-builder.css | 486 ++ app/admin/styles/save-bar-floating.css | 78 + app/admin/styles/seo-preview.css | 227 + app/admin/styles/settings-editor.css | 699 +++ app/admin/styles/shell.css | 115 + app/admin/styles/subpage-sheet.css | 325 ++ app/admin/styles/theme-cards.css | 442 ++ app/admin/styles/tokens.css | 63 + app/admin/styles/utilities.css | 126 + app/themes/classic.css | 10 +- app/themes/editorial.css | 7 +- app/themes/minimal.css | 3 +- app/themes/monograph.css | 5 +- app/themes/studio-modern.css | 7 +- docs/admin-panel.md | 5 + docs/deployment.md | 63 + e2e/admin-smoke.spec.ts | 110 + package-lock.json | 4 +- package.json | 6 +- playwright.config.ts | 22 +- scripts/__tests__/doctor-cli.test.ts | 427 ++ scripts/doctor.mjs | 26 + scripts/doctor.mts | 750 +++ tsconfig.json | 3 + vitest.config.ts | 6 +- 59 files changed, 8652 insertions(+), 6676 deletions(-) create mode 100644 app/admin/__tests__/systemHealth.test.ts create mode 100644 app/admin/components/fields/GridOverrideFields.tsx create mode 100644 app/admin/components/fields/OptionGrid.tsx create mode 100644 app/admin/components/fields/ToggleCard.tsx create mode 100644 app/admin/components/page-builder/AlbumCard.tsx create mode 100644 app/admin/components/page-builder/AlbumDrawer.tsx create mode 100644 app/admin/components/page-builder/SubpageDrawer.tsx create mode 100644 app/admin/components/page-builder/SubpagePreview.tsx create mode 100644 app/admin/components/page-builder/sortOptions.tsx create mode 100644 app/admin/components/page-builder/types.ts create mode 100644 app/admin/components/systemHealth.ts create mode 100644 app/admin/components/useUnsavedGuard.ts create mode 100644 app/admin/styles/album-picker.css create mode 100644 app/admin/styles/analytics.css create mode 100644 app/admin/styles/asset-order.css create mode 100644 app/admin/styles/asset-picker.css create mode 100644 app/admin/styles/base.css create mode 100644 app/admin/styles/buttons.css create mode 100644 app/admin/styles/drawer-live-preview.css create mode 100644 app/admin/styles/drawers.css create mode 100644 app/admin/styles/essay-builder.css create mode 100644 app/admin/styles/favicon.css create mode 100644 app/admin/styles/help.css create mode 100644 app/admin/styles/page-builder.css create mode 100644 app/admin/styles/save-bar-floating.css create mode 100644 app/admin/styles/seo-preview.css create mode 100644 app/admin/styles/settings-editor.css create mode 100644 app/admin/styles/shell.css create mode 100644 app/admin/styles/subpage-sheet.css create mode 100644 app/admin/styles/theme-cards.css create mode 100644 app/admin/styles/tokens.css create mode 100644 app/admin/styles/utilities.css create mode 100644 e2e/admin-smoke.spec.ts create mode 100644 scripts/__tests__/doctor-cli.test.ts create mode 100644 scripts/doctor.mjs create mode 100644 scripts/doctor.mts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 215d248..94750a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,12 @@ on: # at release time. branches: [main, dev] +# Neither job writes anything back to the repository: they check out the +# code, build it and report through the run itself. Read is all they need, +# and declaring it here covers every job rather than each one separately. +permissions: + contents: read + # Cancel in-progress runs for the same PR / branch concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -88,3 +94,59 @@ jobs: done <<< "$unformatted" echo '' >> "$GITHUB_STEP_SUMMARY" echo 'Advisory only — this does not block the merge.' >> "$GITHUB_STEP_SUMMARY" + + # Separate job because it needs a browser and a built server, and because a + # slow browser run should not hold up the fast type-check/test feedback. + # + # Only the admin smoke spec runs here. The other e2e specs render the public + # gallery, which needs a real Immich behind it — CI has none, so they would + # fail for reasons that have nothing to do with the change under test. The + # admin panel reads its state from the repo's config files and degrades + # gracefully when Immich is unreachable, so it is the part of the suite that + # can honestly run without a backend. + e2e-admin: + name: Admin panel smoke test + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v7 + + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Install Chromium + run: npx playwright install --with-deps chromium + + - name: Build + run: npm run build + env: + 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 + + # E2E_START runs the spec against `npm run start` rather than the dev + # server. #542 was a module-evaluation error in shipped output, so the + # build that ships is the one worth loading in a browser. + - name: Admin smoke test + run: npm run test:e2e:admin + env: + E2E_START: '1' + 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 + ADMIN_PASSWORD: e2e-admin-password + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/CHANGELOG.md b/CHANGELOG.md index 06f4860..d9444ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,138 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Releases up to and including v0.9.2 are documented in the [GitHub releases](https://github.com/ralksta/immich-folio/releases). +## [0.14.0] — 2026-08-30 + +### Added + +- **`npm run doctor` — the config doctor in the terminal** + ([#521](https://github.com/ralksta/immich-folio/issues/521)). The doctor + shipped in v0.12.0 as a panel in `/admin`. That is the wrong place for the + case it is best at: a deployment that will not come up, where there is no + panel to open and no admin password to get past. The same checks now run from + a terminal — `AUTH_SECRET`, whether the YAML parses, whether Immich answers + the three calls Folio depends on, whether every published album still exists + and is shared, whether a password is still plaintext or an unusable bcrypt + hash, and whether `content/` can be written. + + It runs in the shipped image too, which is where it matters: + + ```bash + docker compose exec folio npm run doctor + ``` + + The exit code is the worst thing it found — `0` clean, `1` warnings, `2` + errors, `3` the doctor itself could not run — so it can gate a deployment + script. No secret is ever printed, only whether something is set. + + Two findings are reported rather than judged, and say so under their own + **NOTES** heading instead of being counted as passed checks: + `TRUSTED_PROXY_HOPS`, which can only be measured against a real request, and + `content/` writability, which is tested as whoever typed the command — on a + Docker deployment that is not the user the app writes as, so the report names + both and points back at `docker compose exec`. An unwritable path stays a + plain error when the CLI does run as the owner. + + No check was reimplemented: the CLI calls the same functions as the panel and + only gathers their inputs differently. It needs no new dependency either — + Node strips the types itself, which works because those checks import nothing. + +- **CI loads the admin panel in a real browser.** A Playwright spec walks all + fourteen admin routes against a production build and fails on an error + boundary, a missing page body, or anything thrown during hydration. It exists + because of [#542](https://github.com/ralksta/immich-folio/issues/542): a + temporal-dead-zone error at module evaluation took the whole panel down while + the type check, the build and the unit tests all stayed green — none of them + evaluates a client module in a browser, which is the only place that bug is + visible. The spec was checked against the bug: with it reintroduced, the run + names every broken route and reports the original + `Cannot access 'THEME_INFO' before initialization`. + +### Changed + +- **The admin panel has a form-field layer** + ([#533](https://github.com/ralksta/immich-folio/issues/533)). Nothing changes + on screen; this is about what the panel costs to change. It had grown 171% in + thirty days, and three files were two thirds of it. Every field was + hand-written markup: the toggle card existed as fourteen copies, the five card + pickers as five hand-rolled grids, and the grid editor as two 58-line blocks + differing only in which config key they wrote. A new setting cost twenty lines + of copied markup, and changing how a field looks meant editing it fourteen + times. + + `ToggleCard`, `OptionGrid` and `GridOverrideFields` replace those copies; the + subpage and album drawers moved out of `PageBuilder.tsx`, which went from + 2,290 lines and 46 characters of indentation to 1,153 and 22; and `admin.css` + — 4,855 lines in one file — became a load order over twenty sheets named + after what they style. The CSS class names are untouched throughout, which is + what kept the appearance out of it. + + Two things were deliberately left undone rather than half-done: adopting or + deleting the custom `Listbox` (a product decision about a keyboard model, not + a mechanical one), and a `Field` wrapper that would generate an id and wire + `htmlFor` — no admin label does today, so clicking one does not focus its + input. Both are written down on the issue. + +### Fixed + +- **`grid.gap` moves both axes, and two presets get their spacing back** + ([#513](https://github.com/ralksta/immich-folio/issues/513)). The earlier fix + changed the hardcoded `column-gap` in `editorial`, `minimal` and `monograph` + into a `--grid-gap` default, but left the hardcoded `margin-bottom` sitting a + few lines below it in the same three files. In the masonry layout that margin + _is_ the row spacing — a `column-count` layout has no `row-gap` — so the + setting moved the columns and left the rows pinned. It now derives from + `--grid-gap` like everything else. `monograph` keeps its rows deliberately + looser than its columns, so it derives the ratio instead of the value. + + The same change also declared `--grid-gap: 12px` on the base grid, which + meant `classic` and `studio-modern` — which asked for `var(--grid-gap, 20px)` + — never reached their fallback and silently rendered at 12px from v0.12.0 on. + Both declare their 20px outright now, as the other presets do. + + Verified in a browser rather than by reading the cascade: for all seven + presets, an explicit gap of 50px produces 50px on both axes (60px rows for + `monograph`), and with no gap set each preset renders its own intended + default. + +- **The dashboard status badge's colour and words agree** + ([#539](https://github.com/ralksta/immich-folio/issues/539)). The badge decided + its colour and its label through two separately-ordered nested ternaries over + the same four inputs, and they disagreed in two states: with Immich + unreachable _and_ the config doctor warning, the colour matched the warning + first and stayed neutral while the words read "System Degraded"; and a refresh + that followed an earlier error left the badge red under "Checking...". Both + are now decided together, most serious first, so a condition cannot be added + to one and forgotten in the other. + +- **The journal editor warns before you close it with unsaved work** + ([#538](https://github.com/ralksta/immich-folio/issues/538)). It tracked + unsaved changes and used them to drive its save button, but registered no + `beforeunload` handler — so closing the tab mid-entry discarded the work + silently, while the same action in the page builder or the settings editor + asked first. Those two carried the guard as a copy each; all three now share + one, so a fourth editor cannot quietly be the next to forget it. + +- **Editing an essay paragraph no longer strips its italics and links** + ([#537](https://github.com/ralksta/immich-folio/issues/537)). The essay block + editor rendered its textarea from a stripped copy of the paragraph — it turned + `` into `**` and replaced every other tag, `` and `` included, + with nothing — and then wrote that stripped text straight back on the next + keystroke. Touching a paragraph destroyed its markup. The transform was a + partial re-implementation of the serializer, which converts the HTML back to + markdown on save and handles `` correctly, so there was nothing to convert + in the editor at all. It now shows the value raw, as the journal editor + already did for the same data. + +- **The admin panel's switches and pickers announce their state.** The toggle + cards and the five card pickers — theme preset, photo frame, hero style, + layout, aspect ratio — showed which option was active by colour alone, with + no `aria-pressed`, so a screen reader read a row of buttons without saying + which one was on. `SettingRow` already announced itself; the cards beside it + did not. Fixed while giving the panel a form-field layer + ([#533](https://github.com/ralksta/immich-folio/issues/533)), which is what + made it one change instead of nineteen. + ## [0.13.0] — 2026-08-25 ### Added @@ -347,6 +479,11 @@ Releases up to and including v0.9.2 are documented in the change is that their rows finally match their columns. Sites that set `gap` are unaffected, and `classic` and `studio-modern` keep the 12px they had. + > **Correction.** Two claims above were wrong: the row spacing did _not_ + > follow the setting in those three presets, and `classic` and + > `studio-modern` did not keep what they had — they dropped from 20px to + > 12px. Both were fixed later; see the entry under Unreleased. + - **The Immich description can be switched off** ([#506](https://github.com/ralksta/immich-folio/issues/506)). It was served regardless of `exifOnHover`, on the grounds that a caption is editorial rather diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d33c4c4..03b6eba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,6 +44,7 @@ cp content/gallery.yaml.example content/gallery.yaml # 4. Run npm run dev # http://localhost:3000 npm run build # production build check +npm run doctor # check the configuration if something looks wrong ``` ## Before submitting a PR diff --git a/Dockerfile b/Dockerfile index 9c2e44c..95ab9b5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,6 +43,18 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --chmod=755 docker-entrypoint.sh ./docker-entrypoint.sh +# `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 +# 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 +# js-yaml, which the standalone bundle inlines rather than leaving on disk. +# The standalone build copies package.json verbatim, so the script entry is +# already there. +COPY --from=builder /app/scripts/doctor.mjs ./scripts/doctor.mjs +COPY --from=builder /app/scripts/doctor.mts ./scripts/doctor.mts +COPY --from=builder /app/lib/admin/doctor.ts ./lib/admin/doctor.ts +COPY --from=deps /app/node_modules/js-yaml ./node_modules/js-yaml + # Content dir must be writable for admin saves (ownership fixed at runtime) RUN chown -R nextjs:nodejs /app/content diff --git a/README.md b/README.md index d608292..ba804b1 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,10 @@ npm run dev +Something not coming up? `npm run doctor` checks the configuration from the +terminal — no running app and no admin password needed. See +[Config Doctor](docs/deployment.md#config-doctor). + ## First-Run Setup A deployment with no `gallery.yaml` and no Immich credentials serves a setup diff --git a/app/admin/__tests__/systemHealth.test.ts b/app/admin/__tests__/systemHealth.test.ts new file mode 100644 index 0000000..61ed6c7 --- /dev/null +++ b/app/admin/__tests__/systemHealth.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest'; +import { systemHealth, type SystemHealthInput } from '../components/systemHealth'; + +const base: SystemHealthInput = { + statusLoading: false, + setupIncomplete: false, + immich: 'ok', + doctorLevel: null, +}; +const health = (over: Partial = {}) => systemHealth({ ...base, ...over }); + +describe('systemHealth', () => { + it('reports a healthy system', () => { + expect(health()).toEqual({ tone: 'connected', label: 'System OK' }); + }); + + it('stays neutral while loading', () => { + expect(health({ statusLoading: true })).toEqual({ tone: 'unknown', label: 'Checking...' }); + }); + + it('keeps the badge neutral while a stale error is being rechecked', () => { + // The class used to have no loading case, so a doctor error left over from + // the previous run painted the badge red under "Checking..." (#539). + expect(health({ statusLoading: true, immich: 'unknown', doctorLevel: 'error' })).toEqual({ + tone: 'unknown', + label: 'Checking...', + }); + }); + + it('treats an unfinished install as neutral, not as a fault', () => { + expect(health({ setupIncomplete: true, immich: 'error' })).toEqual({ + tone: 'unknown', + label: 'Setup Incomplete', + }); + }); + + it('lets an unreachable Immich outrank a doctor warning', () => { + // The class matched `warn` first and went neutral while the text said + // "System Degraded" — the less serious condition decided the colour (#539). + expect(health({ immich: 'error', doctorLevel: 'warn' })).toEqual({ + tone: 'disconnected', + label: 'System Degraded', + }); + }); + + it('reports a doctor error when Immich itself is fine', () => { + expect(health({ doctorLevel: 'error' })).toEqual({ + tone: 'disconnected', + label: 'Needs Attention', + }); + }); + + it('reports a doctor warning as neutral', () => { + expect(health({ doctorLevel: 'warn' })).toEqual({ + tone: 'unknown', + label: 'Check Diagnostics', + }); + }); + + it('falls back to unknown when Immich is neither ok nor in error', () => { + expect(health({ immich: 'unknown' })).toEqual({ tone: 'unknown', label: 'Status Unknown' }); + }); + + it('never pairs a label with a tone that contradicts it', () => { + const expected: Record = { + 'Checking...': 'unknown', + 'Setup Incomplete': 'unknown', + 'System Degraded': 'disconnected', + 'Needs Attention': 'disconnected', + 'Check Diagnostics': 'unknown', + 'System OK': 'connected', + 'Status Unknown': 'unknown', + }; + for (const statusLoading of [false, true]) { + for (const setupIncomplete of [false, true]) { + for (const immich of ['ok', 'error', 'unknown']) { + for (const doctorLevel of [null, 'ok', 'warn', 'error'] as const) { + const { tone, label } = health({ statusLoading, setupIncomplete, immich, doctorLevel }); + expect(expected[label], `${label} with tone ${tone}`).toBe(tone); + } + } + } + } + }); +}); diff --git a/app/admin/admin.css b/app/admin/admin.css index 1db5b18..81e3c10 100644 --- a/app/admin/admin.css +++ b/app/admin/admin.css @@ -2,4854 +2,32 @@ /* Self-contained — does not affect the public-facing gallery. */ /* Supports light/dark mode via data-theme attribute on . */ -/* ── Theme Variables ───────────────────────────────────────── */ -/* .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; - --admin-bg-elevated: #222222; - --admin-bg-input: #161616; - --admin-border: #2e2e2e; - --admin-border-hover: #444444; - --admin-border-focus: #666666; - --admin-text: #f0f0f0; - --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; - --admin-error-border: #5c2020; - --admin-btn-primary-bg: #ffffff; - --admin-btn-primary-text: #000000; - --admin-btn-primary-hover: #e5e5e5; - --admin-overlay: rgba(0, 0, 0, 0.7); -} - -[data-theme='light'] .admin-layout, -[data-theme='light'] .admin-listbox-popup { - --admin-bg: #f8f8f8; - --admin-bg-raised: #ffffff; - --admin-bg-elevated: #f0f0f0; - --admin-bg-input: #ffffff; - --admin-border: #e0e0e0; - --admin-border-hover: #c0c0c0; - --admin-border-focus: #999999; - --admin-text: #1a1a1a; - --admin-text-secondary: #555555; - --admin-text-muted: #888888; - --admin-accent: #d97706; - --admin-success: #16a34a; - --admin-error: #dc2626; - --admin-error-bg: #fef2f2; - --admin-error-border: #fecaca; - --admin-btn-primary-bg: #1a1a1a; - --admin-btn-primary-text: #ffffff; - --admin-btn-primary-hover: #333333; - --admin-overlay: rgba(0, 0, 0, 0.4); -} - -.admin-layout { - min-height: 100vh; - background: var(--admin-bg); - color: var(--admin-text); - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; -} - -/* ── Loading / Disabled ─────────────────────────────────────── */ - -.admin-loading { - min-height: 100vh; - display: flex; - align-items: center; - justify-content: center; -} - -.admin-spinner { - width: 24px; - height: 24px; - border: 2px solid var(--admin-border); - border-top-color: var(--admin-text); - border-radius: 50%; - animation: spin 0.6s linear infinite; -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -.admin-disabled { - min-height: 100vh; - display: flex; - align-items: center; - justify-content: center; - padding: 2rem; -} - -.admin-disabled-card { - max-width: 420px; - text-align: center; -} - -.admin-disabled-card h1 { - font-size: 1.5rem; - margin-bottom: 0.75rem; -} - -.admin-disabled-card code { - background: var(--admin-bg-raised); - padding: 0.2em 0.5em; - border-radius: 4px; - font-size: 0.85em; - color: var(--admin-accent); -} - -/* ── Login ─────────────────────────────────────────────────── */ - -.admin-login { - min-height: 100vh; - display: flex; - align-items: center; - justify-content: center; - padding: 2rem; -} - -.admin-login-card { - width: 100%; - max-width: 360px; - background: var(--admin-bg-raised); - border: 1px solid var(--admin-border); - border-radius: 12px; - padding: 2rem; -} - -.admin-login-header { - text-align: center; - margin-bottom: 1.5rem; -} - -.admin-login-header h1 { - font-size: 1.25rem; - font-weight: 600; - margin: 0; -} - -.admin-login-header p { - color: var(--admin-text-secondary); - margin: 0.25rem 0 0; - font-size: 0.875rem; -} - -/* ── Fields ─────────────────────────────────────────────────── */ - -.admin-field { - margin-bottom: 0.75rem; -} - -.admin-field label { - display: block; - font-size: 0.75rem; - font-weight: 500; - color: var(--admin-text-secondary); - margin-bottom: 0.3rem; - text-transform: uppercase; - letter-spacing: 0.03em; -} - -.admin-field input, -.admin-field select, -.admin-field textarea { - 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; - transition: border-color 0.15s; -} - -.admin-field input:focus, -.admin-field select:focus, -.admin-field textarea:focus { - outline: none; - border-color: var(--admin-border-focus); -} - -.admin-field textarea { - resize: vertical; - 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; - gap: 0.75rem; -} - -.admin-field-checks { - display: flex; - flex-direction: column; - gap: 0.5rem; - margin: 0.75rem 0; -} - -.admin-field-checks label { - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 0.875rem; - color: var(--admin-text); - cursor: pointer; - text-transform: none; - letter-spacing: 0; -} - -.admin-field-checks input[type='checkbox'] { - width: 16px; - height: 16px; - accent-color: var(--admin-btn-primary-bg); -} - -/* ── Error ─────────────────────────────────────────────────── */ - -.admin-error { - background: var(--admin-error-bg); - border: 1px solid var(--admin-error-border); - color: var(--admin-error); - padding: 0.5rem 0.75rem; - border-radius: 6px; - font-size: 0.8rem; - margin-bottom: 0.75rem; -} - -/* ── Buttons ───────────────────────────────────────────────── */ - -.admin-btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.4rem; - padding: 0 1.2rem; - min-height: var(--admin-control-height); - /* {showStatus && ( diff --git a/app/admin/components/EssayBlockEditor.tsx b/app/admin/components/EssayBlockEditor.tsx index 7282561..669d07d 100644 --- a/app/admin/components/EssayBlockEditor.tsx +++ b/app/admin/components/EssayBlockEditor.tsx @@ -241,13 +241,20 @@ 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 + to strip the tags for display — `` to `**`, everything else + to nothing — while `onChange` wrote the stripped text straight back + into `html`. Italics, links, anything the markdown had produced was + destroyed by the act of editing the paragraph, and the two editors + disagreed about the same file. serializeJournalMarkdown() turns the + HTML back into markdown on save and handles `` too, so there + was nothing to convert here in the first place. */} {block.type === 'paragraph' && (