release: v0.14.0

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.
This commit is contained in:
Ralksta
2026-08-30 15:11:25 +02:00
committed by GitHub
parent a813433891
commit e99d3f2f1b
59 changed files with 8652 additions and 6676 deletions

View File

@@ -9,6 +9,12 @@ on:
# at release time. # at release time.
branches: [main, dev] 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 # Cancel in-progress runs for the same PR / branch
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
@@ -88,3 +94,59 @@ jobs:
done <<< "$unformatted" done <<< "$unformatted"
echo '' >> "$GITHUB_STEP_SUMMARY" echo '' >> "$GITHUB_STEP_SUMMARY"
echo 'Advisory only — this does not block the merge.' >> "$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

View File

@@ -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 Releases up to and including v0.9.2 are documented in the
[GitHub releases](https://github.com/ralksta/immich-folio/releases). [GitHub releases](https://github.com/ralksta/immich-folio/releases).
## [0.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
`<strong>` into `**` and replaced every other tag, `<em>` and `<a>` 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 `<em>` 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 ## [0.13.0] — 2026-08-25
### Added ### 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` 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. 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** - **The Immich description can be switched off**
([#506](https://github.com/ralksta/immich-folio/issues/506)). It was served ([#506](https://github.com/ralksta/immich-folio/issues/506)). It was served
regardless of `exifOnHover`, on the grounds that a caption is editorial rather regardless of `exifOnHover`, on the grounds that a caption is editorial rather

View File

@@ -44,6 +44,7 @@ cp content/gallery.yaml.example content/gallery.yaml
# 4. Run # 4. Run
npm run dev # http://localhost:3000 npm run dev # http://localhost:3000
npm run build # production build check npm run build # production build check
npm run doctor # check the configuration if something looks wrong
``` ```
## Before submitting a PR ## Before submitting a PR

View File

@@ -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 --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
# `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) # Content dir must be writable for admin saves (ownership fixed at runtime)
RUN chown -R nextjs:nodejs /app/content RUN chown -R nextjs:nodejs /app/content

View File

@@ -152,6 +152,10 @@ npm run dev
</details> </details>
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 ## First-Run Setup
A deployment with no `gallery.yaml` and no Immich credentials serves a setup A deployment with no `gallery.yaml` and no Immich credentials serves a setup

View File

@@ -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<SystemHealthInput> = {}) => 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<string, string> = {
'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);
}
}
}
}
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@ import { usePathname } from 'next/navigation';
import BackupManagerModal from './BackupManagerModal'; import BackupManagerModal from './BackupManagerModal';
import DoctorModal from './DoctorModal'; import DoctorModal from './DoctorModal';
import type { DoctorLevel } from '@/lib/admin/doctor'; import type { DoctorLevel } from '@/lib/admin/doctor';
import { systemHealth } from './systemHealth';
import * as Icons from './Icons'; import * as Icons from './Icons';
interface Props { interface Props {
@@ -117,6 +118,15 @@ export default function AdminDashboard({ onLogout, children }: Props) {
// as "System Degraded" with no further hint, which sent operators looking for // as "System Degraded" with no further hint, which sent operators looking for
// a network problem that did not exist (#507). // a network problem that did not exist (#507).
const setupIncomplete = !statusLoading && !statusError && status?.setup?.complete === false; const setupIncomplete = !statusLoading && !statusError && status?.setup?.complete === false;
// Colour and words decided together — they used to be two separately-ordered
// nested ternaries over the same inputs, and disagreed in two states (#539).
const health = systemHealth({
statusLoading,
setupIncomplete,
immich: immichIndicator.className,
doctorLevel,
});
const missingCredentials = status?.setup?.credentials === 'missing'; const missingCredentials = status?.setup?.credentials === 'missing';
return ( return (
@@ -140,15 +150,7 @@ export default function AdminDashboard({ onLogout, children }: Props) {
{/* Diagnostics Badge */} {/* Diagnostics Badge */}
<div className="status-indicator-container"> <div className="status-indicator-container">
<button <button
className={`status-badge-btn ${ className={`status-badge-btn ${health.tone}`}
setupIncomplete || doctorLevel === 'warn'
? 'unknown'
: immichIndicator.className === 'error' || doctorLevel === 'error'
? 'disconnected'
: immichIndicator.className === 'ok'
? 'connected'
: 'unknown'
}`}
onClick={() => { onClick={() => {
setShowStatus(!showStatus); setShowStatus(!showStatus);
if (!showStatus) fetchStatus(); if (!showStatus) fetchStatus();
@@ -156,21 +158,7 @@ export default function AdminDashboard({ onLogout, children }: Props) {
title="Show system status" title="Show system status"
> >
<span className="status-dot"></span> <span className="status-dot"></span>
<span className="status-text"> <span className="status-text">{health.label}</span>
{statusLoading
? 'Checking...'
: setupIncomplete
? 'Setup Incomplete'
: immichIndicator.className === 'error'
? 'System Degraded'
: doctorLevel === 'error'
? 'Needs Attention'
: doctorLevel === 'warn'
? 'Check Diagnostics'
: immichIndicator.className === 'ok'
? 'System OK'
: 'Status Unknown'}
</span>
</button> </button>
{showStatus && ( {showStatus && (

View File

@@ -241,13 +241,20 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
</div> </div>
)} )}
{/* 2. PARAGRAPH BLOCK */} {/* 2. PARAGRAPH BLOCK
The textarea shows `html` raw, as the journal editor does. It used
to strip the tags for display — `<strong>` to `**`, everything else
to nothing — while `onChange` wrote the stripped text straight back
into `html`. Italics, links, anything the markdown had produced was
destroyed by the act of editing the paragraph, and the two editors
disagreed about the same file. serializeJournalMarkdown() turns the
HTML back into markdown on save and handles `<em>` too, so there
was nothing to convert here in the first place. */}
{block.type === 'paragraph' && ( {block.type === 'paragraph' && (
<textarea <textarea
rows={3} rows={3}
value={block.html.replace(/<[^>]+>/g, (t) => value={block.html}
t.startsWith('<strong>') ? '**' : t.startsWith('</strong>') ? '**' : '',
)}
onChange={(e) => handleUpdateBlock(idx, { ...block, html: e.target.value })} onChange={(e) => handleUpdateBlock(idx, { ...block, html: e.target.value })}
placeholder="Write text paragraph... (**bold**, *italic* markdown supported)" placeholder="Write text paragraph... (**bold**, *italic* markdown supported)"
/> />

View File

@@ -25,6 +25,7 @@ import {
} from './Icons'; } from './Icons';
import AssetPicker from './AssetPicker'; import AssetPicker from './AssetPicker';
import { BlockBadge } from './BlockBadge'; import { BlockBadge } from './BlockBadge';
import { useUnsavedGuard } from './useUnsavedGuard';
import { EssayView } from '@/app/[...path]/EssayView'; import { EssayView } from '@/app/[...path]/EssayView';
import type { PhotoItem } from '@/app/[...path]/PhotoGrid'; import type { PhotoItem } from '@/app/[...path]/PhotoGrid';
import './journal-studio.css'; import './journal-studio.css';
@@ -521,6 +522,8 @@ function JournalEditor({ slug, onBack }: JournalEditorProps) {
} }
}; };
useUnsavedGuard(dirty);
// Keyboard shortcut: Cmd+S / Ctrl+S // Keyboard shortcut: Cmd+S / Ctrl+S
useEffect(() => { useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,79 @@
'use client';
import type { ReactNode } from 'react';
type GridOverride = { columns?: number; gap?: number };
const NUMBER_INPUT_STYLE = {
padding: '8px 12px',
borderRadius: '6px',
flex: 1,
fontSize: '0.9rem',
} as const;
/**
* The override editor for one grid: a column count and a gap, both optional,
* both falling back to a wider setting when left empty.
*
* Giving the album covers their own grid (#523) was implemented by copying this
* block — fifty-eight lines whose entire diff was the bounds it clamps to and
* the key it writes (#533). Two editors that have to behave identically had two
* implementations, which is how they drift apart.
*
* A value outside the bounds is discarded rather than clamped, which is what
* the copies did: the field then reads as empty and the setting falls back,
* instead of silently saving a number nobody typed.
*/
export default function GridOverrideFields({
label,
hint,
columnsMin,
columnsMax,
gapMax,
value,
onChange,
}: {
label: string;
hint: ReactNode;
columnsMin: number;
columnsMax: number;
gapMax: number;
value: GridOverride | undefined;
/** Receives the whole next override; the caller normalises and stores it. */
onChange: (next: GridOverride) => void;
}) {
const patch = (key: 'columns' | 'gap', raw: string, min: number, max: number) => {
const n = raw === '' ? undefined : Number(raw);
onChange({
...(value || {}),
[key]: n != null && n >= min && n <= max ? n : undefined,
});
};
return (
<div className="admin-field" style={{ marginTop: '1rem' }}>
<label>{label}</label>
<div style={{ display: 'flex', gap: '12px' }}>
<input
type="number"
min={columnsMin}
max={columnsMax}
value={value?.columns ?? ''}
placeholder="Columns (site default)"
onChange={(e) => patch('columns', e.target.value, columnsMin, columnsMax)}
style={NUMBER_INPUT_STYLE}
/>
<input
type="number"
min={0}
max={gapMax}
value={value?.gap ?? ''}
placeholder="Gap in px (theme default)"
onChange={(e) => patch('gap', e.target.value, 0, gapMax)}
style={NUMBER_INPUT_STYLE}
/>
</div>
<p className="admin-field-hint">{hint}</p>
</div>
);
}

View File

@@ -0,0 +1,85 @@
'use client';
import type { CSSProperties, ReactNode } from 'react';
export type CardOption = { value: string; label: string; desc: string };
/**
* Builds the option list for a picker from its value list and a label map, so
* the values a setting accepts and the words shown for them stay one thing.
*/
export function toOptions(
values: readonly string[],
info: Record<string, { label: string; desc: string }>,
): CardOption[] {
return values.map((value) => ({
value,
label: info[value]?.label ?? value,
desc: info[value]?.desc ?? '',
}));
}
/**
* A labelled grid of picker cards — theme preset, layout, frame, hero style,
* aspect ratio.
*
* All five were written out separately: each had its own `.map()`, its own
* `preset-card` button, its own `active` handling, and its label text buried in
* an object literal a thousand lines from the value list it described (#533).
* The differences between them are the preview drawn on the card and, for the
* theme preset, a few custom properties — so those are the props, and the rest
* is stated once.
*
* The class names are unchanged, so `admin.css` styles these exactly as it
* styled the copies.
*/
export default function OptionGrid({
label,
options,
value,
onSelect,
cardClassName,
renderPreview,
renderSpecs,
cardStyle,
}: {
label: string;
options: CardOption[];
/** The resolved value — the caller applies its own default before passing it. */
value: string;
onSelect: (value: string) => void;
/** Second class on the card, e.g. `frame-card`; `preset-card` is always set. */
cardClassName: string;
renderPreview?: (option: CardOption) => ReactNode;
/** Extra line under the description, used by the theme preset for its specs. */
renderSpecs?: (option: CardOption) => ReactNode;
cardStyle?: (option: CardOption) => CSSProperties | undefined;
}) {
return (
<div className="admin-field">
<label>{label}</label>
<div className="preset-card-grid">
{options.map((option) => {
const isActive = value === option.value;
return (
<button
key={option.value}
type="button"
className={`preset-card ${cardClassName} ${isActive ? 'active' : ''}`}
onClick={() => onSelect(option.value)}
style={cardStyle?.(option)}
aria-pressed={isActive}
>
{renderPreview?.(option)}
<div className="preset-card-info">
<span className="preset-card-name">{option.label}</span>
<span className="preset-card-desc">{option.desc}</span>
{renderSpecs?.(option)}
</div>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
'use client';
import type { ReactNode } from 'react';
/**
* A switch rendered as a card — the panel's top-level on/off control.
*
* The markup used to be written out at every call site: fourteen copies of the
* same eighteen lines, differing only in the label and which setting they read
* (#533). Changing how a switch looked meant editing it fourteen times, and the
* copies had already drifted apart in their line wrapping. The class names are
* unchanged, so `admin.css` styles this exactly as it styled the copies.
*
* `SettingRow` is the lighter sibling for switches *inside* a group panel; this
* is the card that sits at the top level of a section.
*/
export default function ToggleCard({
icon,
title,
description,
checked,
onToggle,
}: {
icon: ReactNode;
title: ReactNode;
description: ReactNode;
checked: boolean;
onToggle: () => void;
}) {
return (
<button
type="button"
className={`admin-toggle-card ${checked ? 'active' : ''}`}
onClick={onToggle}
aria-pressed={checked}
>
<div className="toggle-card-info">
<span className="toggle-card-title">
{icon} {title}
</span>
<span className="toggle-card-desc">{description}</span>
</div>
<div className={`switch-toggle ${checked ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
);
}

View File

@@ -0,0 +1,138 @@
'use client';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { DEFAULT_ALBUM_SORT } from '@/lib/albumSort';
import { IconCamera, IconGripVertical, IconImage, IconLock, IconPencil, IconTrash } from '../Icons';
import { SORT_LABELS } from './sortOptions';
import type { AlbumEntry } from './types';
export function SortableAlbumCard({
album,
index,
name,
count,
thumbnailId,
onRemove,
onEdit,
}: {
album: AlbumEntry;
index: number;
name: string;
count: number;
thumbnailId: string | null;
onRemove: () => void;
onEdit: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: `album-${album.id}-${index}`,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 10 : undefined,
};
return (
<div ref={setNodeRef} style={style} {...attributes}>
<AlbumCard
album={album}
name={name}
count={count}
thumbnailId={thumbnailId}
onRemove={onRemove}
onEdit={onEdit}
dragListeners={listeners}
/>
</div>
);
}
interface AlbumCardProps {
album: AlbumEntry;
name: string;
count: number;
thumbnailId: string | null;
onRemove: () => void;
onEdit: () => void;
dragListeners?: Record<string, unknown>;
}
export default function AlbumCard({
album,
name,
count,
thumbnailId,
onRemove,
onEdit,
dragListeners,
}: AlbumCardProps) {
const heroThumb = album.heroImage || thumbnailId;
const hasPassword = !!album.password;
const hasTitleOverride = !!album.title;
return (
<div className={`album-tile ${hasPassword ? 'has-password' : ''}`}>
<div className="album-tile-cover">
{dragListeners && (
<div className="album-tile-drag" {...dragListeners} title="Drag to reorder">
<IconGripVertical size={18} className="svg-icon svg-drag" />
</div>
)}
{heroThumb ? (
<img src={`/api/admin/thumbnail/${heroThumb}`} alt="" loading="lazy" />
) : (
<div className="album-tile-placeholder">
<IconCamera />
</div>
)}
<div className="album-tile-overlay">
<button className="album-tile-btn" onClick={onEdit} title="Edit details">
<IconPencil size={14} />
</button>
<button
className="album-tile-btn album-tile-btn-danger"
onClick={onRemove}
title="Remove album"
>
<IconTrash size={14} />
</button>
</div>
</div>
<div className="album-tile-info">
<div className="album-tile-title-row">
<span
className={`album-tile-name ${hasTitleOverride ? 'custom-title' : ''}`}
title={album.title || name}
>
{album.title || name}
</span>
<div className="album-tile-badges">
{hasPassword && (
<span className="badge badge-password" title="Password protected">
<IconLock size={12} />
</span>
)}
{album.heroImage && (
<span className="badge badge-hero" title="Custom Hero Image set">
<IconImage size={12} />
</span>
)}
{album.sort && album.sort !== DEFAULT_ALBUM_SORT && (
<span className="badge badge-sort" title={`Photo order: ${SORT_LABELS[album.sort]}`}>
{album.sort === 'manual' ? (
<IconGripVertical size={18} className="svg-icon svg-drag" />
) : (
album.sort
)}
</span>
)}
</div>
</div>
<span className="album-tile-count">{count} photos</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,250 @@
'use client';
import { DEFAULT_ALBUM_SORT } from '@/lib/albumSort';
import { IconCamera, IconCopy, IconGripVertical, IconImage, IconTrash, IconX } from '../Icons';
import { Listbox } from '../Listbox';
import { SORT_OPTIONS } from './sortOptions';
import type { AlbumEntry, HeroPickerTarget, OrderEditorTarget } from './types';
interface AlbumDrawerProps {
album: AlbumEntry;
name: string;
count: number;
thumbnailId: string | null;
onUpdate: (updates: Partial<AlbumEntry>) => void;
onRemove: () => void;
onClose: () => void;
onPickHero: (target: HeroPickerTarget | null) => void;
onEditOrder: (target: OrderEditorTarget | null) => void;
}
/**
* Per-album settings, opened from a card in the builder or from inside the
* subpage drawer. Which album it edits — and how an edit is written back —
* is resolved by the caller and arrives as `onUpdate`/`onRemove`.
*/
export default function AlbumDrawer({
album,
name,
count,
thumbnailId,
onUpdate,
onRemove,
onClose,
onPickHero,
onEditOrder,
}: AlbumDrawerProps) {
const heroThumb = album.heroImage || thumbnailId;
return (
<div className="album-drawer-overlay open" onClick={() => onClose()}>
<div className="album-drawer" onClick={(e) => e.stopPropagation()}>
<div className="album-drawer-header">
<h3>
<IconCamera /> Edit Album Details
</h3>
<button className="admin-btn-icon" onClick={() => onClose()} title="Close details">
<IconX size={14} />
</button>
</div>
{/* One scrolling column: cover banner, then the form. Anything the
form already shows (password state, custom hero) is not
repeated as a read-only stat. */}
<div className="album-drawer-body">
<div className="admin-sheet-columns">
<div className="admin-sheet-col">
<div className="modal-cover-container">
{heroThumb ? (
<img src={`/api/admin/thumbnail/${heroThumb}`} alt="" loading="lazy" />
) : (
<div className="modal-cover-placeholder">
<IconCamera />
</div>
)}
</div>
<div className="modal-meta-box">
<span className="modal-album-title">{album.title || name}</span>
<span className="modal-album-subtitle">
{count} {count === 1 ? 'photo' : 'photos'}
{album.title ? ` · Original: ${name}` : ''}
</span>
</div>
<div className="admin-field">
<label>Title override</label>
<input
value={album.title || ''}
onChange={(e) => onUpdate({ title: e.target.value || undefined })}
placeholder={name}
/>
</div>
<div className="admin-field">
<label>Description</label>
<textarea
value={album.description || ''}
onChange={(e) => onUpdate({ description: e.target.value || undefined })}
placeholder="Optional description for visitors"
rows={4}
/>
</div>
<div className="admin-field">
<label>Password protection</label>
<div className="input-with-icon">
<input
type="password"
value={album.password || ''}
onChange={(e) => onUpdate({ password: e.target.value || undefined })}
placeholder="Leave empty for public access"
/>
</div>
</div>
</div>
<div className="admin-sheet-col admin-sheet-col--divided">
<div className="admin-field">
<label>Layout override (Experimental)</label>
<select
value={album.grid?.layout || ''}
onChange={(e) => {
const layout = e.target.value || undefined;
// Only the layout is exposed here; drop the grid object
// entirely when it goes back to "inherit" so the YAML
// stays clean.
onUpdate({
grid: layout ? { ...(album.grid || {}), layout } : undefined,
});
}}
>
<option value="">Inherit from page / global settings</option>
<option value="masonry">Masonry</option>
<option value="uniform">Uniform Grid</option>
<option value="showcase">Showcase</option>
<option value="filmstrip">Filmstrip</option>
<option value="editorial-flow">Editorial Flow</option>
<option value="justified">Justified (Experimental)</option>
</select>
</div>
<div className="admin-field">
<label>Cover focal point (Experimental)</label>
<input
value={album.coverPosition || ''}
onChange={(e) => onUpdate({ coverPosition: e.target.value || undefined })}
placeholder={'e.g. "50% 25%" or "top" — where the cover crop should anchor'}
/>
</div>
{/* Hero Image Selection */}
<div className="admin-field">
<label>Custom Hero Image</label>
<div className="album-hero-field">
{album.heroImage ? (
<div className="album-hero-preview">
<img src={`/api/admin/thumbnail/${album.heroImage}`} alt="Hero" />
<button
className="album-hero-remove"
onClick={() => onUpdate({ heroImage: undefined })}
title="Remove custom hero image"
>
<IconX size={14} />
</button>
</div>
) : (
<span className="album-hero-empty">Using default album cover</span>
)}
<button
className="admin-btn admin-btn-sm"
onClick={() =>
onPickHero({
albumId: album.id,
onSelect: (assetId) => {
onUpdate({ heroImage: assetId });
onPickHero(null);
},
currentAssetIds: album.heroImage ? [album.heroImage] : [],
title: `Pick Hero Image for ${name}`,
})
}
>
<IconImage size={12} /> {album.heroImage ? 'Change Image' : 'Pick Hero Image'}
</button>
</div>
</div>
{/* Photo order */}
<div className="admin-field">
<label id="album-sort-label">Photo order</label>
<Listbox
labelledBy="album-sort-label"
value={album.sort || DEFAULT_ALBUM_SORT}
options={SORT_OPTIONS}
onChange={(mode) => onUpdate({ sort: mode })}
/>
<span className="admin-field-hint">
{album.sort === 'manual'
? 'Pinned photos come first, in the order you set. Everything else follows in the album’s Immich order.'
: 'Immich order follows the album’s own sort setting in Immich.'}
</span>
{album.sort === 'manual' && (
<div style={{ marginTop: '0.75rem' }}>
<button
className="admin-btn admin-btn-sm"
onClick={() =>
onEditOrder({
albumId: album.id,
albumName: album.title || name,
assetOrder: album.assetOrder || [],
onSave: (assetOrder) => {
onUpdate({ assetOrder });
onEditOrder(null);
},
})
}
>
<IconGripVertical size={18} className="svg-icon svg-drag" />{' '}
{album.assetOrder?.length
? `Reorder photos (${album.assetOrder.length} pinned)`
: 'Reorder photos'}
</button>
</div>
)}
</div>
</div>
</div>
</div>
<div className="album-drawer-footer">
<button
className="admin-btn admin-btn-ghost admin-btn-danger"
onClick={onRemove}
style={{ maxWidth: '160px' }}
>
<IconTrash size={14} /> Remove Album
</button>
<div className="uuid-copy-box" title="Immich album UUID">
<code>{album.id}</code>
<button
className="uuid-copy-btn"
onClick={() => {
navigator.clipboard.writeText(album.id);
}}
title="Copy UUID"
aria-label="Copy Immich album UUID"
>
<IconCopy size={12} />
</button>
</div>
<button
className="admin-btn admin-btn-primary"
onClick={() => onClose()}
style={{ maxWidth: '140px' }}
>
Done
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,576 @@
'use client';
import {
DndContext,
closestCenter,
type DragEndEvent,
type SensorDescriptor,
type SensorOptions,
} from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { EssayBlockEditor } from '../EssayBlockEditor';
import GridOverrideFields from '../fields/GridOverrideFields';
import {
IconBan,
IconBook,
IconEyeOff,
IconGlobe,
IconLock,
IconPencil,
IconSearch,
IconTrash,
IconX,
} from '../Icons';
import AlbumCard, { SortableAlbumCard } from './AlbumCard';
import SubpagePreview from './SubpagePreview';
import {
normalizeSubpageGrid,
type ActiveEditAlbumAddress,
type HeroPickerTarget,
type ImmichAlbumInfo,
type PickerTarget,
type Section,
type Subpage,
} from './types';
import {
COVER_GRID_COLUMNS_MAX,
COVER_GRID_COLUMNS_MIN,
COVER_GRID_GAP_MAX,
PHOTO_GRID_COLUMNS_MAX,
PHOTO_GRID_COLUMNS_MIN,
PHOTO_GRID_GAP_MAX,
slugify,
} from '@/lib/config/schema';
interface SubpageDrawerProps {
sp: Subpage;
spIndex: number;
immichAlbums: ImmichAlbumInfo[];
sensors: SensorDescriptor<SensorOptions>[];
drawerMode: 'edit' | 'preview';
onDrawerModeChange: (mode: 'edit' | 'preview') => void;
onClose: () => void;
updateSubpage: (index: number, updates: Partial<Subpage>) => void;
removeSubpage: (index: number) => void;
addSection: (subpageIndex: number) => void;
removeSection: (subpageIndex: number, sectionIndex: number) => void;
updateSection: (subpageIndex: number, sectionIndex: number, updates: Partial<Section>) => void;
removeSubpageAlbum: (subpageIndex: number, albumIndex: number) => void;
removeSectionAlbum: (subpageIndex: number, sectionIndex: number, albumIndex: number) => void;
onAlbumDragEnd: (event: DragEndEvent) => void;
onPickAlbum: (target: PickerTarget | null) => void;
onEditAlbum: (address: ActiveEditAlbumAddress | null) => void;
onPickHero: (target: HeroPickerTarget | null) => void;
getAlbumName: (id: string) => string;
getAlbumCount: (id: string) => number;
getAlbumThumbnailId: (id: string) => string | null;
}
/**
* The subpage sheet: metadata, layout, and the albums on one page.
*
* The gallery state stays in PageBuilder — this drawer renders the subpage it
* is handed and reports every edit back through the callbacks above, so it
* changes nothing on its own.
*/
export default function SubpageDrawer({
sp,
spIndex,
immichAlbums,
sensors,
drawerMode,
onDrawerModeChange,
onClose,
updateSubpage,
removeSubpage,
addSection,
removeSection,
updateSection,
removeSubpageAlbum,
removeSectionAlbum,
onAlbumDragEnd,
onPickAlbum,
onEditAlbum,
onPickHero,
getAlbumName,
getAlbumCount,
getAlbumThumbnailId,
}: SubpageDrawerProps) {
return (
<div className="subpage-drawer-backdrop" onClick={() => onClose()}>
<div className="subpage-drawer-container" onClick={(e) => e.stopPropagation()}>
<div className="subpage-drawer-header">
<div className="subpage-drawer-title-group">
<span className="subpage-drawer-breadcrumb">Pages / Subpages</span>
<h3 className="subpage-drawer-title">{sp.title || sp.name || 'Untitled Page'}</h3>
</div>
<div className="subpage-drawer-header-actions">
<div className="segmented-control" style={{ padding: '2px' }}>
<button
type="button"
className={`segment-btn ${drawerMode === 'edit' ? 'active' : ''}`}
onClick={() => onDrawerModeChange('edit')}
style={{ padding: '0.2rem 0.6rem', fontSize: '0.75rem' }}
>
<IconPencil size={14} /> Edit
</button>
<button
type="button"
className={`segment-btn ${drawerMode === 'preview' ? 'active' : ''}`}
onClick={() => onDrawerModeChange('preview')}
style={{ padding: '0.2rem 0.6rem', fontSize: '0.75rem' }}
>
<IconSearch size={14} /> Live Preview
</button>
</div>
<a
href={`/${slugify(sp.name)}?fresh=1`}
target="_blank"
rel="noopener noreferrer"
className="admin-btn admin-btn-xs admin-btn-ghost"
title="Open live page in new tab (bypassing cache)"
>
/{slugify(sp.name)} ↗
</a>
<button className="admin-btn-icon" onClick={() => onClose()} title="Close drawer">
<IconX size={14} />
</button>
</div>
</div>
<div className="subpage-drawer-body">
{drawerMode === 'preview' ? (
<SubpagePreview sp={sp} immichAlbums={immichAlbums} />
) : (
<div className="admin-sheet-columns admin-sheet-columns--settings-aside">
<div className="admin-sheet-col">
<div className="subpage-drawer-section">
<div className="admin-field">
<label>Page Name (URL Identifier)</label>
<div className="input-slug-wrapper">
<input
className="subpage-name-input"
value={sp.name}
onChange={(e) => updateSubpage(spIndex, { name: e.target.value })}
placeholder="e.g. dubai, travel, weddings"
/>
<span className="input-slug-preview">/{slugify(sp.name)}</span>
</div>
</div>
</div>
{/* Subpage metadata */}
<div className="subpage-drawer-section">
<div className="admin-field-row">
<div className="admin-field">
<label>Display Title (optional)</label>
<input
value={sp.title || ''}
onChange={(e) =>
updateSubpage(spIndex, { title: e.target.value || undefined })
}
placeholder="Display title (defaults to name)"
/>
</div>
<div className="admin-field">
<label>Subtitle</label>
<input
value={sp.subtitle || ''}
onChange={(e) =>
updateSubpage(spIndex, {
subtitle: e.target.value || undefined,
})
}
placeholder="Subtitle text"
/>
</div>
</div>
<div className="admin-field" style={{ marginTop: '1rem' }}>
<label>Visibility &amp; Access</label>
{/*
One field, three states — enabled/hidden are two YAML flags,
but for the owner it is a single question: how visible is
this page? Splitting it across two toggles produced
contradictory copy ("Disabled (Hidden)" vs "Unlisted").
*/}
{(
[
{
key: 'published',
active: sp.enabled !== false && sp.hidden !== true,
icon: <IconGlobe size={15} />,
iconColor: '#4ade80',
title: 'Published',
desc: 'Shown in the header menu and homepage lists, reachable via URL',
patch: { enabled: true, hidden: false },
},
{
key: 'unlisted',
active: sp.enabled !== false && sp.hidden === true,
icon: <IconEyeOff size={15} />,
iconColor: '#fbbf24',
title: 'Unlisted (Experimental)',
desc: 'Not shown in menus or on the homepage, but reachable via direct link',
patch: { enabled: true, hidden: true },
},
{
key: 'disabled',
active: sp.enabled === false,
icon: <IconBan size={15} />,
iconColor: '#f87171',
title: 'Disabled',
desc: 'Completely offline — returns 404 even when accessed directly',
patch: { enabled: false, hidden: false },
},
] as const
).map((opt) => (
<button
key={opt.key}
type="button"
className={`admin-toggle-card ${opt.active ? 'active' : ''}`}
onClick={() => updateSubpage(spIndex, opt.patch)}
aria-pressed={opt.active}
style={{
padding: '10px 14px',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
borderRadius: '8px',
cursor: 'pointer',
marginBottom: '6px',
}}
>
<div className="toggle-card-info" style={{ textAlign: 'left' }}>
<span
className="toggle-card-title"
style={{
fontWeight: 600,
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
}}
>
<span
style={{ color: opt.iconColor, display: 'inline-flex' }}
aria-hidden="true"
>
{opt.icon}
</span>
{opt.title}
</span>
<span
className="toggle-card-desc"
style={{
fontSize: '0.8rem',
display: 'block',
opacity: 0.75,
}}
>
{opt.desc}
</span>
</div>
<div className={`switch-toggle ${opt.active ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
))}
{/* Password lives in the same group: it is the access half
of "who gets to see this page". Applies to Published and
Unlisted; a Disabled page 404s before the gate. */}
<div className="password-input-wrapper" style={{ marginTop: '6px' }}>
<span className="password-icon">
<IconLock size={12} />
</span>
<input
type="password"
value={sp.password || ''}
onChange={(e) =>
updateSubpage(spIndex, {
password: e.target.value || undefined,
})
}
placeholder="Password protection (optional) — leave empty for public access"
disabled={sp.enabled === false}
/>
</div>
</div>
<div className="admin-field" style={{ marginTop: '1rem' }}>
<label>Page Layout Style</label>
<select
value={sp.grid?.layout || 'masonry'}
onChange={(e) => {
const newLayout = e.target.value;
updateSubpage(spIndex, {
grid: { ...(sp.grid || {}), layout: newLayout },
});
}}
style={{
padding: '8px 12px',
borderRadius: '6px',
width: '100%',
fontSize: '0.9rem',
}}
>
{/*
Plain text, no emoji: an <option> cannot contain markup, so the
SVG icon set is not available here — and a decorative emoji is
not a substitute for it.
*/}
<option value="masonry">Masonry Grid (Dynamic Height)</option>
<option value="uniform">Uniform Grid (Square Tiles)</option>
<option value="showcase">Showcase (Featured First Asset)</option>
<option value="filmstrip">Filmstrip (Horizontal Scroll)</option>
<option value="editorial-flow">Editorial Flow</option>
<option value="essay">Photo Essay Mode (Storytelling Editor)</option>
</select>
</div>
{/* Album cover grid — hidden in essay mode, which
renders a story instead of a cover grid. The
album count is deliberately not checked: it
changes in this very drawer, and a field that
vanishes while you edit is worse than one that
has no effect yet. */}
{sp.grid?.layout !== 'essay' && sp.essayText == null && (
<GridOverrideFields
label="Album Cover Grid"
hint={
<>
Overrides how the album covers are tiled on this page — the photos inside
the albums are not affected. Leave the column count empty to follow the
site-wide grid setting, and the gap empty to keep the theme&apos;s own
spacing. Tablet widths show at most two covers per row.
</>
}
columnsMin={COVER_GRID_COLUMNS_MIN}
columnsMax={COVER_GRID_COLUMNS_MAX}
gapMax={COVER_GRID_GAP_MAX}
value={sp.coverGrid}
onChange={(next) =>
updateSubpage(spIndex, {
coverGrid: normalizeSubpageGrid(next),
})
}
/>
)}
{/* Photo grid — the albums opened from this page.
Separate from the cover grid above: one field
used to drive both, so a cover gap retuned
every photo grid on the page (#523). */}
{sp.essayText == null && (
<GridOverrideFields
label="Photo Grid (Albums On This Page)"
hint={
<>
How the photos inside every album on this page are laid out. Leave both
empty to follow Settings &rsaquo; Grid; a single album can still override
this in gallery.yaml.
</>
}
columnsMin={PHOTO_GRID_COLUMNS_MIN}
columnsMax={PHOTO_GRID_COLUMNS_MAX}
gapMax={PHOTO_GRID_GAP_MAX}
value={sp.grid}
onChange={(next) =>
updateSubpage(spIndex, { grid: normalizeSubpageGrid(next) })
}
/>
)}
</div>
{/* Visual Photo Essay Builder (if layout === 'essay') */}
{(sp.grid?.layout === 'essay' || sp.essayText != null) && (
<div className="subpage-drawer-section">
<div className="drawer-section-heading">
<h4>
<IconBook size={16} /> Storytelling &amp; Photo Essay Builder
</h4>
<p>Compose text, quotes, and fullbleed/wide photos visually.</p>
</div>
<EssayBlockEditor
markdown={sp.essayText || ''}
onChange={(newMarkdown) => updateSubpage(spIndex, { essayText: newMarkdown })}
onSelectPhoto={(callback) => {
onPickHero({
albumId: undefined,
title: 'Select Photo for Photo Essay',
onSelect: (assetId) => {
callback(assetId);
onPickHero(null);
},
});
}}
/>
</div>
)}
</div>
<div className="admin-sheet-col admin-sheet-col--divided">
{/* Albums (if no sections) with DnD */}
{(!sp.sections || sp.sections.length === 0) && (
<div className="subpage-albums">
<div className="subpage-albums-header">
<span>Albums in this Page</span>
<button
className="admin-btn admin-btn-xs admin-btn-primary"
onClick={() => onPickAlbum({ type: 'subpage', subpageIndex: spIndex })}
>
+ Add Album
</button>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onAlbumDragEnd}
>
<SortableContext
items={sp.albums.map((a, i) => `album-${a.id}-${i}`)}
strategy={verticalListSortingStrategy}
>
<div className="album-list">
{sp.albums.length === 0 && (
<p className="empty-hint">
No albums added yet. Click + Add Album to pick from Immich.
</p>
)}
{sp.albums.map((album, aIndex) => (
<SortableAlbumCard
key={`${album.id}-${aIndex}`}
album={album}
index={aIndex}
name={getAlbumName(album.id)}
count={getAlbumCount(album.id)}
thumbnailId={getAlbumThumbnailId(album.id)}
onRemove={() => removeSubpageAlbum(spIndex, aIndex)}
onEdit={() =>
onEditAlbum({
type: 'subpage',
subpageIndex: spIndex,
albumIndex: aIndex,
})
}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
)}
{/* Sections */}
{sp.sections && sp.sections.length > 0 && (
<div className="subpage-sections">
{sp.sections.map((sec, secIndex) => (
<div key={secIndex} className="section-card">
<div className="section-header">
<input
className="section-title-input"
value={sec.title}
onChange={(e) =>
updateSection(spIndex, secIndex, {
title: e.target.value,
})
}
placeholder="Section title"
/>
<button
className="admin-btn-icon"
onClick={() => removeSection(spIndex, secIndex)}
title="Remove section"
>
<IconX size={14} />
</button>
</div>
<div className="admin-field">
<input
value={sec.description || ''}
onChange={(e) =>
updateSection(spIndex, secIndex, {
description: e.target.value || undefined,
})
}
placeholder="Section description (optional)"
className="section-desc-input"
/>
</div>
<div className="album-list">
{sec.albums.map((album, aIndex) => (
<AlbumCard
key={`${album.id}-${aIndex}`}
album={album}
name={getAlbumName(album.id)}
count={getAlbumCount(album.id)}
thumbnailId={getAlbumThumbnailId(album.id)}
onRemove={() => removeSectionAlbum(spIndex, secIndex, aIndex)}
onEdit={() =>
onEditAlbum({
type: 'section',
subpageIndex: spIndex,
sectionIndex: secIndex,
albumIndex: aIndex,
})
}
/>
))}
</div>
<button
className="admin-btn admin-btn-xs"
onClick={() =>
onPickAlbum({
type: 'section',
subpageIndex: spIndex,
sectionIndex: secIndex,
})
}
>
+ Add Album
</button>
</div>
))}
</div>
)}
<div className="subpage-actions" style={{ marginTop: '1rem' }}>
<button
className="admin-btn admin-btn-xs admin-btn-secondary"
onClick={() => addSection(spIndex)}
>
+ Add Section
</button>
{sp.sections && sp.sections.length > 0 && (
<button
className="admin-btn admin-btn-xs admin-btn-secondary"
onClick={() => onPickAlbum({ type: 'subpage', subpageIndex: spIndex })}
>
+ Add Loose Album
</button>
)}
</div>
</div>
</div>
)}
</div>
<div className="subpage-drawer-footer">
<button
className="admin-btn admin-btn-danger admin-btn-sm"
onClick={() => {
removeSubpage(spIndex);
onClose();
}}
>
<IconTrash size={14} /> Delete Subpage
</button>
<button className="admin-btn admin-btn-primary admin-btn-sm" onClick={() => onClose()}>
Done
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,77 @@
'use client';
import { IconFolder } from '../Icons';
import type { ImmichAlbumInfo, Subpage } from './types';
interface SubpagePreviewProps {
sp: Subpage;
immichAlbums: ImmichAlbumInfo[];
}
/** The drawer's read-only "Live Preview" tab: hero banner, sections, covers. */
export default function SubpagePreview({ sp, immichAlbums }: SubpagePreviewProps) {
return (
<div className="drawer-live-preview-panel">
<div className="preview-hero-banner">
<h2 className="preview-hero-title">{sp.title || sp.name || 'Untitled Page'}</h2>
{sp.subtitle && <p className="preview-hero-sub">{sp.subtitle}</p>}
</div>
{sp.sections && sp.sections.length > 0 ? (
sp.sections.map((sec, sIdx) => (
<div key={sIdx} className="preview-section-group">
<h3 className="preview-section-title">{sec.title || 'Untitled Section'}</h3>
{sec.description && <p className="preview-section-desc">{sec.description}</p>}
<div className="preview-albums-grid">
{sec.albums.map((alb, aIdx) => {
const immichAlb = immichAlbums.find((a) => a.id === alb.id);
const thumb = alb.heroImage || immichAlb?.thumbnailAssetId;
return (
<div key={aIdx} className="preview-album-tile">
<div className="preview-album-cover">
{thumb ? (
<img src={`/api/admin/thumbnail/${thumb}`} alt="" />
) : (
<div className="subpage-tile-placeholder">
<IconFolder />
</div>
)}
</div>
<span className="preview-album-title">
{alb.title || immichAlb?.albumName || alb.id}
</span>
</div>
);
})}
</div>
</div>
))
) : (
<div className="preview-albums-grid">
{sp.albums.map((alb, aIdx) => {
const immichAlb = immichAlbums.find((a) => a.id === alb.id);
const thumb = alb.heroImage || immichAlb?.thumbnailAssetId;
return (
<div key={aIdx} className="preview-album-tile">
<div className="preview-album-cover">
{thumb ? (
<img src={`/api/admin/thumbnail/${thumb}`} alt="" />
) : (
<div className="subpage-tile-placeholder">
<IconFolder />
</div>
)}
</div>
<span className="preview-album-title">
{alb.title || immichAlb?.albumName || alb.id}
</span>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,43 @@
'use client';
import type { ReactNode } from 'react';
import { ALBUM_SORT_MODES, type AlbumSortMode } from '@/lib/albumSort';
import {
IconArrowDown,
IconArrowUp,
IconCalendar,
IconGripVertical,
IconSortAlpha,
} from '../Icons';
import type { ListboxOption } from '../Listbox';
/**
* Labels for the per-album sort control.
*
* Kept as plain text on their own because they also feed the `title` attribute
* on the album card's sort badge, where markup is not an option.
*/
export const SORT_LABELS: Record<AlbumSortMode, string> = {
immich: 'Immich order (default)',
newest: 'Newest first',
oldest: 'Oldest first',
filename: 'Filename',
manual: 'Manual',
};
/** Manual reuses the drag glyph so the control matches the card's badge. */
export const SORT_ICONS: Record<AlbumSortMode, ReactNode> = {
immich: <IconCalendar />,
newest: <IconArrowDown />,
oldest: <IconArrowUp />,
filename: <IconSortAlpha />,
manual: <IconGripVertical />,
};
export const SORT_OPTIONS: readonly ListboxOption<AlbumSortMode>[] = ALBUM_SORT_MODES.map(
(mode) => ({
value: mode,
label: SORT_LABELS[mode],
icon: SORT_ICONS[mode],
}),
);

View File

@@ -0,0 +1,106 @@
import type { AlbumSortMode } from '@/lib/albumSort';
export interface AlbumEntry {
id: string;
title?: string;
description?: string;
password?: string;
heroImage?: string;
sort?: AlbumSortMode;
/** Pinned asset UUIDs for `sort: manual`; everything else follows automatically. */
assetOrder?: string[];
/** EXPERIMENTAL: per-album grid override (layout only in the UI for now) */
grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string };
/** EXPERIMENTAL: focal point for the cover crop, e.g. "50% 25%" or "top" */
coverPosition?: string;
}
export interface Section {
title: string;
description?: string;
albums: AlbumEntry[];
}
export interface Subpage {
name: string;
title?: string;
subtitle?: string;
password?: string;
enabled?: boolean;
/** EXPERIMENTAL: reachable by direct link, but not shown in navigation */
hidden?: boolean;
essayText?: string;
essayFile?: string;
sections?: Section[];
albums: AlbumEntry[];
/** Photo grid for the albums on this page (and the page layout style). */
grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string };
/** The album-cover tiles on this page only (#523). */
coverGrid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string };
}
/**
* The cover grid a subpage renders with when its gallery.yaml predates #523.
*
* Back then one `grid` key sized both the covers and the photos, so the values
* a user typed into "Album Cover Grid" live there. Seeding the new field from
* them means the drawer shows what the page actually renders, and the first
* save writes the split out explicitly. Only the two keys the cover grid reads
* are carried over — `layout` and `aspectRatio` never reached the covers.
*/
export function seedCoverGrid(grid: Subpage['grid']): Subpage['coverGrid'] {
if (!grid) return undefined;
return normalizeSubpageGrid({ columns: grid.columns, gap: grid.gap });
}
/**
* Drops keys the editor cleared so an emptied field never persists as `null`
* in gallery.yaml, and returns undefined once nothing is left — `handleSave`
* only writes `grid` when it is truthy.
*/
export function normalizeSubpageGrid(grid: Subpage['grid']): Subpage['grid'] {
if (!grid) return undefined;
const next = { ...grid };
for (const key of Object.keys(next) as Array<keyof typeof next>) {
const value = next[key];
if (value === undefined || value === '') delete next[key];
}
return Object.keys(next).length > 0 ? next : undefined;
}
export interface ActiveEditAlbumAddress {
type: 'standalone' | 'subpage' | 'section';
subpageIndex?: number;
sectionIndex?: number;
albumIndex: number;
}
export interface ImmichAlbumInfo {
id: string;
albumName: string;
description: string;
thumbnailAssetId: string | null;
assetCount: number;
isConfigured: boolean;
}
/** Where the album picker inserts the album it returns. */
export interface PickerTarget {
type: 'standalone' | 'subpage' | 'section';
subpageIndex?: number;
sectionIndex?: number;
}
export interface HeroPickerTarget {
albumId?: string;
onSelect: (assetId: string) => void;
currentAssetIds?: string[];
title?: string;
}
export interface OrderEditorTarget {
albumId: string;
albumName: string;
assetOrder: string[];
onSave: (assetOrder: string[]) => void;
}

View File

@@ -0,0 +1,48 @@
import type { DoctorLevel } from '@/lib/admin/doctor';
/** The three tones `status-badge-btn` is styled for. */
export type BadgeTone = 'connected' | 'disconnected' | 'unknown';
export interface SystemHealthInput {
statusLoading: boolean;
setupIncomplete: boolean;
/** `immichIndicator.className` — 'ok', 'error' or 'unknown'. */
immich: string;
doctorLevel: DoctorLevel | null;
}
/**
* What the dashboard's status badge should say, and in which colour.
*
* The two used to be decided separately: a four-level nested ternary for the
* class and a six-level one for the text, over the same four inputs but in a
* different order. They disagreed in exactly two states (#539):
*
* - Immich unreachable while the doctor also warns — the class matched `warn`
* first and painted the badge neutral, while the text reported "System
* Degraded". The more serious of the two conditions decided the words and the
* less serious one decided the colour.
* - A refetch while a previous run had reported an error — the class had no
* loading case at all, so the badge stayed red under "Checking...".
*
* The text's ordering wins here, because it is the one that reads most-severe
* first and the only one that accounted for loading. Deciding both together is
* the point: a third condition can no longer be added to one and forgotten in
* the other.
*/
export function systemHealth({
statusLoading,
setupIncomplete,
immich,
doctorLevel,
}: SystemHealthInput): { tone: BadgeTone; label: string } {
if (statusLoading) return { tone: 'unknown', label: 'Checking...' };
// An installation that was never finished is not a fault, so it stays neutral
// rather than red — the same reasoning that gave it its own label (#507).
if (setupIncomplete) return { tone: 'unknown', label: 'Setup Incomplete' };
if (immich === 'error') return { tone: 'disconnected', label: 'System Degraded' };
if (doctorLevel === 'error') return { tone: 'disconnected', label: 'Needs Attention' };
if (doctorLevel === 'warn') return { tone: 'unknown', label: 'Check Diagnostics' };
if (immich === 'ok') return { tone: 'connected', label: 'System OK' };
return { tone: 'unknown', label: 'Status Unknown' };
}

View File

@@ -0,0 +1,29 @@
'use client';
import { useEffect } from 'react';
/**
* Asks the browser to confirm before leaving while `dirty` is true.
*
* The page builder and the settings editor each carried their own copy of this
* effect, byte for byte. The journal studio tracked `dirty` and had none, so
* closing the tab mid-entry lost the work without a word while the same action
* in the other two editors asked first (#538). A third copy would have been the
* third chance to forget it.
*
* `returnValue` is set as well as `preventDefault()`: the property is what older
* browsers read, and Safari still needs it. No custom string is passed because
* no browser has shown one for years — the text is the browser's own.
*/
export function useUnsavedGuard(dirty: boolean) {
useEffect(() => {
function handleBeforeUnload(e: BeforeUnloadEvent) {
if (dirty) {
e.preventDefault();
e.returnValue = '';
}
}
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [dirty]);
}

View File

@@ -0,0 +1,159 @@
/* The Immich album browser. */
/* Split out of admin.css (#533). Loaded from admin.css in a fixed order —
later sheets override earlier ones, so the order there is the cascade. */
/* ── Album Picker ──────────────────────────────────────────── */
.picker-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: var(--admin-overlay);
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
backdrop-filter: blur(4px);
}
.picker-modal {
width: 100%;
max-width: 560px;
max-height: 80vh;
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
border-radius: 12px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.picker-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--admin-border);
}
.picker-header h3 {
font-size: 1rem;
font-weight: 600;
margin: 0;
}
.picker-search {
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--admin-border);
}
.picker-search input {
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.85rem;
}
.picker-search input:focus {
outline: none;
border-color: var(--admin-border-focus);
}
.picker-list {
flex: 1;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0.5rem;
}
.picker-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0.75rem;
border-radius: 8px;
cursor: pointer;
transition: background 0.1s;
}
.picker-item:hover:not(.used) {
background: var(--admin-bg-elevated);
}
.picker-item.used {
opacity: 0.5;
cursor: not-allowed;
}
.picker-item-thumb {
flex-shrink: 0;
}
.picker-thumb-placeholder {
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
background: var(--admin-bg-elevated);
border-radius: 8px;
font-size: 1.2rem;
}
.picker-thumb-img {
width: 52px;
height: 52px;
object-fit: cover;
border-radius: 8px;
background: var(--admin-bg-elevated);
}
.picker-item-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.picker-item-name {
font-size: 0.85rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.picker-item-meta {
font-size: 0.7rem;
color: var(--admin-text-muted);
}
.picker-item-action {
flex-shrink: 0;
}
.picker-used-badge {
font-size: 0.7rem;
color: var(--admin-text-muted);
padding: 0.2rem 0.5rem;
border: 1px solid var(--admin-border);
border-radius: 4px;
}
.picker-select-badge {
font-size: 0.7rem;
color: var(--admin-success);
padding: 0.2rem 0.5rem;
border: 1px solid var(--admin-success);
border-radius: 4px;
opacity: 0;
transition: opacity 0.1s;
}
.picker-item:hover .picker-select-badge {
opacity: 1;
}

View File

@@ -0,0 +1,211 @@
/* The analytics view. */
/* Split out of admin.css (#533). Loaded from admin.css in a fixed order —
later sheets override earlier ones, so the order there is the cascade. */
/* ── Analytics View ────────────────────────────────────────── */
.analytics-view {
display: flex;
flex-direction: column;
gap: 1.5rem;
max-width: 1100px;
}
.analytics-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.analytics-header h2 {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.analytics-subtitle {
font-size: 0.8rem;
color: var(--admin-text-muted);
margin: 0.25rem 0 0;
}
.analytics-metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
}
.metric-card {
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
border-radius: 12px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.metric-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--admin-text-muted);
}
.metric-value {
font-size: 1.8rem;
font-weight: 700;
color: var(--admin-text);
font-family: var(--font-mono, monospace);
}
.metric-sub {
font-size: 0.7rem;
color: var(--admin-text-muted);
}
.analytics-panel {
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
border-radius: 12px;
padding: 1.5rem;
}
.analytics-section-title h3 {
font-size: 0.95rem;
font-weight: 600;
margin: 0 0 1.25rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.analytics-bar-chart {
display: flex;
align-items: flex-end;
gap: 0.75rem;
height: 180px;
padding-top: 1.5rem;
}
.bar-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
}
.bar-wrapper {
flex: 1;
width: 100%;
max-width: 32px;
display: flex;
align-items: flex-end;
background: var(--admin-bg-elevated);
border-radius: 6px;
overflow: hidden;
}
.bar-fill {
width: 100%;
background: var(--admin-primary, #3b82f6);
border-radius: 6px 6px 0 0;
transition: height 0.3s ease;
position: relative;
display: flex;
align-items: flex-start;
justify-content: center;
}
.bar-val {
font-size: 0.65rem;
font-weight: 600;
color: #fff;
margin-top: 4px;
}
.bar-label {
font-size: 0.68rem;
color: var(--admin-text-muted);
margin-top: 0.5rem;
font-family: var(--font-mono, monospace);
}
.analytics-top-pages {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.top-page-row {
display: flex;
align-items: center;
gap: 1rem;
}
.page-rank {
font-size: 0.8rem;
font-family: var(--font-mono, monospace);
color: var(--admin-text-muted);
width: 24px;
}
.page-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.page-path-line {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.85rem;
}
.page-path {
font-family: var(--font-mono, monospace);
color: var(--admin-text);
}
.page-count {
font-size: 0.75rem;
color: var(--admin-text-muted);
}
.page-progress-bg {
width: 100%;
height: 6px;
background: var(--admin-bg-elevated);
border-radius: 3px;
overflow: hidden;
}
/* The bar itself. Without this the track stayed empty and the view count had
* no visual weight at all. */
.page-progress-fill {
height: 100%;
min-width: 2px;
border-radius: 3px;
background: var(--admin-accent);
transition: width 0.3s ease;
}
.analytics-empty {
margin: 0;
padding: 2rem 1rem;
text-align: center;
font-size: 0.85rem;
color: var(--admin-text-muted);
}
.admin-loading-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 320px;
}

View File

@@ -0,0 +1,141 @@
/* The manual per-album photo order editor. */
/* Split out of admin.css (#533). Loaded from admin.css in a fixed order —
later sheets override earlier ones, so the order there is the cascade. */
/* ── Per-album photo order (sort: manual) ────────────────────── */
.admin-field-hint {
display: block;
margin-top: 0.35rem;
font-size: 0.75rem;
line-height: 1.4;
color: var(--admin-text-muted);
}
.order-editor-body {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0.75rem;
}
.order-editor-notice {
margin: 0 0 0.75rem;
padding: 0.5rem 0.7rem;
border-radius: 6px;
font-size: 0.78rem;
line-height: 1.4;
color: var(--admin-accent);
border: 1px solid rgba(245, 158, 11, 0.3);
background: rgba(245, 158, 11, 0.05);
}
.order-editor-section + .order-editor-section {
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid var(--admin-border);
}
.order-editor-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.order-editor-section-head h4 {
margin: 0;
font-size: 0.85rem;
font-weight: 600;
color: var(--admin-text);
}
.order-editor-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
gap: 0.5rem;
margin-top: 0.5rem;
}
.order-tile {
position: relative;
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
background: var(--admin-bg-elevated);
border: 2px solid transparent;
cursor: pointer;
transition:
border-color 0.15s,
opacity 0.15s;
}
.order-tile img {
width: 100%;
height: 100%;
object-fit: cover;
/* The whole tile is a drag handle; without this the browser's native image
drag wins and dnd-kit never sees the gesture. */
-webkit-user-drag: none;
user-select: none;
}
.order-tile-pinned {
border-color: var(--admin-accent);
cursor: grab;
}
.order-tile-pinned:active {
cursor: grabbing;
}
.order-tile-unpinned {
opacity: 0.55;
}
.order-tile-unpinned:hover {
opacity: 1;
border-color: var(--admin-border);
}
.order-tile-index {
position: absolute;
bottom: 4px;
left: 4px;
min-width: 18px;
padding: 0 4px;
border-radius: 4px;
font-size: 0.68rem;
font-weight: 700;
text-align: center;
color: #000;
background: var(--admin-accent);
}
.order-tile-action {
position: absolute;
top: 4px;
right: 4px;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 50%;
font-size: 0.85rem;
line-height: 1;
cursor: pointer;
color: var(--admin-text);
background: var(--admin-overlay, rgba(0, 0, 0, 0.6));
}
.order-editor-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 0.75rem;
border-top: 1px solid var(--admin-border);
}

View File

@@ -0,0 +1,168 @@
/* The library search used to pick hero images. */
/* Split out of admin.css (#533). Loaded from admin.css in a fixed order —
later sheets override earlier ones, so the order there is the cascade. */
/* ── Asset Picker (Hero) ───────────────────────────────────── */
.asset-picker-modal {
width: 100%;
max-width: 720px;
height: 85vh;
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
border-radius: 12px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.asset-picker-tabs {
display: flex;
border-bottom: 1px solid var(--admin-border);
}
.asset-picker-tab {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
padding: 0.7rem 1rem;
background: none;
border: none;
color: var(--admin-text-muted);
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
border-bottom: 2px solid transparent;
}
.asset-picker-tab:hover {
color: var(--admin-text-secondary);
background: var(--admin-bg-elevated);
}
.asset-picker-tab.active {
color: var(--admin-text);
border-bottom-color: var(--admin-accent);
}
.asset-picker-grid {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0.75rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
grid-auto-rows: min-content;
gap: 0.5rem;
align-content: start;
}
.asset-picker-loading {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
padding: 3rem;
}
.asset-picker-loading-more {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.asset-picker-tile {
position: relative;
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
background: var(--admin-bg-elevated);
border: 2px solid transparent;
transition:
border-color 0.15s,
transform 0.1s;
}
.asset-picker-tile:hover:not(.used) {
border-color: var(--admin-accent);
transform: scale(1.03);
}
.asset-picker-tile.used {
opacity: 0.4;
cursor: not-allowed;
}
.asset-picker-tile img {
width: 100%;
height: 100%;
object-fit: cover;
}
.asset-picker-used-badge {
position: absolute;
top: 4px;
right: 4px;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--admin-accent);
color: #000;
font-size: 0.7rem;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
}
.asset-picker-fav-badge {
position: absolute;
top: 4px;
left: 4px;
display: flex;
font-size: 0.85rem;
color: var(--admin-accent);
/* drop-shadow, not text-shadow: the badge is an inline SVG, not a glyph. */
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6));
}
.asset-picker-uuid-section {
padding: 0.75rem 1rem;
border-top: 1px solid var(--admin-border);
background: var(--admin-bg);
}
.asset-picker-uuid-form {
display: flex;
gap: 0.5rem;
align-items: center;
}
.asset-picker-uuid-input {
flex: 1;
padding: 0.5rem 0.75rem;
background: var(--admin-bg-input);
border: 1px solid var(--admin-border);
border-radius: 6px;
color: var(--admin-text);
font-size: 0.8rem;
font-family: monospace;
}
.asset-picker-uuid-input:focus {
outline: none;
border-color: var(--admin-border-focus);
}
.asset-picker-uuid-error {
font-size: 0.7rem;
color: var(--admin-error);
margin-top: 0.3rem;
display: block;
}

298
app/admin/styles/base.css Normal file
View File

@@ -0,0 +1,298 @@
/* Loading and disabled states, the login screen, the plain field wrappers, the listbox, the error box. */
/* Split out of admin.css (#533). Loaded from admin.css in a fixed order —
later sheets override earlier ones, so the order there is the cascade. */
/* ── 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 <body>, 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;
}

View File

@@ -0,0 +1,135 @@
/* The button scale. */
/* Split out of admin.css (#533). Loaded from admin.css in a fixed order —
later sheets override earlier ones, so the order there is the cascade. */
/* ── 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);
/* <button> gets this from the UA stylesheet, <a> does not — without it the
* ghost border pushed the "Site" link 2px past its neighbours. */
box-sizing: border-box;
border: none;
border-radius: 6px;
font-family: inherit;
font-size: 0.875rem;
font-weight: 500;
/*
* Explicit, because <a class="admin-btn"> and <button class="admin-btn">
* otherwise resolve `normal` against different inline boxes — the "Site" link
* ended up half a pixel taller than the buttons next to it.
*/
line-height: 1;
cursor: pointer;
transition: all 0.15s;
}
.admin-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.admin-btn-primary {
background: var(--admin-btn-primary-bg);
color: var(--admin-btn-primary-text);
}
.admin-btn-primary:hover:not(:disabled) {
background: var(--admin-btn-primary-hover);
}
.admin-btn-ghost {
background: transparent;
color: var(--admin-text-secondary);
border: 1px solid var(--admin-border);
}
.admin-btn-ghost:hover:not(:disabled) {
color: var(--admin-text);
border-color: var(--admin-border-hover);
}
/*
* Written as `.admin-btn.admin-btn-*` on purpose: these variants are combined
* with the size classes (`admin-btn-sm`, `admin-btn-xs`), which set their own
* background further down. The doubled class outranks them whatever the order.
*/
/* Neutral action next to a primary one. */
.admin-btn.admin-btn-secondary {
background: var(--admin-bg-elevated);
color: var(--admin-text);
border: 1px solid var(--admin-border);
}
.admin-btn.admin-btn-secondary:hover:not(:disabled) {
background: var(--admin-bg-raised);
border-color: var(--admin-border-hover);
}
/* Destructive action — must read as destructive before it is clicked. */
.admin-btn.admin-btn-danger {
background: transparent;
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.35);
}
.admin-btn.admin-btn-danger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.14);
border-color: rgba(239, 68, 68, 0.6);
color: #fca5a5;
}
.admin-btn-sm {
padding: 0 0.8rem;
min-height: 30px;
font-size: 0.8rem;
background: var(--admin-bg-elevated);
color: var(--admin-text-secondary);
border: 1px solid var(--admin-border);
}
.admin-btn-sm:hover {
background: var(--admin-border);
border-color: var(--admin-border-hover);
color: var(--admin-text);
}
.admin-btn-xs {
padding: 0 0.6rem;
min-height: 26px;
font-size: 0.75rem;
background: var(--admin-bg-raised);
color: var(--admin-text-secondary);
border: 1px solid var(--admin-border);
}
.admin-btn-xs:hover {
color: var(--admin-text);
border-color: var(--admin-border-hover);
}
.admin-btn-icon {
background: none;
border: none;
color: var(--admin-text-muted);
cursor: pointer;
font-size: 1rem;
padding: 0.25rem;
border-radius: 4px;
transition: color 0.15s;
}
.admin-btn-icon:hover {
color: var(--admin-text);
}
.admin-btn-icon:disabled {
opacity: 0.3;
cursor: not-allowed;
}

View File

@@ -0,0 +1,89 @@
/* The read-only Live Preview tab inside the subpage drawer. */
/* Split out of admin.css (#533). Loaded from admin.css in a fixed order —
later sheets override earlier ones, so the order there is the cascade. */
/* ── Drawer Live Preview ────────────────────────────────────── */
.drawer-live-preview-panel {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 0.5rem 0;
}
.preview-hero-banner {
text-align: center;
padding: 1.75rem 1rem;
background: var(--admin-bg-elevated);
border-radius: 12px;
border: 1px dashed var(--admin-border);
}
.preview-hero-title {
font-size: 1.3rem;
font-weight: 700;
margin: 0;
color: var(--admin-text);
}
.preview-hero-sub {
font-size: 0.82rem;
color: var(--admin-text-muted);
margin: 0.35rem 0 0;
}
.preview-section-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.preview-section-title {
font-size: 0.95rem;
font-weight: 600;
margin: 0;
border-bottom: 1px solid var(--admin-border);
padding-bottom: 0.35rem;
}
.preview-section-desc {
font-size: 0.78rem;
color: var(--admin-text-muted);
margin: 0;
}
.preview-albums-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 0.75rem;
}
.preview-album-tile {
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.preview-album-cover {
width: 100%;
aspect-ratio: 4/3;
background: var(--admin-bg-elevated);
overflow: hidden;
}
.preview-album-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-album-title {
font-size: 0.75rem;
font-weight: 500;
padding: 0.4rem 0.5rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -0,0 +1,742 @@
/* The slide-over drawer, the centered modal, the status dropdown footer, the backup manager. */
/* Split out of admin.css (#533). Loaded from admin.css in a fixed order —
later sheets override earlier ones, so the order there is the cascade. */
/* ── Premium Slide-over Drawer & Badges Redesign ──────────────── */
/* Inline SVG Icon Global Styling */
.svg-icon {
display: inline-block;
vertical-align: middle;
flex-shrink: 0;
width: 1em;
height: 1em;
stroke-width: 2px;
transition:
transform 0.2s ease,
color 0.15s ease;
}
/* Drag handles use IconGripVertical's square 24x24 viewBox, so keep the box
square too — a 12x18 box would letterbox the glyph down to 12x12. */
.svg-drag {
width: 18px;
height: 18px;
}
h2 .svg-icon,
h3 .svg-icon {
margin-right: 0.5rem;
font-size: 1.15em;
color: var(--admin-accent);
}
/* Album Card Simplification & Grid Spacing */
.album-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1.25rem;
margin: 0.5rem 0 1.25rem;
}
.album-tile {
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
border-radius: 12px;
overflow: hidden;
position: relative;
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.album-tile:hover {
transform: translateY(-2px);
border-color: var(--admin-border-hover);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
}
[data-theme='light'] .album-tile:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
}
.album-tile-cover {
aspect-ratio: 16 / 11;
border-bottom: 1px solid var(--admin-border);
position: relative;
background: var(--admin-bg-elevated);
overflow: hidden;
}
.album-tile-placeholder {
color: var(--admin-text-muted);
font-size: 1.75rem;
}
.album-tile-overlay {
display: flex;
gap: 0.5rem;
}
.album-tile-btn {
background: rgba(17, 17, 17, 0.85);
backdrop-filter: blur(8px);
border: 1px solid var(--admin-border);
color: var(--admin-text);
width: 32px;
height: 32px;
border-radius: 8px;
transition: all 0.15s ease;
}
.album-tile-btn:hover {
background: var(--admin-accent);
color: #000;
border-color: var(--admin-accent);
}
.album-tile-btn-danger:hover {
background: var(--admin-error) !important;
color: #fff !important;
border-color: var(--admin-error) !important;
}
.album-tile-info {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.album-tile-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.album-tile-name {
font-size: 0.85rem;
font-weight: 600;
color: var(--admin-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.album-tile-name.custom-title {
color: var(--admin-accent);
font-style: italic;
}
/* Badges for status indications */
.album-tile-badges {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.album-tile-badges .badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 4px;
font-size: 0.7rem;
background: var(--admin-bg-elevated);
border: 1px solid var(--admin-border);
color: var(--admin-text-secondary);
}
.album-tile-badges .badge-password {
color: var(--admin-accent);
border-color: rgba(245, 158, 11, 0.3);
background: rgba(245, 158, 11, 0.05);
}
.album-tile-badges .badge-hero {
color: var(--admin-success);
border-color: rgba(74, 222, 128, 0.3);
background: rgba(74, 222, 128, 0.05);
}
/* Carries a mode name ("filename", "oldest"), so it cannot use the fixed
icon-sized box the other badges share. */
.album-tile-badges .badge-sort {
width: auto;
min-width: 18px;
padding: 0 5px;
font-size: 0.62rem;
letter-spacing: 0.03em;
text-transform: uppercase;
}
/* ── Breitwand-Kiste Centered Modal styling ── */
.album-drawer-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: var(--admin-overlay);
backdrop-filter: blur(12px);
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
.album-drawer-overlay.open {
opacity: 1;
pointer-events: auto;
}
.album-drawer {
width: 100%;
/* Narrower than the subpage sheet (1100px) so the nested editor still reads
as a layer on top of it, but wide enough for the same two-column body. */
max-width: 960px;
max-height: 90vh;
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
border-radius: 16px;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
overflow: hidden;
transform: scale(0.95) translateY(10px);
transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
.album-drawer-overlay.open .album-drawer {
transform: scale(1) translateY(0);
}
.album-drawer-header {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--admin-border);
display: flex;
align-items: center;
justify-content: space-between;
background: var(--admin-bg-elevated);
}
.album-drawer-header h3 {
font-size: 1.15rem;
font-weight: 700;
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
/* The drawer's only scroll container. Nesting a second scroller here is what
used to clip the sort listbox popup — the two columns inside must stay
overflow-free and scroll together. */
.album-drawer-body {
flex: 1;
min-height: 0;
overflow-y: auto;
/* Stop the scroll handing off to the page once the form hits its end. */
overscroll-behavior: contain;
padding: 1.75rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
@media (max-width: 768px) {
.album-drawer {
max-height: 95vh;
}
.album-drawer-body {
padding: 1.25rem;
}
}
/* Full-screen on phones, mirroring the subpage sheet. */
@media (max-width: 560px) {
.album-drawer-overlay {
padding: 0;
}
.album-drawer {
max-width: none;
max-height: none;
height: 100dvh;
border-radius: 0;
border: none;
}
}
.album-drawer-body::-webkit-scrollbar {
width: 6px;
}
.album-drawer-body::-webkit-scrollbar-track {
background: transparent;
}
.album-drawer-body::-webkit-scrollbar-thumb {
background: var(--admin-border);
border-radius: 3px;
}
.album-drawer-body::-webkit-scrollbar-thumb:hover {
background: var(--admin-border-hover);
}
.album-drawer-footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--admin-border);
background: var(--admin-bg-elevated);
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.75rem;
}
/* Narrow screens cannot fit both buttons plus the UUID on one line; drop the
UUID onto its own row rather than squeezing the actions. */
@media (max-width: 560px) {
.album-drawer-footer .uuid-copy-box {
order: 1;
flex-basis: 100%;
}
}
/* Cover banner across the top of the drawer. flex-shrink must be off: the img
inside is height:100%, so the box has no min-content height and the scrolling
flex column would otherwise crush it to a couple of pixels. */
.modal-cover-container {
width: 100%;
flex: none;
aspect-ratio: 21 / 9;
max-height: 180px;
border-radius: 10px;
overflow: hidden;
border: 1px solid var(--admin-border);
background: var(--admin-bg);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.modal-cover-container img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.modal-cover-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--admin-text-muted);
font-size: 2.5rem;
}
/* Album name + photo count, directly under the cover banner */
.modal-meta-box {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding-bottom: 0.25rem;
border-bottom: 1px solid var(--admin-border);
}
.modal-album-title {
font-size: 1.1rem;
font-weight: 700;
color: var(--admin-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.modal-album-subtitle {
font-size: 0.8rem;
color: var(--admin-text-secondary);
}
/* Drawer Copy UUID element */
.uuid-copy-box {
display: flex;
align-items: center;
background: var(--admin-bg-input);
border: 1px solid var(--admin-border);
border-radius: 6px;
padding: 0.4rem 0.5rem 0.4rem 0.75rem;
gap: 0.5rem;
}
.uuid-copy-box code {
font-family: monospace;
font-size: 0.75rem;
color: var(--admin-text-secondary);
flex: 1;
word-break: break-all;
user-select: all;
}
/* In the footer the UUID is secondary to the two action buttons: it takes the
leftover width and truncates rather than wrapping the row onto two lines. */
.album-drawer-footer .uuid-copy-box {
flex: 1;
min-width: 0;
padding: 0.3rem 0.35rem 0.3rem 0.6rem;
}
.album-drawer-footer .uuid-copy-box code {
word-break: normal;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.uuid-copy-btn {
background: none;
border: none;
color: var(--admin-text-muted);
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.15s ease;
}
.uuid-copy-btn:hover {
color: var(--admin-accent);
background: var(--admin-bg-elevated);
}
/* Section Card Improvements */
.section-card {
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
border-radius: 12px;
padding: 1.25rem;
margin-bottom: 1.25rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.section-card:hover {
border-color: var(--admin-border-hover);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
.section-header {
border-bottom: 1px solid var(--admin-border);
padding-bottom: 0.5rem;
margin-bottom: 0.75rem;
}
.section-title-input {
font-size: 0.95rem !important;
font-weight: 700 !important;
color: var(--admin-text) !important;
border-radius: 6px !important;
}
.section-desc-input {
border-color: transparent !important;
background: transparent !important;
padding-left: 0.4rem !important;
color: var(--admin-text-secondary) !important;
}
.section-desc-input:focus {
border-color: var(--admin-border) !important;
background: var(--admin-bg-input) !important;
}
/* ── Modern Subpage Details Spacing ── */
.subpage-detail {
margin-top: 1.5rem;
border: 1px solid var(--admin-border);
background: var(--admin-bg-elevated);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
}
[data-theme='light'] .subpage-detail {
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.05);
}
.subpage-meta {
padding: 1.25rem;
background: var(--admin-bg-raised);
border-bottom: 1px solid var(--admin-border);
}
/* ── Status Dropdown Footer ── */
.status-dropdown-footer {
padding: 0.5rem 0.85rem;
background: var(--admin-bg-elevated);
border-top: 1px solid var(--admin-border);
display: flex;
justify-content: flex-end;
}
.admin-btn-sm {
padding: 0.35rem 0.65rem !important;
font-size: 0.75rem !important;
}
/* ── Backup Manager Modal ────────────────────────────────────── */
.backup-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(4px);
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
animation: fadeIn 0.15s ease-out;
}
.backup-modal-container {
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
border-radius: 14px;
width: 100%;
max-width: 640px;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.35);
overflow: hidden;
animation: scaleUp 0.15s cubic-bezier(0.16, 1, 0.3, 1);
}
.backup-modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 1.25rem 1.5rem 0.75rem;
border-bottom: 1px solid var(--admin-border);
background: var(--admin-bg-elevated);
}
.backup-modal-header h2 {
margin: 0;
font-size: 1.15rem;
font-weight: 700;
color: var(--admin-text);
}
.backup-modal-subtitle {
margin: 0.25rem 0 0;
font-size: 0.8rem;
color: var(--admin-text-muted);
}
.backup-modal-close-btn {
background: none;
border: none;
font-size: 1.1rem;
color: var(--admin-text-muted);
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 6px;
line-height: 1;
transition: all 0.15s;
}
.backup-modal-close-btn:hover {
color: var(--admin-text);
background: var(--admin-bg-input);
}
/* Modal Tabs */
.backup-modal-tabs {
display: flex;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--admin-border);
background: var(--admin-bg-raised);
}
.backup-modal-tab {
padding: 0.4rem 0.85rem;
font-size: 0.82rem;
font-weight: 600;
border-radius: 8px;
background: transparent;
border: 1px solid transparent;
color: var(--admin-text-muted);
cursor: pointer;
transition: all 0.15s;
}
.backup-modal-tab:hover {
color: var(--admin-text);
background: var(--admin-bg-elevated);
}
.backup-modal-tab.active {
background: var(--admin-bg-elevated);
border-color: var(--admin-border);
color: var(--admin-accent);
}
/* Modal Status Alert */
.backup-status-alert {
margin: 0.75rem 1.5rem 0;
padding: 0.65rem 0.85rem;
border-radius: 8px;
font-size: 0.82rem;
font-weight: 500;
}
.backup-status-alert.error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid var(--admin-error, #ef4444);
color: var(--admin-error, #ef4444);
}
.backup-status-alert.success {
background: rgba(16, 185, 129, 0.1);
border: 1px solid var(--admin-success, #10b981);
color: var(--admin-success, #10b981);
}
/* Confirmation Box */
.backup-confirm-box {
margin: 0.75rem 1.5rem 0;
padding: 1rem;
background: rgba(239, 68, 68, 0.05);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 10px;
}
.backup-confirm-content strong {
display: block;
font-size: 0.9rem;
color: var(--admin-text);
margin-bottom: 0.25rem;
}
.backup-confirm-content p {
margin: 0 0 0.75rem;
font-size: 0.8rem;
color: var(--admin-text-secondary);
line-height: 1.4;
}
.backup-confirm-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
/* Modal Body & List */
.backup-modal-body {
flex: 1;
overflow-y: auto;
overscroll-behavior: contain;
padding: 1rem 1.5rem;
}
.backup-loading,
.backup-empty {
text-align: center;
padding: 2.5rem 1rem;
font-size: 0.85rem;
color: var(--admin-text-muted);
}
.backup-list {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.backup-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: var(--admin-bg-elevated);
border: 1px solid var(--admin-border);
border-radius: 10px;
transition: border-color 0.15s;
}
.backup-item:hover {
border-color: var(--admin-border-hover);
}
.backup-item-info {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.backup-item-title {
display: flex;
align-items: center;
gap: 0.5rem;
}
.backup-filename {
font-size: 0.88rem;
font-weight: 600;
color: var(--admin-text);
}
.backup-badge {
font-size: 0.68rem;
font-weight: 600;
padding: 0.15rem 0.45rem;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.backup-badge.pre-restore {
background: rgba(245, 158, 11, 0.15);
color: #f59e0b;
border: 1px solid rgba(245, 158, 11, 0.3);
}
.backup-item-meta code {
font-family: monospace;
font-size: 0.72rem;
color: var(--admin-text-muted);
}
.backup-modal-footer {
padding: 0.85rem 1.5rem;
border-top: 1px solid var(--admin-border);
background: var(--admin-bg-elevated);
display: flex;
justify-content: flex-end;
}
.backup-toggle-btn {
margin-top: 0.4rem;
padding: 0.55rem 0.85rem;
background: var(--admin-bg-elevated);
border: 1px dashed var(--admin-border);
border-radius: 8px;
color: var(--admin-text-secondary);
font-size: 0.78rem;
font-weight: 500;
cursor: pointer;
width: 100%;
text-align: center;
transition: all 0.15s ease;
}
.backup-toggle-btn:hover {
background: var(--admin-bg-raised);
border-color: var(--admin-accent);
color: var(--admin-accent);
}

View File

@@ -0,0 +1,193 @@
/* The photo essay and storytelling builder. */
/* Split out of admin.css (#533). Loaded from admin.css in a fixed order —
later sheets override earlier ones, so the order there is the cascade. */
/* ── Photo Essay & Storytelling Builder ───────────────────────── */
.essay-block-editor {
display: flex;
flex-direction: column;
gap: 1rem;
}
.essay-toolbar-sticky {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--admin-bg-raised, #1a1a1a);
border: 1px solid var(--admin-border, #2e2e2e);
border-radius: 10px;
backdrop-filter: blur(8px);
}
.essay-toolbar-label {
font-size: 0.8rem;
font-weight: 600;
color: var(--admin-text, #f0f0f0);
margin-right: 0.5rem;
}
.essay-toolbar-divider {
width: 1px;
height: 20px;
background: var(--admin-border, #2e2e2e);
margin: 0 0.25rem;
}
.essay-block-card {
background: var(--admin-bg-raised, #161616);
border: 1px solid var(--admin-border, #2e2e2e);
border-radius: 10px;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.essay-block-card:hover {
border-color: var(--admin-border-hover, #444444);
}
.essay-block-card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--admin-border, rgba(255, 255, 255, 0.06));
}
.essay-block-badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0.55rem;
border-radius: 9999px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
background: var(--admin-bg-elevated, #222);
color: var(--admin-text-secondary, #a0a0a0);
border: 1px solid var(--admin-border, #2e2e2e);
}
/* Position prefix inside the chip, e.g. "3 · Quote". */
.essay-block-badge-index {
color: var(--admin-text-muted);
font-variant-numeric: tabular-nums;
}
.essay-block-badge-index::after {
content: ' ·';
}
/* The card's own hover state is enough of a highlight; the chip stays quiet. */
.essay-block-card:hover .essay-block-badge {
border-color: var(--admin-border-hover);
color: var(--admin-text);
}
.essay-block-actions {
display: flex;
align-items: center;
gap: 0.25rem;
}
/* Polished dark inputs for Essay Editor */
.essay-block-editor input[type='text'],
.essay-block-editor textarea,
.essay-block-editor select {
background: var(--admin-bg-input, #111111) !important;
border: 1px solid var(--admin-border, #2e2e2e) !important;
border-radius: 6px !important;
color: var(--admin-text, #f0f0f0) !important;
font-size: 0.85rem !important;
padding: 0.55rem 0.75rem !important;
outline: none !important;
transition: border-color 0.15s !important;
}
.essay-block-editor input[type='text']:focus,
.essay-block-editor textarea:focus,
.essay-block-editor select:focus {
border-color: var(--admin-border-focus, #888888) !important;
}
.essay-photo-card {
display: flex;
flex-direction: column;
gap: 0.75rem;
background: var(--admin-bg, #111111);
padding: 0.85rem;
border-radius: 8px;
border: 1px solid var(--admin-border, #2e2e2e);
}
.essay-photo-preview-container {
display: flex;
gap: 1rem;
align-items: flex-start;
}
.essay-photo-thumbnail {
width: 120px;
height: 80px;
border-radius: 6px;
background: var(--admin-bg-elevated, #222);
object-fit: cover;
border: 1px solid var(--admin-border, #333);
flex-shrink: 0;
}
.essay-photo-empty {
width: 120px;
height: 80px;
border-radius: 6px;
background: var(--admin-bg-elevated, #1a1a1a);
border: 1px dashed var(--admin-border, #333);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--admin-text-muted, #707070);
font-size: 0.75rem;
gap: 4px;
flex-shrink: 0;
}
.essay-photo-meta {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.essay-pill-selector {
display: inline-flex;
background: var(--admin-bg-input, #000);
padding: 2px;
border-radius: 6px;
border: 1px solid var(--admin-border, #2e2e2e);
}
.essay-pill-btn {
padding: 0.25rem 0.65rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
border: none;
background: transparent;
color: var(--admin-text-muted, #888);
cursor: pointer;
transition: all 0.15s ease;
}
.essay-pill-btn.active {
background: var(--admin-btn-primary-bg, #ffffff);
color: var(--admin-btn-primary-text, #000000);
}

View File

@@ -0,0 +1,19 @@
/* Favicon upload in Settings > General. */
/* Split out of admin.css (#533). Loaded from admin.css in a fixed order —
later sheets override earlier ones, so the order there is the cascade. */
/* ── Favicon upload (Settings › General) ─────────────────────── */
.favicon-field {
margin-top: 1rem;
}
.favicon-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.favicon-row input[type='file'] {
font-size: 0.8rem;
}

196
app/admin/styles/help.css Normal file
View File

@@ -0,0 +1,196 @@
/* The help section and the search bar. */
/* Split out of admin.css (#533). Loaded from admin.css in a fixed order —
later sheets override earlier ones, so the order there is the cascade. */
/* ── Help section ──────────────────────────────────────────── */
.help-kbd {
display: inline-block;
min-width: 1.6rem;
padding: 2px 6px;
border: 1px solid var(--admin-border, rgba(255, 255, 255, 0.15));
border-bottom-width: 2px;
border-radius: 4px;
background: var(--admin-bg-elevated, rgba(255, 255, 255, 0.06));
color: var(--admin-text);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.75rem;
line-height: 1.4;
text-align: center;
}
.help-shortcuts {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0;
padding: 4px 0 0;
}
.help-shortcuts-row {
display: grid;
grid-template-columns: 8.5rem 1fr;
gap: 12px;
align-items: baseline;
padding: 10px 4px;
border-bottom: 1px solid var(--admin-border-subtle, rgba(255, 255, 255, 0.06));
}
.help-shortcuts-row:last-child {
border-bottom: none;
}
.help-shortcuts-keys {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin: 0;
}
.help-shortcuts-label {
margin: 0;
color: var(--admin-text);
font-size: 0.85rem;
}
.help-shortcuts-note {
display: block;
margin-top: 2px;
color: var(--admin-text-secondary);
font-size: 0.78rem;
}
.help-action {
padding: 12px 4px 4px;
}
@media (max-width: 600px) {
.help-shortcuts-row {
grid-template-columns: 1fr;
gap: 6px;
}
}
/* ── Search Bar ────────────────────────────────────────────── */
.builder-search-container {
padding: 0.75rem 1.5rem;
background: var(--admin-bg-raised);
border-bottom: 1px solid var(--admin-border);
margin-bottom: 1rem;
}
.builder-search-wrapper {
position: relative;
display: flex;
align-items: center;
max-width: 480px;
}
.builder-search-icon {
position: absolute;
left: 10px;
font-size: 0.85rem;
color: var(--admin-text-muted);
pointer-events: none;
}
.builder-search-input {
width: 100%;
padding: 0.5rem 2rem 0.5rem 2rem;
background: var(--admin-bg-input);
border: 1px solid var(--admin-border);
border-radius: 8px;
color: var(--admin-text);
font-size: 0.82rem;
transition: all 0.15s;
}
.builder-search-input:focus {
outline: none;
border-color: var(--admin-accent);
box-shadow: 0 0 0 1px var(--admin-accent);
background: var(--admin-bg);
}
.builder-search-clear {
position: absolute;
right: 8px;
background: none;
border: none;
color: var(--admin-text-muted);
font-size: 1.1rem;
cursor: pointer;
padding: 0 4px;
}
.builder-search-clear:hover {
color: var(--admin-text);
}
/* Keyframes */
@keyframes pulse-green {
0% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4);
}
70% {
box-shadow: 0 0 0 6px rgba(16, 185, 129, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
}
}
@keyframes pulse-red {
0% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
}
70% {
box-shadow: 0 0 0 6px rgba(239, 68, 68, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0);
}
}
/* Color Field */
.color-field {
display: flex;
align-items: center;
gap: 0.5rem;
}
.color-field input[type='color'] {
width: 36px;
height: 36px;
padding: 0;
border: 1px solid var(--admin-border);
border-radius: 6px;
background: none;
cursor: pointer;
}
.color-field input[type='text'] {
flex: 1;
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: monospace;
}
/* Radio Group */
.radio-group {
display: flex;
gap: 1rem;
}
.radio-group label {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.85rem;
color: var(--admin-text);
cursor: pointer;
}

View File

@@ -0,0 +1,486 @@
/* Hero grid, album grid, album hero field, subpage overview and detail, section cards. */
/* Split out of admin.css (#533). Loaded from admin.css in a fixed order —
later sheets override earlier ones, so the order there is the cascade. */
/* ── Page Builder ──────────────────────────────────────────── */
.builder-section {
margin-bottom: 2rem;
}
.builder-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.builder-section-header h2 {
font-size: 1rem;
font-weight: 600;
margin: 0;
}
.empty-hint {
color: var(--admin-text-muted);
font-size: 0.8rem;
font-style: italic;
padding: 0.75rem;
border: 1px dashed var(--admin-border);
border-radius: 6px;
text-align: center;
}
/* Hero Grid */
.hero-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.75rem;
}
.hero-tile {
position: relative;
aspect-ratio: 16 / 10;
border-radius: 10px;
overflow: hidden;
background: var(--admin-bg-elevated);
border: 1px solid var(--admin-border);
transition: border-color 0.15s;
}
.hero-tile:hover {
border-color: var(--admin-border-hover);
}
.hero-tile img {
width: 100%;
height: 100%;
object-fit: cover;
}
.hero-tile-remove {
position: absolute;
top: 6px;
right: 6px;
width: 26px;
height: 26px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
border: none;
color: #fff;
font-size: 0.9rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.15s;
}
.hero-tile:hover .hero-tile-remove {
opacity: 1;
}
.hero-tile-remove:hover {
background: rgba(220, 40, 40, 0.8);
}
.hero-tile-index {
position: absolute;
bottom: 6px;
left: 6px;
width: 22px;
height: 22px;
border-radius: 5px;
background: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 0.7rem;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
/* Album Grid */
.album-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 1rem;
}
.album-tile {
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
border-radius: 10px;
overflow: hidden;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.album-tile:hover {
border-color: var(--admin-border-hover);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.album-tile-cover {
position: relative;
aspect-ratio: 1;
background: var(--admin-bg-elevated);
overflow: hidden;
}
.album-tile-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.album-tile-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
}
.album-tile-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
opacity: 0;
transition: opacity 0.15s;
}
.album-tile:hover .album-tile-overlay {
opacity: 1;
}
.album-tile-btn {
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(4px);
border: 1px solid rgba(255, 255, 255, 0.25);
color: #fff;
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 0.95rem;
transition: background 0.1s;
}
.album-tile-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
.album-tile-info {
padding: 0.6rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.album-tile-name {
font-size: 0.875rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.album-tile-count {
font-size: 0.75rem;
color: var(--admin-text-muted);
}
.album-tile-details {
padding: 0.75rem;
border-top: 1px solid var(--admin-border);
background: var(--admin-bg);
}
/* Album Hero Field */
.album-hero-field {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.album-hero-preview {
position: relative;
width: 80px;
height: 54px;
border-radius: 6px;
overflow: hidden;
flex-shrink: 0;
border: 1px solid var(--admin-border);
}
.album-hero-preview img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.album-hero-remove {
position: absolute;
top: 2px;
right: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.65);
color: #fff;
border: none;
cursor: pointer;
font-size: 0.75rem;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.album-hero-empty {
font-size: 0.8rem;
color: var(--admin-text-muted);
font-style: italic;
}
.album-tile-uuid {
margin-top: 0.5rem;
}
.album-tile-uuid code {
font-size: 0.7rem;
color: var(--admin-text-muted);
word-break: break-all;
}
/* Subpage Overview Grid */
.subpage-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
.subpage-tile {
position: relative;
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
border-radius: 10px;
overflow: hidden;
cursor: pointer;
transition:
border-color 0.15s,
box-shadow 0.15s,
transform 0.1s;
}
.subpage-tile:hover {
border-color: var(--admin-border-hover);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
.subpage-tile.active {
border-color: var(--admin-accent);
box-shadow: 0 0 0 1px var(--admin-accent);
}
.subpage-tile-cover {
position: relative;
aspect-ratio: 16 / 10;
background: var(--admin-bg-elevated);
overflow: hidden;
}
.subpage-tile-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.subpage-tile-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
}
.subpage-tile-info {
padding: 0.4rem 0.6rem;
display: flex;
flex-direction: column;
gap: 0.05rem;
}
.subpage-tile-name {
font-size: 0.78rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.subpage-tile-meta {
font-size: 0.65rem;
color: var(--admin-text-muted);
}
/* Subpage Expanded Detail */
.subpage-detail {
background: var(--admin-bg);
border: 1px solid var(--admin-border);
border-radius: 10px;
overflow: hidden;
animation: slideDown 0.15s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.subpage-detail-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: var(--admin-bg-raised);
border-bottom: 1px solid var(--admin-border);
}
.subpage-detail-header-left {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
min-width: 0;
}
.subpage-detail-header-right {
display: flex;
align-items: center;
gap: 0.25rem;
}
.subpage-move-btns {
display: flex;
gap: 0;
}
.subpage-move-btns .admin-btn-icon {
font-size: 0.75rem;
padding: 0.1rem 0.3rem;
}
.subpage-name-input {
flex: 1;
background: transparent;
border: 1px solid transparent;
color: var(--admin-text);
font-size: 0.95rem;
font-weight: 600;
padding: 0.3rem 0.5rem;
border-radius: 4px;
transition: border-color 0.15s;
}
.subpage-name-input:hover {
border-color: var(--admin-border);
}
.subpage-name-input:focus {
outline: none;
border-color: var(--admin-border-focus);
background: var(--admin-bg-input);
}
.subpage-meta {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--admin-border);
}
.subpage-albums {
padding: 0.75rem 1rem;
}
.subpage-albums-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
font-size: 0.75rem;
color: var(--admin-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.subpage-sections {
padding: 0 1rem 0.5rem;
}
.subpage-actions {
display: flex;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-top: 1px solid var(--admin-border);
}
/* Section Card */
.section-card {
background: var(--admin-bg-input);
border: 1px solid var(--admin-border);
border-radius: 8px;
padding: 0.75rem;
margin-bottom: 0.5rem;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.section-title-input {
flex: 1;
background: transparent;
border: 1px solid transparent;
color: var(--admin-text);
font-size: 0.85rem;
font-weight: 500;
padding: 0.2rem 0.4rem;
border-radius: 4px;
}
.section-title-input:focus {
outline: none;
border-color: var(--admin-border-hover);
background: var(--admin-bg);
}
.section-desc-input {
font-size: 0.8rem !important;
padding: 0.4rem 0.5rem !important;
}

View File

@@ -0,0 +1,78 @@
/* The floating save bar. */
/* Split out of admin.css (#533). Loaded from admin.css in a fixed order —
later sheets override earlier ones, so the order there is the cascade. */
/* ── Dynamic Floating Save Bar ───────────────────────────────── */
.floating-save-bar {
position: fixed;
bottom: 1.5rem;
left: 50%;
transform: translateX(-50%);
background: var(--admin-bg-raised, #1f1f1f);
border: 1px solid var(--admin-border-hover, #444);
box-shadow:
0 10px 30px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.05);
border-radius: 16px;
padding: 0.75rem 1.25rem;
z-index: 900;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem;
width: calc(100% - 3rem);
max-width: 800px;
transition: border-color 0.2s ease;
animation: save-bar-in 0.2s ease-out;
}
.floating-save-bar.dirty {
border-color: var(--admin-accent);
}
/* Keyframes must carry the translateX(-50%) or the bar jumps horizontally. */
@keyframes save-bar-in {
from {
opacity: 0;
transform: translate(-50%, 1rem);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
.badge-pulse-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #ff9800;
display: inline-block;
margin-right: 6px;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba(255, 152, 0, 0.7);
}
70% {
transform: scale(1);
box-shadow: 0 0 0 8px rgba(255, 152, 0, 0);
}
100% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba(255, 152, 0, 0);
}
}
@media (prefers-reduced-motion: reduce) {
.floating-save-bar {
animation: none;
}
.badge-pulse-dot {
animation: none;
}
}

View File

@@ -0,0 +1,227 @@
/* The Google search-result snippet preview. */
/* Split out of admin.css (#533). Loaded from admin.css in a fixed order —
later sheets override earlier ones, so the order there is the cascade. */
/* ── Google Search Result Snippet Preview ────────────────────── */
.google-snippet-preview {
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
border-radius: 12px;
padding: 1rem 1.25rem;
margin-bottom: 1.25rem;
}
.google-snippet-header {
font-size: 0.8rem;
font-weight: 600;
color: var(--admin-text-muted);
margin-bottom: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.google-snippet-card {
background: #ffffff;
border-radius: 8px;
padding: 1rem 1.25rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
font-family: Arial, sans-serif;
color: #202124;
}
.google-snippet-url {
font-size: 0.78rem;
color: #202124;
margin-bottom: 0.2rem;
word-break: break-all;
}
.google-snippet-arrow {
font-size: 0.6rem;
color: #5f6368;
}
.google-snippet-title {
font-size: 1.15rem;
font-weight: 400;
color: #1a0dab;
line-height: 1.3;
margin-bottom: 0.25rem;
cursor: pointer;
}
.google-snippet-title:hover {
text-decoration: underline;
}
.google-snippet-desc {
font-size: 0.85rem;
color: #4d5156;
line-height: 1.4;
word-break: break-word;
}
/* Aspect Ratio Mini Preview Box */
.ratio-card-preview {
width: 100%;
aspect-ratio: 16 / 10;
display: flex;
align-items: center;
justify-content: center;
background: var(--admin-bg-elevated);
border-bottom: 1px solid var(--admin-border);
}
.mini-aspect-box {
width: 44px;
height: 44px;
border: 2px dashed var(--admin-accent);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.mini-aspect-box.ratio-1 {
width: 34px;
height: 34px;
}
.mini-aspect-box.ratio-3-2 {
width: 46px;
height: 30px;
}
.mini-aspect-box.ratio-2-3 {
width: 30px;
height: 44px;
}
.mini-aspect-box.ratio-16-9 {
width: 50px;
height: 28px;
}
.mini-aspect-box.ratio-auto {
width: 42px;
height: 32px;
border-style: solid;
}
.mini-aspect-inner {
width: 100%;
height: 100%;
background: var(--admin-accent);
opacity: 0.2;
}
/* Grid Layout Mini Demos */
/*
* Same framing as .ratio-card-preview. Without an explicit box here the demos
* (which size themselves with height: 100%) collapse to zero height.
*/
.grid-card-preview {
width: 100%;
aspect-ratio: 16 / 10;
background: var(--admin-bg-elevated);
border-bottom: 1px solid var(--admin-border);
overflow: hidden;
}
.mini-layout-demo {
width: 100%;
height: 100%;
padding: 0.35rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.demo-masonry-col-group {
display: flex;
gap: 0.25rem;
height: 100%;
width: 100%;
}
.demo-col {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.demo-tile {
background: var(--admin-text-secondary);
opacity: 0.35;
border-radius: 2px;
flex: 1;
transition:
background 0.15s ease,
opacity 0.15s ease;
}
/* The selected layout tints its diagram with the accent colour. */
.preset-card.active .demo-tile {
background: var(--admin-accent);
opacity: 0.55;
}
.demo-tile.h-high {
flex: 2;
}
.demo-tile.h-low {
flex: 1;
}
.demo-tile.h-med {
flex: 1.5;
}
.demo-uniform-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.25rem;
height: 100%;
}
.demo-showcase-grid {
display: flex;
gap: 0.25rem;
height: 100%;
}
/* Deliberately NOT named .hero-tile — that class belongs to the page builder
* and its `aspect-ratio` would leak into this demo. */
.demo-tile.demo-hero {
flex: 2;
}
.demo-filmstrip-row {
display: flex;
gap: 0.25rem;
height: 100%;
align-items: center;
}
/* A band of wide tiles running off the right edge — the horizontal scroll hint
* that separates this demo from the uniform grid. */
.demo-tile.strip {
width: 46%;
height: 62%;
flex-shrink: 0;
}
.demo-editorial-flow {
display: flex;
flex-direction: column;
gap: 0.25rem;
height: 100%;
}
.demo-tile.wide {
width: 100%;
height: 45%;
}
.demo-row {
display: flex;
gap: 0.25rem;
flex: 1;
}

View File

@@ -0,0 +1,699 @@
/* Sidebar nav, preset preview mockups, the status badge and its dropdown, grouped feature settings, the doctor report. */
/* Split out of admin.css (#533). Loaded from admin.css in a fixed order —
later sheets override earlier ones, so the order there is the cascade. */
/* ── Settings Editor ──────────────────────────────────────── */
.settings-live-sync-note {
margin: 0 0 1.25rem;
font-size: 0.75rem;
color: var(--admin-text-muted);
display: flex;
align-items: center;
gap: 6px;
}
.settings-layout {
display: grid;
grid-template-columns: 160px 1fr;
gap: 2rem;
}
.settings-nav {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
/* Rendered as an <a> — each settings section is its own route. */
.settings-nav-item {
background: none;
border: none;
color: var(--admin-text-muted);
text-align: left;
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
border-radius: 6px;
cursor: pointer;
transition: all 0.1s;
display: block;
text-decoration: none;
}
.settings-nav-item:hover {
color: var(--admin-text);
background: var(--admin-bg-raised);
}
.settings-nav-item.active {
color: var(--admin-text);
background: var(--admin-bg-elevated);
}
.settings-content {
min-width: 0;
}
.settings-panel h3 {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 1rem;
}
/* Preset Card Grid */
.preset-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1rem;
width: 100%;
}
.preset-card {
display: flex;
flex-direction: column;
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
border-radius: 10px;
overflow: hidden;
cursor: pointer;
padding: 0;
text-align: left;
transition:
transform 0.2s cubic-bezier(0.16, 1, 0.3, 1),
border-color 0.2s,
box-shadow 0.2s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.preset-card:hover {
transform: translateY(-2px);
border-color: var(--admin-border-hover);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.preset-card.active {
border-color: var(--admin-accent);
box-shadow: 0 0 0 2px var(--admin-accent);
}
/* ── Preset Mini Preview Mockup ──────────────────────────────────
* Driven entirely by the --preset-* custom properties the card sets inline,
* so each mockup reflects that preset's real background, tile colour, corner
* radius, grid density and photo frame instead of looking identical.
*/
.preset-card-preview {
width: 100%;
aspect-ratio: 16 / 10;
padding: 0.55rem;
display: flex;
flex-direction: column;
gap: 0.45rem;
border-bottom: 1px solid var(--admin-border);
position: relative;
overflow: hidden;
background: var(--preset-bg);
}
.preset-card-preview .mini-header {
display: flex;
align-items: baseline;
gap: 0.4rem;
flex-shrink: 0;
}
/* Type specimen: generic families stand in for the preset's real webfont,
* which is not loaded inside the admin panel. */
.preset-card-preview .mini-specimen {
font-size: 0.95rem;
line-height: 1;
color: var(--preset-accent);
}
.preset-card-preview .mini-specimen.type-serif {
font-family: Georgia, 'Times New Roman', serif;
}
.preset-card-preview .mini-specimen.type-sans {
font-family: 'Helvetica Neue', Arial, sans-serif;
font-weight: 600;
letter-spacing: -0.02em;
}
.preset-card-preview .mini-specimen.type-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85rem;
}
.preset-card-preview .mini-line {
height: 2px;
flex: 1;
max-width: 46px;
border-radius: 1px;
background: var(--preset-accent);
opacity: 0.4;
}
.preset-card.active .preset-card-preview .mini-line {
opacity: 1;
}
.preset-card-preview .mini-grid {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--preset-gap, 4px);
}
.preset-card-preview .mini-tile {
background: var(--preset-tile);
border-radius: var(--preset-radius, 0);
/* Keeps near-white tiles on a white page (Minimal, Classic) readable. */
box-shadow: inset 0 0 0 1px rgba(128, 128, 128, 0.16);
}
/* Passepartout: a mat board in the page colour framing the photo. */
.preset-card-preview[data-frame='passepartout'] .mini-tile {
padding: 4px;
background-clip: content-box;
border: 1px solid rgba(128, 128, 128, 0.3);
}
.preset-card-preview[data-frame='shadow'] .mini-tile {
box-shadow:
0 2px 5px rgba(0, 0, 0, 0.28),
inset 0 0 0 1px rgba(128, 128, 128, 0.16);
}
.preset-card-specs {
font-size: 0.62rem;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
color: var(--admin-text-muted);
opacity: 0.75;
margin-top: 0.15rem;
}
.preset-card-info {
padding: 0.6rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.preset-card-name {
font-size: 0.85rem;
font-weight: 600;
color: var(--admin-text);
text-transform: capitalize;
}
.preset-card-desc {
font-size: 0.68rem;
color: var(--admin-text-muted);
line-height: 1.3;
}
/* ── Status Diagnostics & Badge ──────────────────────────────── */
.status-indicator-container {
position: relative;
display: inline-block;
margin-right: 0.5rem;
}
/*
* A status readout, not an action: same height and typography as the buttons
* beside it, but pill-shaped so the row still reads as "state | actions".
* It inherited the browser's default button font before, which is why it looked
* like it came from a different app.
*/
.status-badge-btn {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0 0.85rem;
min-height: var(--admin-control-height);
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
border-radius: 999px;
font-family: inherit;
font-size: 0.8rem;
font-weight: 500;
line-height: 1;
color: var(--admin-text-secondary);
cursor: pointer;
transition: all 0.15s;
}
.status-badge-btn:hover {
background: var(--admin-bg-elevated);
border-color: var(--admin-border-hover);
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
position: relative;
}
/* Healthy is the resting state — no animation. A permanently pulsing dot pulls
* the eye toward the one thing that needs no attention. */
.status-badge-btn.connected .status-dot {
background-color: var(--admin-success, #10b981);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.18);
}
.status-badge-btn.disconnected .status-dot {
background-color: var(--admin-error, #ef4444);
box-shadow: 0 0 8px var(--admin-error, #ef4444);
animation: pulse-red 1.5s infinite;
}
/* The diagnostics request itself did not run — deliberately not red, and not
animated: an unanswered check is not a fault report. */
.status-badge-btn.unknown .status-dot {
background-color: var(--admin-text-secondary);
}
.status-text {
font-size: inherit;
font-weight: inherit;
color: inherit;
}
/* Status Dropdown */
.status-dropdown-backdrop {
position: fixed;
inset: 0;
z-index: 99;
background: transparent;
}
.status-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 0.5rem;
width: 260px;
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
border-radius: 12px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
z-index: 100;
overflow: hidden;
animation: slideDown 0.15s cubic-bezier(0.16, 1, 0.3, 1);
}
.status-dropdown-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.65rem 0.85rem;
border-bottom: 1px solid var(--admin-border);
background: var(--admin-bg-elevated);
}
.status-dropdown-header h4 {
margin: 0;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--admin-text-muted);
}
.status-refresh-btn {
background: none;
border: none;
color: var(--admin-text-muted);
cursor: pointer;
font-size: 0.95rem;
padding: 0 0.2rem;
line-height: 1;
transition: color 0.15s;
}
.status-refresh-btn:hover {
color: var(--admin-text);
}
.status-dropdown-body {
padding: 0.5rem 0.85rem;
display: flex;
flex-direction: column;
}
/* ── Grouped feature settings (#510) ──────────────────────────
The features panel used to be one flat grid of identical cards. These give it
the structure the config already had: labelled groups, and sub-settings that
look like sub-settings instead of peers. */
.settings-group {
margin-bottom: 1.75rem;
}
.settings-group-head {
display: flex;
flex-direction: column;
gap: 0.2rem;
margin-bottom: 0.65rem;
}
.settings-group-title {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--admin-text-secondary);
}
/* Marks a group whose switches publish something, so it does not read like the
one next to it that controls a fade. */
.settings-group-chip {
margin-left: 4px;
padding: 2px 7px;
border: 1px solid var(--admin-border);
border-radius: 999px;
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.06em;
color: var(--admin-text-secondary);
}
.settings-group-desc {
font-size: 0.75rem;
color: var(--admin-text-muted);
line-height: 1.35;
}
.settings-subpanel {
padding: 1rem 1.25rem;
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
border-radius: 12px;
}
.settings-subpanel-head {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding-bottom: 0.75rem;
}
.settings-subpanel-title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.88rem;
font-weight: 600;
color: var(--admin-text);
}
.settings-subpanel-desc {
font-size: 0.75rem;
color: var(--admin-text-muted);
}
/* Quiet marker on an album Immich still considers private (#515). Deliberately
not styled as a warning: publishing it is allowed, this only says what it is. */
.picker-item-note {
display: inline-flex;
align-items: center;
margin-top: 0.2rem;
font-size: 0.68rem;
letter-spacing: 0.02em;
color: var(--admin-text-muted);
}
/* ── Config doctor report (#491) ──────────────────────────────
Each finding is one line with a severity dot: the point is scanning a list
for the ones that are not green, not reading prose. */
.doctor-list {
margin: 0;
padding: 0;
list-style: none;
}
.doctor-item {
display: flex;
align-items: flex-start;
gap: 0.7rem;
padding: 0.8rem 0;
border-bottom: 1px solid var(--admin-border);
}
.doctor-item:last-child {
border-bottom: none;
}
.doctor-dot {
width: 8px;
height: 8px;
margin-top: 0.42rem;
border-radius: 50%;
flex-shrink: 0;
background: var(--admin-success);
}
.doctor-item--warn .doctor-dot {
background: var(--admin-accent);
}
.doctor-item--error .doctor-dot {
background: var(--admin-error);
}
.doctor-item-body {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.doctor-item-title {
font-size: 0.84rem;
font-weight: 600;
color: var(--admin-text);
}
/* A finding that needs nothing recedes; the eye should land on the others. */
.doctor-item--ok .doctor-item-title {
font-weight: 500;
color: var(--admin-text-secondary);
}
.doctor-item-detail {
font-size: 0.75rem;
line-height: 1.45;
color: var(--admin-text-muted);
}
/* The metadata group as one card with an expander: four permanently visible
sub-switches read as four decisions, when they are details of one. */
.metadata-card {
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
border-radius: 12px;
overflow: hidden;
transition: border-color 0.15s ease;
}
.metadata-card.active {
border-color: var(--admin-accent);
box-shadow: 0 0 0 1px var(--admin-accent);
}
.metadata-card-main {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
background: none;
border: none;
color: inherit;
text-align: left;
font: inherit;
cursor: pointer;
}
.metadata-details-btn {
display: inline-flex;
align-items: center;
gap: 5px;
margin: 0 1.25rem 0.9rem;
padding: 0;
background: none;
border: none;
color: var(--admin-text-secondary);
font: inherit;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
cursor: pointer;
}
.metadata-details-btn:hover {
color: var(--admin-text);
}
.metadata-details-chevron {
transition: transform 0.15s ease;
}
.metadata-details-chevron.open {
transform: rotate(180deg);
}
.metadata-details {
padding: 0 1.25rem 0.6rem;
border-top: 1px solid var(--admin-border);
}
/* The first row sits directly under that border and does not need its own. */
.metadata-details .setting-row:first-child {
border-top: none;
}
.setting-row {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.7rem 0;
background: none;
border: none;
border-top: 1px solid var(--admin-border);
color: inherit;
text-align: left;
cursor: pointer;
font: inherit;
}
/* A sub-setting of the rows above it — indented so the dependency is visible
rather than only true. */
.setting-row--indented {
padding-left: 0.9rem;
margin-top: 0.35rem;
}
.setting-row--disabled {
cursor: default;
}
.setting-row--disabled .setting-row-title,
.setting-row--disabled .setting-row-desc {
color: var(--admin-text-muted);
}
.setting-row-info {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.setting-row-title {
font-size: 0.81rem;
font-weight: 500;
color: var(--admin-text);
}
.setting-row-desc {
font-size: 0.72rem;
color: var(--admin-text-muted);
line-height: 1.3;
}
/* Says why an inert row is inert, instead of leaving it looking broken. */
.setting-row-hint {
display: inline-flex;
align-items: center;
gap: 5px;
margin-top: 0.2rem;
font-size: 0.69rem;
color: var(--admin-text-secondary);
}
.switch-toggle--sm {
width: 34px;
height: 20px;
border-radius: 10px;
}
.switch-toggle--sm .switch-slider {
width: 14px;
height: 14px;
}
.switch-toggle--sm.on .switch-slider {
transform: translateX(14px);
}
/* An inert switch must not look like an off switch someone can turn on. */
.setting-row--disabled .switch-toggle {
background: var(--admin-bg-elevated);
}
.setting-row--disabled .switch-slider {
background: var(--admin-text-muted);
}
/* Explains an unfinished installation before the indicators below it, which
would otherwise read as a fault rather than a pending step. */
.status-setup-note {
margin: 0 0 0.35rem;
padding: 0.5rem 0.6rem;
border-radius: 6px;
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
color: var(--admin-text-secondary);
font-size: 0.75rem;
line-height: 1.45;
}
.status-setup-note a {
color: var(--admin-text);
text-decoration: underline;
}
.status-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.45rem 0;
border-bottom: 1px dashed var(--admin-border);
font-size: 0.78rem;
}
.status-item:last-child {
border-bottom: none;
}
.status-label {
color: var(--admin-text-muted);
}
.status-val {
font-weight: 500;
color: var(--admin-text);
}
.status-val.ok {
color: var(--admin-success, #10b981);
}
.status-val.error {
color: var(--admin-error, #ef4444);
}
.status-val.update {
color: var(--admin-warning, #f59e0b);
}
.status-val.unknown {
color: var(--admin-text-secondary);
}

115
app/admin/styles/shell.css Normal file
View File

@@ -0,0 +1,115 @@
/* Dashboard layout, tab bar, save bar. */
/* Split out of admin.css (#533). Loaded from admin.css in a fixed order —
later sheets override earlier ones, so the order there is the cascade. */
/* ── Dashboard Layout ──────────────────────────────────────── */
.admin-dashboard {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.admin-header {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.5rem;
background: var(--admin-bg);
border-bottom: 1px solid var(--admin-border);
backdrop-filter: blur(8px);
}
.admin-header h1 {
font-size: 1rem;
font-weight: 600;
margin: 0;
white-space: nowrap;
}
.admin-header-left {
display: flex;
align-items: center;
gap: 1.5rem;
}
.admin-header-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.admin-header-divider {
width: 1px;
height: 20px;
background: var(--admin-border);
margin: 0 0.25rem;
}
.admin-tabs {
display: flex;
gap: 0;
}
/* Rendered as an <a> — each tab is its own route. */
.admin-tab {
background: none;
border: none;
color: var(--admin-text-muted);
padding: 0.5rem 1rem;
font-size: 0.85rem;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.15s;
display: inline-block;
text-decoration: none;
}
.admin-tab:hover {
color: var(--admin-text-secondary);
}
.admin-tab.active {
color: var(--admin-text);
border-bottom-color: var(--admin-text);
}
.admin-main {
flex: 1;
/* Extra bottom padding so the floating save bar never covers page content. */
padding: 1.5rem 1.5rem 6rem;
max-width: 1120px;
margin: 0 auto;
width: 100%;
}
/* ── Save Bar ──────────────────────────────────────────────── */
.save-bar-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.unsaved-badge {
display: inline-flex;
align-items: center;
font-size: 0.75rem;
color: var(--admin-accent);
font-weight: 500;
}
.save-message {
font-size: 0.75rem;
font-weight: 500;
}
.save-message.success {
color: var(--admin-success);
}
.save-message.error {
color: var(--admin-error);
}

View File

@@ -0,0 +1,325 @@
/* Subpage tile badges and hover actions, the editor sheet, the two-column layout shared by the editor sheets. */
/* Split out of admin.css (#533). Loaded from admin.css in a fixed order —
later sheets override earlier ones, so the order there is the cascade. */
/* ── Subpage Tile Badges & Hover Actions ─────────────────────── */
.subpage-badge-protected {
position: absolute;
top: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(8px);
color: #ffca28;
font-size: 0.7rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 12px;
border: 1px solid rgba(255, 202, 40, 0.3);
z-index: 2;
}
.subpage-hover-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(3px);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
}
.subpage-tile:hover .subpage-hover-overlay {
opacity: 1;
}
.hover-action-btn {
font-size: 0.78rem;
font-weight: 600;
color: #ffffff;
background: var(--admin-accent, #e60012);
padding: 0.4rem 0.85rem;
border-radius: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.subpage-tile-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.subpage-tile-slug {
font-size: 0.7rem;
color: var(--admin-text-muted);
font-family: var(--font-mono, monospace);
background: var(--admin-bg-input);
padding: 1px 6px;
border-radius: 4px;
}
/* ── Subpage Editor Sheet (centered two-column modal) ─────────── */
/* z-index 999 is load-bearing: the album detail modal (1000) and the
portalled Listbox popup (1100) must stack above this sheet. */
.subpage-drawer-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(6px);
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
animation: fadeIn 0.2s ease-out;
}
.subpage-drawer-container {
width: 100%;
max-width: 1100px;
max-height: 90vh;
background: var(--admin-bg-card, #181818);
border: 1px solid var(--admin-border, #333);
border-radius: 16px;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.4);
/* No fill-mode: after the entry animation the computed transform must
return to none, so the container never becomes a containing block for
fixed-position descendants (Listbox popup measurement). */
animation: sheetIn 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes sheetIn {
from {
opacity: 0;
transform: scale(0.96) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.subpage-drawer-header {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--admin-border);
display: flex;
align-items: center;
justify-content: space-between;
background: var(--admin-bg-raised);
}
.subpage-drawer-breadcrumb {
font-size: 0.75rem;
color: var(--admin-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
}
.subpage-drawer-title {
margin: 0.2rem 0 0 0;
font-size: 1.2rem;
font-weight: 700;
color: var(--admin-text);
}
.subpage-drawer-header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.subpage-drawer-body {
padding: 1.5rem;
overflow-y: auto;
overscroll-behavior: contain;
flex: 1;
/* Required so the flex child scrolls under max-height instead of growing */
min-height: 0;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.subpage-drawer-footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--admin-border);
background: var(--admin-bg-raised);
display: flex;
align-items: center;
justify-content: space-between;
}
/* ── Shared two-column layout for admin editor sheets ─────────── */
/* Used by the subpage editor and the album detail editor so both read as the
same surface. Columns always live inside their sheet's single body scroller
— never give a column its own overflow, a nested scroller clips the
portalled Listbox popup (see the .album-drawer-body note). */
.admin-sheet-columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
align-items: start;
}
/* The subpage editor is asymmetric on purpose: the left column is a settings
form with a natural maximum, the right one holds the album list and wants
every pixel it can get. */
.admin-sheet-columns--settings-aside {
grid-template-columns: minmax(380px, 460px) 1fr;
}
.admin-sheet-col {
display: flex;
flex-direction: column;
gap: 1.25rem;
min-width: 0;
}
.admin-sheet-col--divided {
border-left: 1px solid var(--admin-border);
padding-left: 2rem;
}
@media (max-width: 1024px) {
.admin-sheet-columns,
.admin-sheet-columns--settings-aside {
grid-template-columns: 1fr;
gap: 1.25rem;
}
.admin-sheet-col--divided {
border-left: none;
padding-left: 0;
}
}
@media (max-width: 768px) {
.subpage-drawer-backdrop {
padding: 0;
}
.subpage-drawer-container {
max-width: none;
max-height: none;
height: 100dvh;
border-radius: 0;
border: none;
}
}
@media (prefers-reduced-motion: reduce) {
.subpage-drawer-container,
.subpage-drawer-backdrop {
animation: none;
}
}
/* Breadcrumb + title stack on the left of the drawer header. */
.subpage-drawer-title-group {
display: flex;
flex-direction: column;
min-width: 0;
}
/* One thematic group of fields inside the drawer body. */
.subpage-drawer-section {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
border-radius: 8px;
}
.subpage-drawer-section > .admin-field:only-child {
margin-bottom: 0;
}
/* Heading + hint at the top of a drawer section. */
.drawer-section-heading h4 {
margin: 0;
font-size: 1rem;
display: flex;
align-items: center;
gap: 8px;
}
.drawer-section-heading p {
margin: 4px 0 0;
font-size: 0.8rem;
color: var(--admin-text-muted);
}
/* Published / hidden marker, replacing the traffic-light emoji. */
/* Named apart from the header badge's .status-dot, which is a different dot. */
.page-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
display: inline-block;
margin-right: 2px;
}
.page-status-dot.is-on {
background: #22c55e;
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.18);
}
.page-status-dot.is-off {
background: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.18);
}
.input-with-icon {
position: relative;
display: flex;
align-items: center;
width: 100%;
}
.input-with-icon > input {
width: 100%;
}
.input-slug-wrapper {
position: relative;
display: flex;
align-items: center;
}
.input-slug-preview {
position: absolute;
right: 12px;
font-size: 0.75rem;
font-family: var(--font-mono, monospace);
color: var(--admin-text-muted);
pointer-events: none;
background: var(--admin-bg-elevated);
padding: 2px 8px;
border-radius: 4px;
}
.password-input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.password-icon {
position: absolute;
left: 12px;
font-size: 0.85rem;
pointer-events: none;
}
.password-input-wrapper input {
padding-left: 2.25rem !important;
}

View File

@@ -0,0 +1,442 @@
/* Photo-frame and hero-style cards, the global button system, the preset and aspect-ratio grids, the theme section. */
/* Split out of admin.css (#533). Loaded from admin.css in a fixed order —
later sheets override earlier ones, so the order there is the cascade. */
/* ── Visual Cards for Photo Frame & Hero Style ────────────────── */
.frame-card-preview,
.hero-card-preview {
width: 100%;
aspect-ratio: 16 / 10;
padding: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
background: var(--admin-bg-elevated);
border-bottom: 1px solid var(--admin-border);
position: relative;
overflow: hidden;
}
/* Photo Frame Demos */
.mini-frame-demo {
width: 70%;
height: 70%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.mini-frame-photo {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #444, #222);
border-radius: 2px;
}
.mini-frame-demo.frame-none .mini-frame-photo {
border: none;
}
.mini-frame-demo.frame-passepartout {
padding: 6px;
background: #ffffff;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1);
}
.mini-frame-demo.frame-shadow .mini-frame-photo {
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4);
}
/* Hero Style Demos */
.mini-hero-demo {
width: 100%;
height: 100%;
display: flex;
gap: 0.35rem;
padding: 0.25rem;
}
.demo-line {
height: 3px;
background: var(--admin-text-muted);
border-radius: 2px;
}
.demo-line.title {
width: 80%;
height: 5px;
background: var(--admin-accent);
}
.demo-line.title.giant {
width: 95%;
height: 8px;
margin-bottom: 0.2rem;
}
.demo-line.title.short {
width: 50%;
margin: 0 auto;
}
.demo-line.title.light {
width: 60%;
background: #ffffff;
}
.demo-line.sub {
width: 50%;
}
.hero-demo-split {
align-items: center;
}
.hero-demo-text {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.hero-demo-photo {
flex: 1;
height: 100%;
background: linear-gradient(135deg, #3a3a3a, #1a1a1a);
border-radius: 3px;
}
.hero-demo-photo.small {
height: 60%;
width: 80%;
margin: 0 auto;
}
.hero-demo-photo.banner {
width: 100%;
height: 65%;
}
.hero-demo-full {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #555, #111);
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
}
.hero-demo-minimal,
.hero-demo-stacked {
flex-direction: column;
justify-content: center;
}
.hero-demo-center-text {
text-align: center;
width: 100%;
margin-bottom: 0.2rem;
}
.hero-demo-typographic {
flex-direction: column;
}
.hero-demo-grid2 {
display: flex;
gap: 0.25rem;
flex: 1;
}
.hero-demo-mosaic {
display: flex;
gap: 0.25rem;
width: 100%;
height: 100%;
}
.hero-demo-photo.big {
flex: 1.5;
}
.hero-demo-photo-col {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
/* ── Global Button System ────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
font-family: var(--font-sans, system-ui, sans-serif);
font-size: 0.82rem;
font-weight: 600;
border-radius: 8px;
border: 1px solid transparent;
cursor: pointer;
transition: all 0.15s ease;
line-height: 1.2;
}
.btn-sm {
padding: 0.35rem 0.65rem !important;
font-size: 0.75rem !important;
border-radius: 6px !important;
}
.btn-secondary {
background: var(--admin-bg-elevated, #242424);
border-color: var(--admin-border, #333333);
color: var(--admin-text, #ffffff);
}
.btn-secondary:hover {
background: var(--admin-bg-raised, #2e2e2e);
border-color: var(--admin-border-hover, #444444);
}
.btn-danger {
background: #ef4444;
color: #ffffff;
border-color: #ef4444;
}
.btn-danger:hover {
background: #dc2626;
border-color: #dc2626;
}
.btn-outline-danger {
background: transparent;
border-color: rgba(239, 68, 68, 0.4);
color: #ef4444;
}
.btn-outline-danger:hover {
background: rgba(239, 68, 68, 0.12);
border-color: #ef4444;
}
/* ── Preset Grid & Buttons (Grid Layout & Aspect Ratio) ───────── */
.preset-grid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.35rem;
}
.preset-btn {
padding: 0.45rem 0.85rem;
background: var(--admin-bg-input, #1c1c1c);
border: 1px solid var(--admin-border, #333333);
border-radius: 8px;
color: var(--admin-text-secondary, #cccccc);
font-family: var(--font-sans, system-ui, sans-serif);
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
text-transform: capitalize;
}
.preset-btn:hover {
background: var(--admin-bg-elevated, #282828);
border-color: var(--admin-border-hover, #555555);
color: var(--admin-text, #ffffff);
}
.preset-btn.active {
background: var(--admin-bg-elevated, #2a2a2a);
border-color: var(--admin-accent, #e60012);
color: var(--admin-accent, #e60012);
font-weight: 600;
box-shadow: 0 0 0 1px var(--admin-accent, #e60012);
}
/* ── Theme Settings Section Upgrades ─────────────────────────── */
.theme-settings-panel {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.settings-section-header {
margin-bottom: 0.5rem;
}
.settings-section-header h3 {
margin: 0 0 0.25rem 0;
font-size: 1.1rem;
font-weight: 700;
color: var(--admin-text);
}
.settings-section-sub {
margin: 0;
font-size: 0.8rem;
color: var(--admin-text-muted);
}
.settings-section-divider {
height: 1px;
background: var(--admin-border);
margin: 0.75rem 0;
}
/* Segmented Control (Mode Toggle) */
.segmented-control {
display: inline-flex;
background: var(--admin-bg-input, #1c1c1c);
padding: 4px;
border-radius: 10px;
border: 1px solid var(--admin-border, #333);
gap: 4px;
}
.segment-btn {
padding: 0.5rem 1.1rem;
font-size: 0.82rem;
font-weight: 600;
border-radius: 7px;
border: none;
background: transparent;
color: var(--admin-text-muted);
cursor: pointer;
transition: all 0.15s ease;
}
.segment-btn:hover {
color: var(--admin-text);
}
.segment-btn.active {
background: var(--admin-bg-elevated, #2d2d2d);
color: var(--admin-text, #ffffff);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
/* Accent Color Swatches */
.accent-picker-wrapper {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.color-swatches-row {
display: flex;
align-items: center;
gap: 0.65rem;
flex-wrap: wrap;
}
.color-swatch-btn {
width: 32px;
height: 32px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
transition:
transform 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.color-swatch-btn:hover {
transform: scale(1.15);
}
.color-swatch-btn.active {
border-color: #ffffff;
box-shadow: 0 0 0 2px var(--admin-accent, #e60012);
transform: scale(1.15);
}
/* Custom Toggle Cards Grid */
.admin-toggle-cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.admin-toggle-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
border-radius: 12px;
cursor: pointer;
text-align: left;
transition: all 0.15s ease;
}
.admin-toggle-card:hover {
border-color: var(--admin-border-hover);
background: var(--admin-bg-elevated);
}
.admin-toggle-card.active {
border-color: var(--admin-accent);
box-shadow: 0 0 0 1px var(--admin-accent);
}
.toggle-card-info {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding-right: 1rem;
}
.toggle-card-title {
font-size: 0.88rem;
font-weight: 600;
color: var(--admin-text);
display: inline-flex;
align-items: center;
gap: 6px;
}
.toggle-card-desc {
font-size: 0.75rem;
color: var(--admin-text-muted);
line-height: 1.3;
}
/* iOS Style Switch Slider */
.switch-toggle {
width: 42px;
height: 24px;
background: var(--admin-border, #444);
border-radius: 12px;
position: relative;
transition: background-color 0.2s ease;
flex-shrink: 0;
}
.switch-toggle.on {
background: var(--admin-accent, #e60012);
}
.switch-slider {
width: 18px;
height: 18px;
background: #ffffff;
border-radius: 50%;
position: absolute;
top: 3px;
left: 3px;
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.switch-toggle.on .switch-slider {
transform: translateX(18px);
}

View File

@@ -0,0 +1,63 @@
/* Theme variables — every other sheet here reads them. */
/* Split out of admin.css (#533). Loaded from admin.css in a fixed order —
later sheets override earlier ones, so the order there is the cascade. */
/* ── Theme Variables ───────────────────────────────────────── */
/* .admin-listbox-popup is listed alongside .admin-layout because it is
portalled to <body>, 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;
}

View File

@@ -0,0 +1,126 @@
/* Drag handles, the preview/slug line, the danger icon button, and the shared responsive rules. */
/* Split out of admin.css (#533). Loaded from admin.css in a fixed order —
later sheets override earlier ones, so the order there is the cascade. */
/* ── Drag Handles ──────────────────────────────────────────── */
.hero-tile-drag,
.album-tile-drag,
.subpage-tile-drag {
position: absolute;
z-index: 5;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.8);
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
border-radius: 4px;
cursor: grab;
opacity: 0;
transition: opacity 0.15s;
user-select: none;
touch-action: none;
}
.hero-tile-drag:active,
.album-tile-drag:active,
.subpage-tile-drag:active {
cursor: grabbing;
}
.hero-tile-drag {
top: 6px;
left: 6px;
}
.album-tile-drag {
top: 6px;
left: 6px;
}
.subpage-tile-drag {
top: 6px;
left: 6px;
}
.hero-tile:hover .hero-tile-drag,
.album-tile:hover .album-tile-drag,
.subpage-tile:hover .subpage-tile-drag {
opacity: 1;
}
/* ── Preview & Slug ───────────────────────────────────────────── */
.subpage-slug-preview {
font-size: 0.72rem;
color: var(--admin-text-muted);
font-family: monospace;
padding: 0.15rem 0.4rem;
background: var(--admin-bg);
border-radius: 3px;
white-space: nowrap;
}
.save-bar-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.admin-btn-preview {
font-size: 0.8rem;
padding: 0.4rem 0.8rem;
text-decoration: none;
}
/* ── Danger icon button ───────────────────────────────────────── */
.admin-btn-icon-danger:hover {
color: var(--admin-error) !important;
}
/* ── Responsive ──────────────────────────────────────────────── */
@media (max-width: 768px) {
.admin-main {
padding: 1rem 1rem 6rem;
}
.floating-save-bar {
bottom: 1rem;
width: calc(100% - 2rem);
padding: 0.625rem 0.875rem;
gap: 0.5rem;
}
.admin-header {
padding: 0.5rem 1rem;
}
.admin-header h1 {
display: none;
}
.settings-layout {
grid-template-columns: 1fr;
gap: 1rem;
}
.settings-nav {
flex-direction: row;
overflow-x: auto;
gap: 0;
}
.settings-nav-item {
white-space: nowrap;
}
.admin-field-row {
grid-template-columns: 1fr;
}
}

View File

@@ -72,11 +72,11 @@
/* Photo grid: warm passepartout, elegant hover */ /* Photo grid: warm passepartout, elegant hover */
[data-preset='classic'] .photo-grid { [data-preset='classic'] .photo-grid {
column-gap: var(--grid-gap, 20px); /* Declared, not a var() fallback. The base .photo-grid sets --grid-gap: 12px,
} so `var(--grid-gap, 20px)` never reached its fallback and this preset
silently rendered at 12px from v0.12.0 on (#513). The base rules read
[data-preset='classic'] .photo-grid__item { --grid-gap for both axes, so declaring it here is all this needs. */
margin-bottom: var(--grid-gap, 20px); --grid-gap: 20px;
} }
[data-preset='classic'] .photo-grid__item:hover img { [data-preset='classic'] .photo-grid__item:hover img {

View File

@@ -58,9 +58,10 @@
--grid-gap: 32px; --grid-gap: 32px;
} }
[data-preset='editorial'] .photo-grid__item { /* No row spacing here on purpose. The base .photo-grid__item rule reads
margin-bottom: 32px; var(--grid-gap), which inherits the 32px declared above — so the default look
} is unchanged and an explicit grid.gap now moves rows as well as columns.
A hardcoded margin-bottom here was the other half of #513. */
[data-preset='editorial'] .photo-grid__item img { [data-preset='editorial'] .photo-grid__item img {
transition: transition:

View File

@@ -98,7 +98,8 @@
} }
[data-preset='minimal'] .photo-grid__item { [data-preset='minimal'] .photo-grid__item {
margin-bottom: 2px; /* Row spacing deliberately omitted — the base rule reads var(--grid-gap) and
inherits the 2px above, so grid.gap moves both axes (#513). */
border-radius: 0; border-radius: 0;
cursor: pointer; cursor: pointer;
} }

View File

@@ -36,7 +36,10 @@
[data-preset='monograph'] .photo-grid__item { [data-preset='monograph'] .photo-grid__item {
counter-increment: photo-index; counter-increment: photo-index;
margin-bottom: 48px; /* Rows sit looser than columns by design — 48px against 40px. Derived from
--grid-gap rather than hardcoded so an explicit grid.gap keeps that ratio
instead of leaving the rows behind (#513). */
margin-bottom: calc(var(--grid-gap, 40px) * 1.2);
} }
[data-preset='monograph'] .photo-grid__item::before { [data-preset='monograph'] .photo-grid__item::before {

View File

@@ -783,11 +783,14 @@
/* ── Fotoraster ─────────────────────────────────────────────── */ /* ── Fotoraster ─────────────────────────────────────────────── */
[data-preset='studio-modern'] .photo-grid { [data-preset='studio-modern'] .photo-grid {
column-gap: var(--grid-gap, 20px); /* Declared, not a var() fallback. The base .photo-grid sets --grid-gap: 12px,
so `var(--grid-gap, 20px)` never reached its fallback and this preset
silently rendered at 12px from v0.12.0 on (#513). The base rules read
--grid-gap for both axes, so declaring it here is all this needs. */
--grid-gap: 20px;
} }
[data-preset='studio-modern'] .photo-grid__item { [data-preset='studio-modern'] .photo-grid__item {
margin-bottom: var(--grid-gap, 20px);
border-radius: 0; border-radius: 0;
} }

View File

@@ -201,3 +201,8 @@ This is useful after making external changes to config files or when Immich data
The panel reports the health of the installation: whether Immich answers, whether `gallery.yaml` and `settings.yaml` parse, the number of cached entries, and the backup count with the timestamp of the most recent one. `settings.yaml` is optional, so its absence is not a fault — only a file that exists and cannot be parsed counts as invalid. A check that could not run (an expired session, for example) is reported as such rather than as an outage. The panel reports the health of the installation: whether Immich answers, whether `gallery.yaml` and `settings.yaml` parse, the number of cached entries, and the backup count with the timestamp of the most recent one. `settings.yaml` is optional, so its absence is not a fault — only a file that exists and cannot be parsed counts as invalid. A check that could not run (an expired session, for example) is reported as such rather than as an outage.
Separately, while you are logged in, the public site shows a diagnostic banner on any album Immich returns empty — the case that otherwise looks like a broken page to you and to nobody else. Separately, while you are logged in, the public site shows a diagnostic banner on any album Immich returns empty — the case that otherwise looks like a broken page to you and to nobody else.
The same checks are available from the terminal as `npm run doctor`, which
needs neither a running app nor this password — see
[Config Doctor](deployment.md#config-doctor). Use it when the site does not
come up far enough to reach this panel.

View File

@@ -14,6 +14,7 @@ For the quick path — clone, `npm run dev`, open `/install` — see the
- [Docker Compose](#docker-compose) - [Docker Compose](#docker-compose)
- [Standalone Docker](#standalone-docker) - [Standalone Docker](#standalone-docker)
- [Health Check](#health-check) - [Health Check](#health-check)
- [Config Doctor](#config-doctor)
- [Behaviour when Immich is Unreachable](#behaviour-when-immich-is-unreachable) - [Behaviour when Immich is Unreachable](#behaviour-when-immich-is-unreachable)
- [Reverse Proxy](#reverse-proxy) - [Reverse Proxy](#reverse-proxy)
@@ -100,6 +101,68 @@ The container includes a built-in health check at `/api/health`:
curl http://localhost:7211/api/health curl http://localhost:7211/api/health
``` ```
## Config Doctor
```bash
npm run doctor
```
Checks an installation from the terminal and prints what it finds: whether
`AUTH_SECRET` is set and long enough, whether `gallery.yaml` and
`settings.yaml` parse, whether Immich answers the three calls Folio depends on,
whether every published album ID still exists in Immich (and is shared there),
whether any password is still stored in plaintext or as an unusable bcrypt
hash, and whether `content/` can be written to.
This is the same set of checks as the **Diagnostics** panel in `/admin`, but it
needs neither a running app nor an admin password — which is the point. It is
the tool for the case where the site will not come up at all.
It runs in the shipped image too:
```bash
docker compose exec folio npm run doctor
```
No secret is ever printed — only whether something is set, and where to look.
The exit code is the worst thing it found, so it can gate a deployment script:
| Code | Meaning |
| ---- | ------------------------------- |
| `0` | every check passed |
| `1` | warnings, no errors |
| `2` | at least one error |
| `3` | the doctor itself could not run |
Two findings are reported rather than judged. `content/` writability is tested
as the user who typed the command, and on a Docker deployment that is not the
user the app writes as — so when the paths belong to someone else, the CLI says
whose they are and leaves it at that. Run it through the container to have it
checked as the app itself:
```bash
docker compose exec folio npm run doctor
```
The other is `TRUSTED_PROXY_HOPS`, judged against the
`X-Forwarded-For` chain of a live request, and a CLI has none. It reports the
configured value and says so rather than guessing — use the Diagnostics panel,
reached over your public URL, to have that one measured.
Both are printed under **NOTES** rather than among the passed checks, since
nothing was established, and counted separately in the summary line for the
same reason. Notes never affect the exit code. An unwritable path _does_ stay a
plain error when the CLI runs as the owner, which is the case that is really
about the app.
Run from a checkout, it reads `.env.local` and `.env` the way `npm run dev`
does; real environment variables and `content/install.json` are resolved in the
same order the app resolves them.
It needs **Node 22.18 or newer**, which is what runs the TypeScript directly
and is why the CLI adds no dependency of its own. The shipped image is already
on it; an older local Node is told so instead of crashing.
## Behaviour when Immich is Unreachable ## Behaviour when Immich is Unreachable
Immich Folio buffers your gallery rather than merely proxying it: Immich Folio buffers your gallery rather than merely proxying it:

110
e2e/admin-smoke.spec.ts Normal file
View File

@@ -0,0 +1,110 @@
import { test, expect } from '@playwright/test';
/**
* Smoke test for every admin route.
*
* This exists because of #542: a module-evaluation TDZ error in SettingsEditor
* took the entire admin panel down in production while all four gates stayed
* green. tsc could not see it (the read went through a hoisted function),
* `next build` could not (the module is only evaluated in the browser), the
* unit tests could not (nothing imports it), and an HTTP smoke check could not
* (the server renders the shell; the throw happens after hydration).
*
* A real browser is the only thing that catches that class of bug, so this
* spec loads each route and asserts three things: no error boundary, the
* route's own content actually rendered, and nothing threw during hydration.
*/
/** Matches the fallback playwright.config.ts hands the dev/start server. */
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'e2e-admin-password';
/**
* Each route plus a selector that only exists once that route's own component
* has rendered. Asserting the admin chrome would not be enough — a page that
* throws is replaced wholesale by app/error.tsx, but a route that renders an
* empty shell would still pass.
*/
const ROUTES: { path: string; content: string; name: string }[] = [
{ path: '/admin', content: '.page-builder', name: 'dashboard' },
{ path: '/admin/pages', content: '.page-builder', name: 'pages' },
{ path: '/admin/journal', content: '.journal-studio', name: 'journal' },
// AnalyticsView falls back to .admin-panel when the analytics API is
// unreachable, which is the normal case without a live Immich behind it.
{ path: '/admin/analytics', content: '.analytics-view, .admin-panel', name: 'analytics' },
{ path: '/admin/help', content: '.settings-panel', name: 'help' },
{ path: '/admin/settings', content: '.settings-editor', name: 'settings' },
...['general', 'theme', 'grid', 'footer', 'legal', 'seo', 'security', 'about'].map((id) => ({
path: `/admin/settings/${id}`,
content: '.settings-panel',
name: `settings/${id}`,
})),
];
/**
* Console noise that says nothing about the page's own health. Without a live
* Immich the status probe and the analytics fetch both fail, and their failures
* are reported as console errors. Uncaught exceptions are never filtered —
* those are the signal this test was written for.
*/
const IGNORED_CONSOLE = [
/Failed to load resource/i,
/net::ERR_/i,
/ECONNREFUSED/i,
/the server responded with a status of 5\d\d/i,
];
function isRealConsoleError(text: string): boolean {
return !IGNORED_CONSOLE.some((pattern) => pattern.test(text));
}
test('every admin route renders without throwing', async ({ page }) => {
const pageErrors: string[] = [];
const consoleErrors: string[] = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error' && isRealConsoleError(message.text())) {
consoleErrors.push(message.text());
}
});
await page.goto('/admin');
// A reused local dev server may have been started without ADMIN_PASSWORD.
// Skipping beats failing on a configuration difference — CI starts its own
// server with the password set, so it never takes this branch.
const disabled = await page
.locator('.admin-disabled')
.isVisible()
.catch(() => false);
test.skip(disabled, 'Admin panel disabled — start the server with ADMIN_PASSWORD set.');
await page.fill('input[type="password"]', ADMIN_PASSWORD);
await page.click('button[type="submit"]');
await expect(page.locator('.page-builder')).toBeVisible({ timeout: 15_000 });
// One test walking every route rather than one test each: the routes share a
// login, and expect.soft keeps a broken route from hiding the ones after it.
// When the panel goes down it usually goes down everywhere, and the useful
// report is the full list — a run that stops at the first failure was what
// made #542 look like a Settings problem instead of a panel-wide one.
for (const route of ROUTES) {
await test.step(route.name, async () => {
pageErrors.length = 0;
consoleErrors.length = 0;
await page.goto(route.path);
// app/error.tsx replaces the whole page when a route throws. Assert it
// first: its absence is what "the panel still works" actually means.
await expect
.soft(page.locator('.empty-state__title'), `error boundary on ${route.path}`)
.toHaveCount(0);
await expect
.soft(page.locator(route.content).first(), `no content on ${route.path}`)
.toBeVisible({ timeout: 15_000 });
expect.soft(pageErrors, `uncaught exception on ${route.path}`).toEqual([]);
expect.soft(consoleErrors, `console errors on ${route.path}`).toEqual([]);
});
}
});

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "immich-folio", "name": "immich-folio",
"version": "0.13.0", "version": "0.14.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich-folio", "name": "immich-folio",
"version": "0.13.0", "version": "0.14.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",

View File

@@ -1,6 +1,6 @@
{ {
"name": "immich-folio", "name": "immich-folio",
"version": "0.13.0", "version": "0.14.0",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -12,7 +12,9 @@
"test": "vitest", "test": "vitest",
"test:unit": "vitest run", "test:unit": "vitest run",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"test:e2e": "playwright test" "test:e2e": "playwright test",
"test:e2e:admin": "playwright test e2e/admin-smoke.spec.ts",
"doctor": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/doctor.mjs"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",

View File

@@ -1,5 +1,9 @@
import { defineConfig, devices } from '@playwright/test'; import { defineConfig, devices } from '@playwright/test';
// Overridable so a run can sidestep a dev server or container already holding
// the default port; CI leaves it alone.
const PORT = process.env.E2E_PORT || '3000';
export default defineConfig({ export default defineConfig({
testDir: './e2e', testDir: './e2e',
fullyParallel: true, fullyParallel: true,
@@ -8,7 +12,7 @@ export default defineConfig({
workers: process.env.CI ? 1 : undefined, workers: process.env.CI ? 1 : undefined,
reporter: 'html', reporter: 'html',
use: { use: {
baseURL: 'http://localhost:3000', baseURL: `http://localhost:${PORT}`,
trace: 'on-first-retry', trace: 'on-first-retry',
screenshot: 'only-on-failure', screenshot: 'only-on-failure',
}, },
@@ -19,9 +23,19 @@ export default defineConfig({
}, },
], ],
webServer: { webServer: {
command: 'npm run dev', // The admin smoke test (e2e/admin-smoke.spec.ts) exists to catch bugs that
url: 'http://localhost:3000', // only surface in a real browser against a real build — #542 was invisible
// to the dev server's on-demand compilation path. E2E_START=1 points the
// suite at `npm run start` so CI exercises the same output that ships.
command:
process.env.E2E_START === '1' ? `npm run start -- -p ${PORT}` : `npm run dev -- -p ${PORT}`,
url: `http://localhost:${PORT}`,
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
timeout: 30_000, timeout: 60_000,
env: {
// Without this the admin panel reports itself disabled and the smoke
// test skips. A local run that already exports ADMIN_PASSWORD keeps it.
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || 'e2e-admin-password',
},
}, },
}); });

View File

@@ -0,0 +1,427 @@
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import yaml from 'js-yaml';
import type { CliFinding } from '../doctor.mts';
import {
EXIT_CODES,
EXIT_INTERNAL,
collectAlbumIds,
collectPasswords,
exitCodeFor,
currentUid,
findUnwritable,
formatReport,
frontmatterPassword,
reportWritable,
loadDotEnv,
resolveCredentials,
shouldUseColor,
wrap,
type EnvLike,
} from '../doctor.mts';
import type { DoctorFinding } from '../../lib/admin/doctor';
/**
* The CLI's own logic — the gathering, the formatting and the exit-code
* mapping. The checks themselves are covered by lib/__tests__/doctor.test.ts
* and are imported, not reimplemented (#521).
*/
function tmpdir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'folio-doctor-'));
}
describe('exitCodeFor', () => {
it('maps clean to 0 and the rest upwards', () => {
expect(exitCodeFor('ok')).toBe(0);
expect(exitCodeFor('warn')).toBe(1);
expect(exitCodeFor('error')).toBe(2);
});
it('keeps the internal failure code out of the finding range', () => {
expect(EXIT_INTERNAL).toBeGreaterThan(Math.max(...Object.values(EXIT_CODES)));
});
});
describe('collectAlbumIds', () => {
it('finds standalone, subpage and section albums in one pass', () => {
const gallery = yaml.load(`
albums:
- "11111111-1111-4111-8111-111111111111"
subpages:
- name: Travel
albums:
- "22222222-2222-4222-8222-222222222222"
- name: Japan
sections:
- title: Kyoto
albums:
- "33333333-3333-4333-8333-333333333333"
`);
expect(collectAlbumIds(gallery)).toEqual([
'11111111-1111-4111-8111-111111111111',
'22222222-2222-4222-8222-222222222222',
'33333333-3333-4333-8333-333333333333',
]);
});
it('reads the ID off the object form that carries a title or a password', () => {
const gallery = yaml.load(`
albums:
- "44444444-4444-4444-8444-444444444444": Hokkaido
- "55555555-5555-4555-8555-555555555555":
title: Kyoto
sort: filename
`);
expect(collectAlbumIds(gallery)).toEqual([
'44444444-4444-4444-8444-444444444444',
'55555555-5555-4555-8555-555555555555',
]);
});
it('handles the map form of subpages', () => {
const gallery = yaml.load(`
subpages:
Travel:
albums:
- "66666666-6666-4666-8666-666666666666"
`);
expect(collectAlbumIds(gallery)).toEqual(['66666666-6666-4666-8666-666666666666']);
});
/** hero and assetOrder hold asset UUIDs — reporting them as albums would
* turn every configured hero image into a "does not exist" error. */
it('does not mistake asset IDs for album IDs', () => {
const gallery = yaml.load(`
hero:
- "77777777-7777-4777-8777-777777777777"
albums:
- "88888888-8888-4888-8888-888888888888":
sort: manual
assetOrder:
- "99999999-9999-4999-8999-999999999999"
`);
expect(collectAlbumIds(gallery)).toEqual(['88888888-8888-4888-8888-888888888888']);
});
/** validateUuid() drops these rather than passing a typo on to Immich. */
it('drops entries that are not UUIDs, and deduplicates', () => {
const gallery = yaml.load(`
albums:
- "your-album-uuid-1"
- "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
subpages:
- name: Travel
albums:
- "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
`);
expect(collectAlbumIds(gallery)).toEqual(['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa']);
});
it('survives an empty or non-object document', () => {
expect(collectAlbumIds(null)).toEqual([]);
expect(collectAlbumIds('just a string')).toEqual([]);
});
});
describe('collectPasswords', () => {
it('finds a password wherever it sits, and labels it by path', () => {
const gallery = yaml.load(`
subpages:
- name: Projects
password: "hunter2"
albums:
- "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb":
password: "scrypt:a:b"
`);
const found = collectPasswords(gallery, 'gallery.yaml');
expect(found).toHaveLength(2);
expect(found[0].label).toContain('Projects');
expect(found[0].value).toBe('hunter2');
expect(found[1].label).toContain('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb');
});
it('picks up the site password from settings.yaml', () => {
const settings = yaml.load('sitePassword: "scrypt:a:b"');
const found = collectPasswords(settings, 'settings.yaml');
expect(found).toHaveLength(1);
expect(found[0].label).toBe('settings.yaml sitePassword');
});
it('ignores an empty password key', () => {
expect(collectPasswords(yaml.load('password: ""'), 'gallery.yaml')).toEqual([]);
});
});
describe('frontmatterPassword', () => {
it('reads a quoted or bare value', () => {
expect(frontmatterPassword('---\ntitle: A\npassword: "scrypt:a:b"\n---\n\nbody')).toBe(
'scrypt:a:b',
);
expect(frontmatterPassword('---\npassword: hunter2\n---\n')).toBe('hunter2');
});
it('returns null without frontmatter, or without a password in it', () => {
expect(frontmatterPassword('# Just markdown')).toBeNull();
expect(frontmatterPassword('---\ntitle: A\n---\n')).toBeNull();
});
/** The body of an entry may well talk about passwords. */
it('does not look past the frontmatter', () => {
expect(frontmatterPassword('---\ntitle: A\n---\n\npassword: not-one\n')).toBeNull();
});
});
describe('resolveCredentials', () => {
it('prefers the environment over install.json', () => {
const dir = tmpdir();
fs.writeFileSync(
path.join(dir, 'install.json'),
JSON.stringify({ apiUrl: 'http://file:2283', apiKey: 'file-key', authSecret: 'file-secret' }),
);
const creds = resolveCredentials(dir, { IMMICH_API_URL: 'http://env:2283' });
expect(creds.apiUrl).toBe('http://env:2283');
expect(creds.apiKey).toBe('file-key');
expect(creds.authSecret).toBe('file-secret');
});
it('treats an unparseable install.json as absent', () => {
const dir = tmpdir();
fs.writeFileSync(path.join(dir, 'install.json'), '{ not json');
expect(resolveCredentials(dir, {})).toEqual({ apiUrl: '', apiKey: '', authSecret: '' });
});
it('strips the trailing slash and empties an invalid URL, as env.ts does', () => {
const dir = tmpdir();
expect(resolveCredentials(dir, { IMMICH_API_URL: 'http://immich:2283/' }).apiUrl).toBe(
'http://immich:2283',
);
expect(resolveCredentials(dir, { IMMICH_API_URL: 'not a url' }).apiUrl).toBe('');
});
});
describe('loadDotEnv', () => {
it('reads .env.local ahead of .env, and never overwrites a real variable', () => {
const dir = tmpdir();
fs.writeFileSync(path.join(dir, '.env'), 'IMMICH_API_KEY=from-env\nAUTH_SECRET=from-env\n');
fs.writeFileSync(path.join(dir, '.env.local'), 'AUTH_SECRET="from-local"\n');
const env: EnvLike = { IMMICH_API_KEY: 'from-process' };
loadDotEnv(dir, env);
expect(env.IMMICH_API_KEY).toBe('from-process');
expect(env.AUTH_SECRET).toBe('from-local');
});
it('drops trailing comments on unquoted values and keeps quoted ones whole', () => {
const dir = tmpdir();
fs.writeFileSync(
path.join(dir, '.env'),
'A=plain # a comment\nB="quoted # hash"\nexport C=exported\n',
);
const env: EnvLike = {};
loadDotEnv(dir, env);
expect(env.A).toBe('plain');
expect(env.B).toBe('quoted # hash');
expect(env.C).toBe('exported');
});
it('is silent when neither file exists', () => {
const env: EnvLike = {};
expect(() => loadDotEnv(tmpdir(), env)).not.toThrow();
expect(env).toEqual({});
});
});
describe('findUnwritable', () => {
it('reports nothing for a writable content directory', () => {
const dir = tmpdir();
fs.mkdirSync(path.join(dir, 'journal'));
expect(findUnwritable(dir)).toEqual([]);
});
/** The app creates these on first write; only one that exists and refuses
* writes is a fault. */
it('does not report a directory that has not been created yet', () => {
expect(findUnwritable(tmpdir())).toEqual([]);
});
it('reports the owner alongside a path it cannot write', () => {
const dir = tmpdir();
const backups = path.join(dir, '.backups');
fs.mkdirSync(backups);
fs.chmodSync(backups, 0o555);
try {
const found = findUnwritable(dir);
// Running as root, 0555 is still writable — the case cannot be staged.
if (currentUid() === 0) return;
expect(found).toHaveLength(1);
expect(found[0].label).toBe('content/.backups');
expect(found[0].ownerUid).toBe(fs.statSync(backups).uid);
} finally {
fs.chmodSync(backups, 0o755);
}
});
});
/**
* The CLI tests whoever typed the command; the panel tests the app's own user.
* On a Docker deployment those differ, and calling that an error accused a
* healthy install (#551).
*/
describe('reportWritable', () => {
const mine = { label: 'content/.backups', ownerUid: 1000 };
const theirs = { label: 'content/.backups', ownerUid: 1001 };
it('passes when everything is writable', () => {
const finding = reportWritable([], 1000);
expect(finding.level).toBe('ok');
expect(finding.note).toBeUndefined();
expect(finding.title).toBe('content/ is writable');
});
it('is a real error when the CLI runs as the owner', () => {
const finding = reportWritable([mine], 1000);
expect(finding.level).toBe('error');
expect(finding.note).toBeUndefined();
expect(finding.detail).toContain('Saving from the admin panel will fail');
});
it('is a note, not an error, when the owner is someone else', () => {
const finding = reportWritable([theirs], 1000);
expect(finding.level).toBe('ok');
expect(finding.note).toBe(true);
expect(finding.title).toContain('uid 1000');
expect(finding.title).toContain('uid 1001');
expect(finding.detail).toContain('docker compose exec');
});
it('does not downgrade a mixed result to a note', () => {
const finding = reportWritable([mine, theirs], 1000);
expect(finding.level).toBe('ok');
expect(finding.note).toBe(true);
});
it('claims nothing on a platform without uids', () => {
const finding = reportWritable([theirs], null);
expect(finding.level).toBe('ok');
expect(finding.note).toBe(true);
expect(finding.title).not.toContain('undefined');
expect(finding.detail).toContain('may not be the account the app');
});
it('keeps the id the panel uses, whatever the outcome', () => {
for (const finding of [
reportWritable([], 1000),
reportWritable([mine], 1000),
reportWritable([theirs], 1000),
reportWritable([theirs], null),
]) {
expect(finding.id).toBe('content-writable');
}
});
});
describe('wrap', () => {
it('breaks at word boundaries and indents every line', () => {
expect(wrap('one two three four', 9, ' ')).toEqual([' one two', ' three', ' four']);
});
it('leaves a token longer than the width intact', () => {
expect(wrap('aaaaaaaaaaaa', 4, '')).toEqual(['aaaaaaaaaaaa']);
});
});
describe('formatReport', () => {
const findings: DoctorFinding[] = [
{ id: 'a', level: 'ok', title: 'Fine', detail: 'Nothing to do.' },
{ id: 'b', level: 'error', title: 'Broken', detail: 'Fix it.' },
{ id: 'c', level: 'warn', title: 'Odd', detail: 'Have a look.' },
];
it('groups by level, worst first', () => {
const report = formatReport(findings);
expect(report.indexOf('ERRORS (1)')).toBeLessThan(report.indexOf('WARNINGS (1)'));
expect(report.indexOf('WARNINGS (1)')).toBeLessThan(report.indexOf('PASSED (1)'));
});
it('ends with a count of each level', () => {
expect(formatReport(findings)).toContain('1 error, 1 warning, 1 check passed.');
expect(formatReport([])).toContain('0 errors, 0 warnings, 0 checks passed.');
});
it('omits a level nobody hit', () => {
const report = formatReport([findings[0]]);
expect(report).not.toContain('ERRORS');
expect(report).not.toContain('WARNINGS');
});
it('emits no escape sequences unless colour is asked for', () => {
const ansi = /\u001b\[/;
expect(ansi.test(formatReport(findings))).toBe(false);
expect(ansi.test(formatReport(findings, { color: true }))).toBe(true);
});
// The proxy-hop finding carries level `ok` so it cannot skew the exit code,
// but the CLI never checked it — listing it under PASSED would claim a check
// that did not run.
describe('notes', () => {
const proxy: CliFinding = {
id: 'proxy-hops',
level: 'ok',
note: true,
title: 'TRUSTED_PROXY_HOPS is 0 (unset) — not verifiable from the terminal',
detail: 'Open the Diagnostics panel to have it measured.',
};
it('needs the flag, not the id: the same check can be a real finding', () => {
const notANote = { ...proxy, note: undefined };
const report = formatReport([notANote]);
expect(report).not.toContain('NOTES');
expect(report).toContain('PASSED (1)');
});
it('lists a note in its own group rather than under PASSED', () => {
const report = formatReport([...findings, proxy]);
expect(report).toContain('NOTES (1)');
expect(report).toContain('PASSED (1)');
expect(report).toContain(proxy.title);
expect(report.indexOf(proxy.title)).toBeGreaterThan(report.indexOf('NOTES (1)'));
expect(report.indexOf(proxy.title)).toBeLessThan(report.indexOf('PASSED (1)'));
});
it('sits between the warnings and the passes', () => {
const report = formatReport([...findings, proxy]);
expect(report.indexOf('WARNINGS (1)')).toBeLessThan(report.indexOf('NOTES (1)'));
expect(report.indexOf('NOTES (1)')).toBeLessThan(report.indexOf('PASSED (1)'));
});
it('is counted separately, not as a passed check', () => {
expect(formatReport([...findings, proxy])).toContain(
'1 error, 1 warning, 1 check passed, 1 note.',
);
});
it('says nothing about notes when there are none', () => {
const report = formatReport(findings);
expect(report).not.toContain('NOTES');
expect(report).toContain('1 error, 1 warning, 1 check passed.');
});
});
});
describe('shouldUseColor', () => {
it('follows the terminal by default', () => {
expect(shouldUseColor({}, true)).toBe(true);
expect(shouldUseColor({}, false)).toBe(false);
});
it('honours NO_COLOR above everything', () => {
expect(shouldUseColor({ NO_COLOR: '1', FORCE_COLOR: '1' }, true)).toBe(false);
});
it('honours FORCE_COLOR when the output is piped', () => {
expect(shouldUseColor({ FORCE_COLOR: '1' }, false)).toBe(true);
expect(shouldUseColor({ FORCE_COLOR: '0' }, false)).toBe(false);
});
});

26
scripts/doctor.mjs Normal file
View File

@@ -0,0 +1,26 @@
/**
* Entry point for `npm run doctor` — see scripts/doctor.mts for the doctor
* itself, and for why it is TypeScript run by node's own type stripping (#521).
*
* This launcher is plain JavaScript so that it parses on every Node version.
* Type stripping arrived in Node 22.18; older runtimes fail on the `.mts` file
* with an ERR_UNKNOWN_FILE_EXTENSION stack trace, which is not an answer to
* give someone whose site is already down. The version is checked here, before
* the import that would throw.
*/
const [major, minor] = process.versions.node.split('.').map(Number);
if (major < 22 || (major === 22 && minor < 18)) {
process.stderr.write(
`\nThe config doctor needs Node 22.18 or newer (this is ${process.versions.node}).\n` +
`It runs TypeScript directly, which older versions cannot do.\n\n` +
`The same checks are in the admin panel: /admin → the status badge → Diagnostics.\n` +
`The shipped Docker image already has a new enough Node:\n` +
` docker compose exec folio npm run doctor\n\n`,
);
process.exit(3);
}
const { main } = await import('./doctor.mts');
process.exitCode = await main();

750
scripts/doctor.mts Normal file
View File

@@ -0,0 +1,750 @@
/**
* Config doctor for the terminal — `npm run doctor` (#521).
*
* The panel version of this (`/admin` → status badge → Diagnostics) needs a
* running app and a working admin password. This one answers when neither is
* true, which is the situation people actually write support threads about.
*
* ── Why this file is a `.mts` run by plain `node` ────────────────────────
*
* The project has no TypeScript runner among its dependencies, and the issue
* offered two ways out: add `tsx` as a dev dependency, or compile a `.mjs`
* entry point during the build. Both fail the case this CLI exists for.
*
* The shipped image (`node:22-alpine`, `output: 'standalone'`) carries only
* production dependencies — `tsx` is not among them, and `npx tsx` in a
* container whose app is already broken means a network round trip to the
* registry before the diagnosis can start. A compiled `.mjs` would work, but
* only by adding a build step whose output has to be kept in the image and in
* sync with the source.
*
* Node 22.18 unflagged native type stripping, so `node scripts/doctor.mts`
* runs the TypeScript directly with no runner at all. It has one constraint:
* no path aliases, and every relative import needs its file extension. That
* rules out importing `lib/config` — but `lib/admin/doctor.ts` has no imports
* whatsoever (it is pure functions over gathered inputs, written that way in
* #491 precisely so a CLI could reuse it), so the entire TypeScript module
* graph of this script is two files. Nothing is duplicated that judges: the
* checks all come from `lib/admin/doctor.ts`.
*
* What *is* repeated here is the gathering — reading the filesystem and the
* environment instead of a request, exactly as the issue described. It is
* deliberately tolerant rather than a second `getConfig()`: it walks the parsed
* YAML for anything that looks like an album ID or a password rather than
* knowing the schema, so a config shape it has never seen still gets checked,
* and a config `getConfig()` would throw on still gets a report.
*
* ── Exit codes ───────────────────────────────────────────────────────────
*
* 0 every check passed
* 1 at least one warning, no errors
* 2 at least one error
* 3 the doctor itself could not run
*
* Nothing here ever prints a secret: `lib/admin/doctor.ts` reports only whether
* something is set and where to look, and the gathering below never puts a
* password, an API key or `AUTH_SECRET` into a finding.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
checkAlbumIds,
checkAlbumsShared,
checkAuthSecret,
checkImmichCalls,
checkPasswords,
worstLevel,
type AlbumRef,
type DoctorFinding,
type DoctorLevel,
type PasswordRef,
} from '../lib/admin/doctor.ts';
/**
* `process.env`, minus the `NODE_ENV`-is-required augmentation Next adds to
* `NodeJS.ProcessEnv` — which would force every test to hand over a NODE_ENV
* it does not care about.
*/
export type EnvLike = Record<string, string | undefined>;
// ── Exit codes ────────────────────────────────────────────────────────────
/** Worst finding level → process exit code. */
export const EXIT_CODES: Record<DoctorLevel, number> = { ok: 0, warn: 1, error: 2 };
/** The doctor itself failed — distinct from any finding it could have made. */
export const EXIT_INTERNAL = 3;
export function exitCodeFor(level: DoctorLevel): number {
return EXIT_CODES[level] ?? EXIT_INTERNAL;
}
// ── Environment ───────────────────────────────────────────────────────────
/**
* `.env.local` then `.env`, without overwriting anything already set.
*
* Next loads these for `npm run dev`; a bare `node` process does not, and a
* checkout that keeps its credentials there would otherwise be told it has no
* Immich configured. Real environment variables still win, which matches both
* Next's precedence and `getInstallCredentials()`.
*/
export function loadDotEnv(cwd: string, env: EnvLike = process.env): void {
for (const file of ['.env.local', '.env']) {
let raw: string;
try {
raw = fs.readFileSync(path.join(cwd, file), 'utf8');
} catch {
continue;
}
for (const line of raw.split('\n')) {
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
if (!match) continue;
const [, key, rest] = match;
if (key in env) continue;
let value = rest.trim();
if (
(value.startsWith('"') && value.endsWith('"') && value.length > 1) ||
(value.startsWith("'") && value.endsWith("'") && value.length > 1)
) {
value = value.slice(1, -1);
} else {
value = value.replace(/\s+#.*$/, '').trim();
}
env[key] = value;
}
}
}
export interface Credentials {
apiUrl: string;
apiKey: string;
authSecret: string;
}
/**
* Mirrors `getInstallCredentials()`: environment first, `content/install.json`
* underneath. A malformed or unreadable install file is treated as absent —
* the checks below then report what is missing.
*/
export function resolveCredentials(contentDir: string, env: EnvLike): Credentials {
let file: Record<string, unknown> = {};
try {
const parsed: unknown = JSON.parse(
fs.readFileSync(path.join(contentDir, 'install.json'), 'utf8'),
);
if (parsed && typeof parsed === 'object') file = parsed as Record<string, unknown>;
} catch {
// No install.json, or one that cannot be parsed. Env may still carry everything.
}
const str = (value: unknown) => (typeof value === 'string' ? value : '');
// env.ts normalises IMMICH_API_URL through `new URL()` and strips trailing
// slashes; an invalid value there becomes empty rather than throwing.
let apiUrl = env.IMMICH_API_URL || str(file.apiUrl);
if (apiUrl) {
try {
apiUrl = new URL(apiUrl).toString().replace(/\/+$/, '');
} catch {
apiUrl = '';
}
}
return {
apiUrl,
apiKey: env.IMMICH_API_KEY || str(file.apiKey),
authSecret: env.AUTH_SECRET || str(file.authSecret),
};
}
// ── Gathering from the YAML ───────────────────────────────────────────────
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
type YamlNode = unknown;
function isRecord(node: YamlNode): node is Record<string, unknown> {
return !!node && typeof node === 'object' && !Array.isArray(node);
}
/**
* Every album ID reachable in the tree, deduplicated and in document order.
*
* Anything under an `albums:` list counts, whatever nests it — standalone
* albums, subpages in either the list or the map form, sections. An entry is
* either the ID itself or a single-key object whose key is the ID (the form
* that carries a title, a password or a sort mode). Only well-formed UUIDs are
* kept, matching `validateUuid()`, which drops the rest rather than passing a
* typo on to Immich.
*/
export function collectAlbumIds(node: YamlNode): string[] {
const found: string[] = [];
const takeEntry = (entry: unknown) => {
if (typeof entry === 'string') {
if (UUID_REGEX.test(entry.trim())) found.push(entry.trim());
return;
}
if (isRecord(entry)) {
const key = Object.keys(entry)[0];
if (key && UUID_REGEX.test(key.trim())) found.push(key.trim());
// The value may itself hold nested config, but never further album IDs.
walk(entry);
}
};
const walk = (current: YamlNode) => {
if (Array.isArray(current)) {
current.forEach(walk);
return;
}
if (!isRecord(current)) return;
for (const [key, value] of Object.entries(current)) {
if (key === 'albums' && Array.isArray(value)) {
value.forEach(takeEntry);
continue;
}
// assetOrder and hero hold asset IDs, not albums — skipped by name.
if (key === 'assetOrder' || key === 'hero') continue;
walk(value);
}
};
walk(node);
return [...new Set(found)];
}
/**
* Every `password:` in the tree, labelled by where it sits.
*
* The label is a YAML path rather than a slug: it is what the reader has to
* open the file and find the value, and unlike a slug it cannot go stale when
* the schema grows a new place to put a password. The value travels only as far
* as `checkPasswords()`, which reports the storage format and never the secret.
*/
export function collectPasswords(node: YamlNode, source: string): PasswordRef[] {
const found: PasswordRef[] = [];
const walk = (current: YamlNode, trail: string) => {
if (Array.isArray(current)) {
current.forEach((item, i) => walk(item, `${trail}[${i}]`));
return;
}
if (!isRecord(current)) return;
for (const [key, value] of Object.entries(current)) {
const here = trail ? `${trail}.${key}` : key;
if ((key === 'password' || key === 'sitePassword') && typeof value === 'string' && value) {
// Name the subpage or album rather than the index where one is at hand.
const name = typeof current.name === 'string' ? ` (${current.name})` : '';
found.push({ label: `${source} ${here}${name}`, value });
continue;
}
walk(value, here);
}
};
walk(node, '');
return found;
}
/** The `password:` of a journal entry's frontmatter, if it has one. */
export function frontmatterPassword(markdown: string): string | null {
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(markdown);
if (!match) return null;
for (const line of match[1].split('\n')) {
const found = /^\s*password\s*:\s*(.+?)\s*$/.exec(line);
if (!found) continue;
const value = found[1].replace(/^["']|["']$/g, '');
return value || null;
}
return null;
}
export interface UnwritablePath {
/** As the report names it, e.g. `content/.backups`. */
label: string;
/** Owner of the path, so the report can say whose permission problem it is. */
ownerUid: number;
}
/**
* Content paths that exist but refuse writes. Absent is fine — see the route.
*
* The owner comes back with each path because "not writable" from a terminal
* is only half a finding: the panel asks as the app's own user, a CLI asks as
* whoever typed the command, and on a Docker deployment those differ (#551).
*/
export function findUnwritable(contentDir: string): UnwritablePath[] {
const unwritable: UnwritablePath[] = [];
for (const dir of ['', '.backups', 'journal']) {
const target = path.join(contentDir, dir);
try {
fs.accessSync(target, fs.constants.W_OK);
} catch {
try {
const stat = fs.statSync(target);
unwritable.push({ label: `content/${dir}`.replace(/\/$/, ''), ownerUid: stat.uid });
} catch {
// Not created yet: the app makes it on first write.
}
}
}
return unwritable;
}
/** The uid this process runs as, or null where the platform has none. */
export function currentUid(): number | null {
return typeof process.getuid === 'function' ? process.getuid() : null;
}
/**
* The writability finding, in the CLI's own words.
*
* `checkWritable()` in lib/admin/doctor.ts says "saving from the admin panel
* will fail", which is true where it runs — inside the app, as the app's user.
* A CLI tests whoever typed the command. Run from the host against a container
* that writes as uid 1001, that produced an error on a deployment with nothing
* wrong with it, and the obvious reaction (chown the volume from the host) is
* the one change that would actually break the container's access (#551).
*
* So the level follows what the answer is worth:
*
* - running as the owner — the app's own user, or a bare-metal install — the
* result is about the app, and stays an error.
* - anyone else: reported, attributed, and left in NOTES, because the CLI
* cannot tell whether the user it tested is the one that matters.
*
* The id stays `content-writable`, so the panel and the CLI still name the
* same check.
*/
export function reportWritable(unwritable: UnwritablePath[], uid: number | null): CliFinding {
if (!unwritable.length) {
return {
id: 'content-writable',
level: 'ok',
title: 'content/ is writable',
detail: 'Config, journal and backups can be saved.',
};
}
const labels = unwritable.map((u) => u.label).join(', ');
const count = unwritable.length;
const noun = `${count} content ${count === 1 ? 'path is' : 'paths are'} not writable`;
// No getuid(): Windows. Nothing can be attributed, so nothing is claimed.
if (uid === null) {
return {
id: 'content-writable',
level: 'ok',
note: true,
title: `${noun} for this user`,
detail:
'Checked as the account running this command, which may not be the account the app ' +
`runs as. If it is, saving from the admin panel will fail, and so will backups: ${labels}`,
};
}
if (unwritable.every((u) => u.ownerUid === uid)) {
return {
id: 'content-writable',
level: 'error',
title: noun,
detail:
'Saving from the admin panel will fail, and so will backups. Check the ownership of the ' +
`mounted volume: ${labels}`,
};
}
const owners = [...new Set(unwritable.map((u) => u.ownerUid))].join(', ');
return {
id: 'content-writable',
level: 'ok',
note: true,
title: `${noun} by uid ${uid} — but ${count === 1 ? 'it is' : 'they are'} owned by uid ${owners}`,
detail:
'Checked as the user running this command, not as the user the app runs as. The paths ' +
`belong to uid ${owners}, which can write them; the Docker image runs the app as uid 1001. ` +
`This only matters if the app itself runs as uid ${uid}. To check as the app: ` +
`docker compose exec folio npm run doctor. Paths: ${labels}`,
};
}
// ── Report formatting ─────────────────────────────────────────────────────
const LEVEL_ORDER: DoctorLevel[] = ['error', 'warn', 'ok'];
const LEVEL_LABEL: Record<DoctorLevel, string> = {
error: 'ERRORS',
warn: 'WARNINGS',
ok: 'PASSED',
};
/**
* A finding, plus whether the CLI actually established it.
*
* Notes carry level `ok` so they cannot skew `worstLevel()` or the exit code,
* but listing them under PASSED would claim a check that never ran. They get
* their own group instead. The flag lives here rather than on `DoctorFinding`
* because the type is shared with the panel, where the same checks are real —
* being unverifiable is a fact about the terminal, not about the finding.
*
* It is per finding rather than per id: `content-writable` is a note when the
* CLI ran as someone other than the owner, and a genuine error when it did not
* (#551).
*/
export type CliFinding = DoctorFinding & { note?: boolean };
const NOTE_LABEL = 'NOTES';
const NOTE_MARK = 'i';
const NOTE_COLOR = '\x1b[36m';
const LEVEL_MARK: Record<DoctorLevel, string> = { error: 'x', warn: '!', ok: 'v' };
/** Hand-written, so the CLI needs no colour dependency. */
const LEVEL_COLOR: Record<DoctorLevel, string> = {
error: '\x1b[31m',
warn: '\x1b[33m',
ok: '\x1b[32m',
};
const DIM = '\x1b[2m';
const BOLD = '\x1b[1m';
const RESET = '\x1b[0m';
/** Soft-wraps at a word boundary; a single long token is left intact. */
export function wrap(text: string, width: number, indent: string): string[] {
const lines: string[] = [];
let line = '';
for (const word of text.split(/\s+/).filter(Boolean)) {
if (line && (line + ' ' + word).length > width) {
lines.push(indent + line);
line = word;
} else {
line = line ? `${line} ${word}` : word;
}
}
if (line) lines.push(indent + line);
return lines;
}
export interface FormatOptions {
color?: boolean;
width?: number;
}
/** The whole report as one string, so it can be asserted on in a test. */
export function formatReport(findings: CliFinding[], options: FormatOptions = {}): string {
const color = options.color ?? false;
const width = Math.max(40, Math.min(options.width ?? 80, 120));
const paint = (code: string, text: string) => (color ? `${code}${text}${RESET}` : text);
const out: string[] = ['', paint(BOLD, 'Immich Folio — config doctor'), ''];
const notes = findings.filter((f) => f.note === true);
const checks = findings.filter((f) => f.note !== true);
const group = (label: string, color: string, mark: string, group: CliFinding[]) => {
if (!group.length) return;
out.push(paint(color + BOLD, `${label} (${group.length})`));
for (const finding of group) {
out.push(` ${paint(color, mark)} ${finding.title}`);
for (const line of wrap(finding.detail, width - 6, ' ')) {
out.push(paint(DIM, line));
}
out.push('');
}
};
for (const level of LEVEL_ORDER) {
// Notes sit between the warnings and the passes: they are not a problem,
// but they are the part of the report a reader still has to act on.
if (level === 'ok') group(NOTE_LABEL, NOTE_COLOR, NOTE_MARK, notes);
group(
LEVEL_LABEL[level],
LEVEL_COLOR[level],
LEVEL_MARK[level],
checks.filter((f) => f.level === level),
);
}
const counts = LEVEL_ORDER.map((level) => checks.filter((f) => f.level === level).length);
const [errors, warns, oks] = counts;
out.push(
paint(
BOLD,
`${errors} error${errors === 1 ? '' : 's'}, ` +
`${warns} warning${warns === 1 ? '' : 's'}, ` +
`${oks} check${oks === 1 ? '' : 's'} passed` +
(notes.length ? `, ${notes.length} note${notes.length === 1 ? '' : 's'}` : '') +
`.`,
),
);
out.push('');
return out.join('\n');
}
/** Colour unless the output is piped, or NO_COLOR / FORCE_COLOR says otherwise. */
export function shouldUseColor(
env: EnvLike = process.env,
isTty: boolean = process.stdout.isTTY === true,
): boolean {
if (env.NO_COLOR) return false;
if (env.FORCE_COLOR && env.FORCE_COLOR !== '0') return true;
return isTty;
}
// ── The run ───────────────────────────────────────────────────────────────
/**
* `TRUSTED_PROXY_HOPS` cannot be checked from a terminal.
*
* `checkProxyHops()` compares the configured value against the
* `X-Forwarded-For` chain of a live request, and there is none here. Rather
* than guess — a wrong guess in either direction points at a rate-limiting bug
* that is not there — the CLI reports the configured value and says where the
* real check lives.
*/
function reportProxyHops(env: EnvLike): CliFinding {
const raw = env.TRUSTED_PROXY_HOPS;
const hops = raw && !isNaN(parseInt(raw, 10)) ? Math.max(0, parseInt(raw, 10)) : 0;
return {
id: 'proxy-hops',
level: 'ok',
note: true,
title: `TRUSTED_PROXY_HOPS is ${hops}${raw ? '' : ' (unset)'} — not verifiable from the terminal`,
detail:
'Whether this is right can only be judged against a real request: the value says how far ' +
'from the right of X-Forwarded-For the client IP sits, and no request reaches a CLI. Open ' +
'the Diagnostics panel in /admin over your public URL to have it measured. One reverse ' +
'proxy (nginx, Traefik or Caddy) alone is 1; no proxy at all is 0.',
};
}
async function gatherFindings(cwd: string, env: EnvLike): Promise<CliFinding[]> {
const contentDir = env.INSTALL_CONTENT_DIR || path.join(cwd, 'content');
const findings: CliFinding[] = [];
const credentials = resolveCredentials(contentDir, env);
findings.push(checkAuthSecret(credentials.authSecret || undefined));
findings.push(reportProxyHops(env));
// ── The YAML, read tolerantly ───────────────────────────────────────
let yamlLoad: ((input: string) => unknown) | null = null;
try {
yamlLoad = (await import('js-yaml')).load as (input: string) => unknown;
} catch {
// Reported once below, against whichever file needed it.
}
const readYaml = (filename: string): { node: YamlNode; error: string | null } => {
let raw: string;
try {
raw = fs.readFileSync(path.join(contentDir, filename), 'utf8');
} catch {
return { node: null, error: null }; // Absent, which each caller judges itself.
}
if (!yamlLoad) return { node: null, error: 'js-yaml is not installed' };
try {
return { node: yamlLoad(raw) ?? {}, error: null };
} catch (error) {
// js-yaml appends a snippet of the offending lines; the first line
// already names the fault and the position, and the rest turns into
// noise once it is wrapped.
const message = error instanceof Error ? error.message : String(error);
return { node: null, error: message.split('\n')[0] };
}
};
const gallery = readYaml('gallery.yaml');
const settings = readYaml('settings.yaml');
const galleryExists = fs.existsSync(path.join(contentDir, 'gallery.yaml'));
if (!galleryExists) {
findings.push({
id: 'gallery-yaml',
level: 'error',
title: 'content/gallery.yaml does not exist',
detail:
`Nothing is published yet. Run the setup wizard at /install, or copy ` +
`content/gallery.yaml.example to content/gallery.yaml and put your album IDs in it.`,
});
} else if (gallery.error) {
findings.push({
id: 'gallery-yaml',
level: 'error',
title: 'content/gallery.yaml could not be parsed',
detail: `The site falls back to the setup screen until this is fixed. ${gallery.error}. A working copy may be waiting in content/.backups/.`,
});
} else {
findings.push({
id: 'gallery-yaml',
level: 'ok',
title: 'content/gallery.yaml parses',
detail: 'The gallery structure could be read.',
});
}
if (settings.error) {
findings.push({
id: 'settings-yaml',
level: 'error',
title: 'content/settings.yaml could not be parsed',
detail: `Site title, theme and grid defaults fall back to their built-in values. ${settings.error}`,
});
}
const configuredAlbums = collectAlbumIds(gallery.node);
// ── Immich: the three calls Folio depends on ────────────────────────
const calls: Array<{ endpoint: string; ok: boolean }> = [];
let albums: AlbumRef[] = [];
if (!credentials.apiUrl || !credentials.apiKey) {
findings.push({
id: 'immich-api',
level: 'error',
title: 'No Immich URL or API key configured',
detail:
'Set IMMICH_API_URL and IMMICH_API_KEY, or run the setup wizard at /install. ' +
(credentials.apiUrl ? 'The API key is missing.' : 'The URL is missing or not a valid URL.'),
});
} else {
// getConfig() appends /api unless it is already there.
const base = credentials.apiUrl.endsWith('/api')
? credentials.apiUrl
: `${credentials.apiUrl}/api`;
const timeout = Math.max(1000, parseInt(env.IMMICH_TIMEOUT_MS || '', 10) || 15000);
const headers = { 'x-api-key': credentials.apiKey, Accept: 'application/json' };
const call = async (endpoint: string, init?: RequestInit) => {
try {
const res = await fetch(`${base}${endpoint}`, {
headers,
signal: AbortSignal.timeout(timeout),
...init,
});
calls.push({ endpoint, ok: res.ok });
return res.ok ? res : null;
} catch {
calls.push({ endpoint, ok: false });
return null;
}
};
await call('/server/ping');
const albumRes = await call('/albums');
await call('/search/metadata', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ size: 1, page: 1 }),
});
if (albumRes) {
try {
const parsed: unknown = await albumRes.json();
if (Array.isArray(parsed)) albums = parsed as AlbumRef[];
} catch {
// A malformed body is already reflected by the call above.
}
}
findings.push(checkImmichCalls(calls));
}
if (albums.length) {
findings.push(checkAlbumIds(configuredAlbums, albums));
findings.push(checkAlbumsShared(configuredAlbums, albums));
} else if (galleryExists && !gallery.error && !configuredAlbums.length) {
findings.push({
id: 'album-ids',
level: 'warn',
title: 'No albums configured',
detail: 'gallery.yaml lists no album IDs, so the gallery has nothing to show.',
});
}
// ── Passwords, from every file one can live in ──────────────────────
const passwords: PasswordRef[] = [
...(env.SITE_PASSWORD ? [{ label: 'SITE_PASSWORD', value: env.SITE_PASSWORD }] : []),
...collectPasswords(settings.node, 'settings.yaml'),
...collectPasswords(gallery.node, 'gallery.yaml'),
];
for (const dir of ['journal', 'essays']) {
let entries: string[];
try {
entries = fs.readdirSync(path.join(contentDir, dir));
} catch {
continue; // Optional directories.
}
for (const name of entries.filter((n) => n.endsWith('.md'))) {
try {
const password = frontmatterPassword(
fs.readFileSync(path.join(contentDir, dir, name), 'utf8'),
);
if (password) passwords.push({ label: `${dir}/${name}`, value: password });
} catch {
// An unreadable entry is covered by the writability check below.
}
}
}
findings.push(checkPasswords(passwords));
// ── Writability of the content volume ───────────────────────────────
if (!fs.existsSync(contentDir)) {
findings.push({
id: 'content-writable',
level: 'error',
title: 'content/ does not exist',
detail:
`Nothing can be saved and nothing can be published. Expected it at ${contentDir} — ` +
'run the doctor from the project root, or check that the volume is mounted.',
});
} else {
findings.push(reportWritable(findUnwritable(contentDir), currentUid()));
}
return findings;
}
export async function main(
cwd: string = process.cwd(),
env: EnvLike = process.env,
): Promise<number> {
loadDotEnv(cwd, env);
let findings: CliFinding[];
try {
findings = await gatherFindings(cwd, env);
} catch (error) {
// A raw stack trace is never the right answer here: the person running this
// is already looking at something broken.
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(
`\nThe config doctor could not finish: ${message}\n` +
`Run it from the project root, and please report this at ` +
`https://github.com/ralksta/immich-folio/issues\n\n`,
);
return EXIT_INTERNAL;
}
process.stdout.write(
formatReport(findings, {
color: shouldUseColor(env),
width: process.stdout.columns || 80,
}),
);
return exitCodeFor(worstLevel(findings));
}
// Only when run directly. `npm run doctor` goes through scripts/doctor.mjs,
// which checks the Node version first; the tests import the helpers above.
const invoked = process.argv[1] ? path.resolve(process.argv[1]) : '';
if (invoked && invoked === fileURLToPath(import.meta.url)) {
process.exitCode = await main();
}

View File

@@ -11,6 +11,9 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
// scripts/doctor.mts is run by node's own type stripping, which needs the
// file extension on every relative import (#521). Safe alongside noEmit.
"allowImportingTsExtensions": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"incremental": true, "incremental": true,
"plugins": [ "plugins": [

View File

@@ -5,7 +5,11 @@ export default defineConfig({
test: { test: {
environment: 'node', environment: 'node',
setupFiles: ['./vitest.setup.ts'], setupFiles: ['./vitest.setup.ts'],
include: ['lib/__tests__/**/*.test.ts', 'app/**/__tests__/**/*.test.ts'], include: [
'lib/__tests__/**/*.test.ts',
'app/**/__tests__/**/*.test.ts',
'scripts/__tests__/**/*.test.ts',
],
coverage: { coverage: {
provider: 'v8', provider: 'v8',
include: ['lib/**/*.ts'], include: ['lib/**/*.ts'],