release: v0.18.0 (#725)

Release v0.18.0 — merges `dev` into `main`.

## Thanks

- [@lancetm714](https://github.com/lancetm714) for the accent-contrast
fix (#680 → #724).
- [@RichKidsDev](https://github.com/RichKidsDev) for reporting the
outdated Impressum (#697).

### Security

- **Journal drafts and password-protected entries no longer render
publicly
  as a subpage essay**
  ([#684](https://github.com/ralksta/immich-folio/pull/684),

[GHSA-987w-cpwm-c9g9](https://github.com/ralksta/immich-folio/security/advisories/GHSA-987w-cpwm-c9g9),
medium). A subpage's `essayFile` read the journal entry straight from
disk,
skipping the draft and password rules `/journal/<slug>` applies. It now
resolves through the same rules: a draft reads as missing for visitors,
and
  a protected entry shows its password gate, sharing the
  `lb_auth_journal_<slug>` cookie.

- **Immich descriptions are escaped in generated subpage essays**
  ([#684](https://github.com/ralksta/immich-folio/pull/684),

[GHSA-8ppx-39jv-q5xm](https://github.com/ralksta/immich-folio/security/advisories/GHSA-8ppx-39jv-q5xm),
  low). The essay built from a subpage's albums (`layout: essay` without
markdown) put each asset's Immich description into the page as raw HTML.
Captions are now HTML-escaped, and they go through `assetCaption()`, so
  `exif.caption: false` is respected there as well. Tracked in
  [#689](https://github.com/ralksta/immich-folio/issues/689).

- **Admin drafts no longer keep a typed password, and the contact form's
  email check runs in linear time**
([#727](https://github.com/ralksta/immich-folio/pull/727)). The admin's
  session drafts wrote a password as typed to `sessionStorage`; a draft
holding a plaintext password is now not kept (the unsaved-changes guard
still warns). The email pattern could backtrack polynomially on a
crafted
domain; its labels now exclude the dot and the length is checked first.
  Both found by CodeQL before release.

### Added

- **A built-in contact form with an inbox in the admin**
  ([#703](https://github.com/ralksta/immich-folio/pull/703), closes
[#702](https://github.com/ralksta/immich-folio/issues/702)). `/contact`
  needs neither SMTP nor a form service: each message is one file in
`content/messages/` (mode `0600`), read under the new _Messages_ tab,
where
_Reply by email_ opens the owner's mail client with the message quoted.
Messages older than `contact.retentionDays` (default 90) are deleted. An
optional ntfy push goes to `contact.notifyUrl` or `CONTACT_NOTIFY_URL`,
and
since [#709](https://github.com/ralksta/immich-folio/pull/709) it
carries a
  fixed "new message" line and nothing about the sender — no name, no
  address, no text. Spam is kept out without a captcha: an off-screen
honeypot, a minimum fill time, three submissions a minute per IP and a
cap
of 500 stored messages. With the Impressum on and no `legal.contactUrl`,
the Impressum links the form as its second contact channel. A new doctor
  check warns when the form is on but nobody is notified.

- **A privacy policy page, next to a list of what the site processes**
  ([#708](https://github.com/ralksta/immich-folio/pull/708)). `/privacy`
renders `content/privacy.md`, edited under Settings → Legal, and the
footer
links it once the file has text; `privacy.enabled: false` hides it.
Folio
writes no legal text. Beside the editor it lists what _this_
installation
  does, read off the configuration — the map, statistics, contact form,
password cookies, CDN — and marks every entry that involves a third
party.
_Insert headings_ adds the matching section headings and nothing else.
The
  doctor warns while the Impressum is on and there is no policy.

- **French, Spanish, Italian and Dutch interface translations**
([#681](https://github.com/ralksta/immich-folio/pull/681)), selected
with
  `settings.yaml: lang` as before. The admin panel stays English.

- **CDN mode for photos and videos**
([#681](https://github.com/ralksta/immich-folio/pull/681)). With
`CDN_URL`
set, image and video URLs point at a pull CDN whose origin is this
server,
  so repeat views reach neither the server nor Immich. A site password
switches the mode off, since a CDN would hand out cached photos without
  checking the unlock cookie. The CDN origin is added to the CSP, and a
  doctor check covers the password and `TRUSTED_PROXY_HOPS` cases. See
  `docs/deployment.md#cdn-mode`.

- **A reworked admin panel**
  ([#712](https://github.com/ralksta/immich-folio/pull/712),
  [#713](https://github.com/ralksta/immich-folio/pull/713),
  [#714](https://github.com/ralksta/immich-folio/pull/714),
  [#716](https://github.com/ralksta/immich-folio/pull/716),
  [#717](https://github.com/ralksta/immich-folio/pull/717),
  [#718](https://github.com/ralksta/immich-folio/pull/718),
  [#719](https://github.com/ralksta/immich-folio/pull/719),
  [#721](https://github.com/ralksta/immich-folio/pull/721)):
- **A sidebar instead of the header row**, grouped by task, with an
unread
    count on Messages and a dot on Diagnostics while Immich is down. It
    collapses to an icon rail, with names shown on hover and focus, and
    remembers that per browser.
  - **`/admin` is an overview**: what needs attention (unread messages,
    doctor warnings), published pages, journal entries and recent views.
  - **The site's own visual language** — the preset's fonts, hairline
borders, the square marker — and one page header on every screen. The
accent is kept for the current place and the primary action; switches
    are now pills reading ON or OFF, in green rather than the accent, so
    Studio Modern's red no longer makes an enabled setting look like an
    error.
- **The page builder is a structure list and a panel.** Hero, subpages
and
standalone albums sit in one list on the left, subpages drag-sorted in
    place; the selected entry is edited on the right instead of in an
    overlay.
- **Dialogs only where a change is permanent.** Deleting a message or a
journal entry, and resetting the favicon (which used to delete the file
without asking), go through one confirm dialog in the admin's own look.
Removals in the page builder, which only touch unsaved state, happen at
    once and offer _Undo_. No `window.confirm()` is left.
  - **Feature switches are grouped by meaning** as one-line rows under
Settings → General; page transitions and scroll-to-top moved to Theme →
    Look & motion. No setting keys changed.

### Changed

- **Fonts and the Leaflet stylesheet are served from the site itself**
([#700](https://github.com/ralksta/immich-folio/pull/700)). Every page
used
to link `fonts.googleapis.com`, which sends each visitor's IP address to
Google. The server now fetches a theme's Google fonts once, rewrites the
  stylesheet and caches the files in `content/.fonts/`; custom fonts in
`theme.fonts` keep working. If Google cannot be reached before a font is
  cached, the page falls back to the theme's system fonts. `leaflet.css`
  comes from the npm package instead of unpkg. The CSP drops
  `fonts.googleapis.com`, `fonts.gstatic.com` and `unpkg.com`.

- **Email addresses are kept out of the served HTML**
([#705](https://github.com/ralksta/immich-folio/pull/705)). The
Impressum
and footer render the address only after hydration, from an encoded
form,
  so harvesters reading raw HTML find nothing. Without JavaScript the
Impressum shows `name [at] example [dot] de`. This defeats bulk
harvesting,
  not a person with a browser.

- **The Impressum cites § 5 DDG and links its contacts**
  ([#698](https://github.com/ralksta/immich-folio/pull/698), reported in
  [#697](https://github.com/ralksta/immich-folio/issues/697) by
[@RichKidsDev](https://github.com/RichKidsDev)). The TMG was replaced by
the
DDG in May 2024; `legal.heading` replaces the line for other laws (e.g.
  § 5 ECG in Austria). `legal.contactUrl` and `legal.contactLabel` add a
  contact form as the second channel, so a phone number stays optional.
  Email, phone and contact URL are now links, and the "Created with the
Impressum generator by eRecht24" line, shown on every site whether true
or
not, is gone. Settings → Legal gains fields for heading, contact URL,
VAT
  ID and tax number, and a doctor check flags missing details.

- **Missing pages answer `404`**
([#704](https://github.com/ralksta/immich-folio/pull/704)). Every
missing
  page used to answer `200`, because streaming had started before
`notFound()` ran. `/contact`, `/impressum`, `/map` and `/privacy` while
switched off, and `/journal/<slug>` without an entry, now return a real
404 with the same not-found page. Album and subpage slugs still depend
on
  Immich and stay soft 404s.

- **Passwords set in the admin are stored as scrypt hashes**
  ([#706](https://github.com/ralksta/immich-folio/pull/706), closes
  [#690](https://github.com/ralksta/immich-folio/issues/690)). Subpage,
album, journal-entry and site passwords were written as typed. The save
routes now hash them; a stored hash reads _Protected_ with _Change_ and
_Remove_ and is never put into an input. Visitors who unlocked a page
stay
signed in across unrelated saves. Existing plaintext passwords are
hashed
  the next time their file is saved in the admin.

- **One notification surface in the admin**
([#707](https://github.com/ralksta/immich-folio/pull/707)) replaces
every
`alert()`: errors stay until dismissed, successes clear themselves, and
  screen readers announce both.

### Fixed

- **Map tiles load again**
([#701](https://github.com/ralksta/immich-folio/pull/701)). CARTO
started
answering every tile with an "API KEY REQUIRED" image, leaving `/map`
and
journal map blocks as a grey grid. Tiles now come from OpenStreetMap;
the
  dark look is a CSS filter, so the map now follows the colour mode.

- **The page builder no longer drops a subpage's `proofing:` flag on
save**
([#711](https://github.com/ralksta/immich-folio/pull/711)). A page
switched
  off for proofing by hand reverted after any unrelated edit.

- **Unsaved edits in Settings and About survive switching admin tabs,
and a
  backup restore is no longer overwritten by the next save**
([#687](https://github.com/ralksta/immich-folio/pull/687)). Editors that
were open during a restore now reload and offer unsaved edits back as a
  conflict. Also: "Restore my changes" after a conflict found nothing,
removing an album left its drawer showing the next album, and the
journal
  saved (rotating a backup) without changes.

- **Accent-backed buttons stay readable with a light or malformed
accent**
  ([#724](https://github.com/ralksta/immich-folio/pull/724), supersedes
[#680](https://github.com/ralksta/immich-folio/pull/680)) — contributed
by
  [@lancetm714](https://github.com/lancetm714). A white accent made the
  proofing dialog's _Download selected (.zip)_ button white on white.
`theme.accent` is now accepted as hex only and normalised to `#rrggbb`,
anything else falls back to the preset, and the proofing dialog and
filter
  pills use the mode-aware `--on-accent` text colour from #688.

- **Every preset's accent is visible in both colour modes**
([#688](https://github.com/ralksta/immich-folio/pull/688)). Presets had
one
accent for both modes; minimal's dark mode drew black buttons on black.
  Presets now carry a light and a dark accent, and text on accent-filled
  buttons picks black or white for contrast. A custom accent stays the
  owner's choice in both modes.

- **Visitor-side inconsistencies**
([#685](https://github.com/ralksta/immich-folio/pull/685)): after
visiting
`/impressum`, every subpage header turned centred and uppercase; back
links
  announced "Back to Back to …"; the hero EXIF chip ignored
`exif.location`/`camera`/`settings`; videos in journal entries opened as
  stills; the setup screen named a non-existent `.env.example`.

- **An essay's lightbox walks only the photos the story shows**
([#686](https://github.com/ralksta/immich-folio/pull/686)), not every
photo
of the subpage's albums, and the proofing bar in essays shows the
selection
  count instead of a filter button that did nothing.

- **The page panel scrolls with the mouse wheel**
  ([#720](https://github.com/ralksta/immich-folio/pull/720)).

- **The journal editor's top bar wraps on phones**
  ([#723](https://github.com/ralksta/immich-folio/pull/723), closes
[#675](https://github.com/ralksta/immich-folio/issues/675)), so the
title
  stays editable and Save stays on screen.

- **Every admin form field has a name for assistive technology**
([#715](https://github.com/ralksta/immich-folio/pull/715)). Only two
inputs
had a linked label; 47 are linked now, and clicking a label focuses its
  field.

### Internal

- **`SettingsEditor` and `PageBuilder` are split up**
  ([#710](https://github.com/ralksta/immich-folio/pull/710),
[#711](https://github.com/ralksta/immich-folio/pull/711)). The
2,200-line
settings editor is a shell with one file per section; the page builder's
YAML handling and edits are pure functions under test. Both were checked
  side by side against `dev` for identical output.
- **`useAdminFetch()`**
([#707](https://github.com/ralksta/immich-folio/pull/707))
gives Analytics, Messages, Backups and the journal list one loading and
  error pattern.
- README brought up to v0.17.0
  ([#678](https://github.com/ralksta/immich-folio/pull/678),
  [#679](https://github.com/ralksta/immich-folio/pull/679)).

### Upgrade notes

**`content/` gains two directories the app writes to:**
`content/.fonts/`
for the cached fonts and `content/messages/` for contact-form messages.
Both
are created on demand, so a writable `content/` is enough, as before.

**The server needs outbound HTTPS to Google once per font** to fill the
font
cache. Until then pages fall back to the theme's system fonts. Visitors'
browsers no longer contact Google.

**A custom CSP or reverse-proxy allowlist** must now allow
`tile.openstreetmap.org` for images instead of
`*.basemaps.cartocdn.com`,
and no longer needs `fonts.googleapis.com`, `fonts.gstatic.com` or
`unpkg.com`. The built-in CSP is updated.

**`theme.accent` must be hex** (`#rgb`, `#rrggbb` or `#rrggbbaa`).
Anything
else, including a value without `#`, falls back to the preset's accent.

**Plaintext passwords in `gallery.yaml`, `settings.yaml` or journal
entries**
are hashed the next time that file is saved in the admin; one save
clears the
doctor's warning.

**New, optional settings:** `contact.*` (the form is off by default),
`privacy.enabled`, `legal.heading`, `legal.contactUrl`,
`legal.contactLabel`,
and the `CDN_URL` and `CONTACT_NOTIFY_URL` environment variables. See
`content/settings.yaml.example` and `.env.local.example`. In the example
file,
`vatId` and `taxId` had swapped sample values; check yours if you copied
them.

No `IMAGE_CACHE_VERSION` bump is needed this time.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
This commit is contained in:
Ralksta
2026-09-27 14:43:38 +02:00
committed by GitHub
parent eab3b9c257
commit 3193c500e4
155 changed files with 13188 additions and 3539 deletions

View File

@@ -8,6 +8,8 @@ import type { DoctorLevel } from '@/lib/admin/doctor';
import { DOCTOR_LEVEL_EVENT, systemHealth } from './systemHealth';
import { reportIfSessionExpired } from './sessionExpiry';
import * as Icons from './Icons';
import { useNotify } from './Notifications';
import { adminGet } from './useAdminFetch';
interface Props {
onLogout: () => void;
@@ -15,16 +17,100 @@ interface Props {
children: ReactNode;
}
const TABS = [
{ label: 'Pages', href: '/admin/pages', match: /^\/admin\/pages$/ },
{ label: 'Journal', href: '/admin/journal', match: /^\/admin\/journal/ },
{ label: 'Settings', href: '/admin/settings', match: /^\/admin\/settings/ },
{ label: 'Analytics', href: '/admin/analytics', match: /^\/admin\/analytics/ },
{ label: 'Diagnostics', href: '/admin/diagnostics', match: /^\/admin\/diagnostics/ },
{ label: 'Help', href: '/admin/help', match: /^\/admin\/help/ },
/**
* The sidebar, grouped by what the owner is doing (docs/admin-ux-concept.md).
* `match` decides the active entry; Overview only matches /admin itself.
*/
const NAV: {
group?: string;
items: {
label: string;
href: string;
match: RegExp;
icon: ReactNode;
badge?: 'messages' | 'health';
}[];
}[] = [
{
items: [
{
label: 'Overview',
href: '/admin',
match: /^\/admin\/?$/,
icon: <Icons.IconHome size={16} />,
},
],
},
{
group: 'Content',
items: [
{
label: 'Pages',
href: '/admin/pages',
match: /^\/admin\/pages$/,
icon: <Icons.IconGrid size={16} />,
},
{
label: 'Journal',
href: '/admin/journal',
match: /^\/admin\/journal/,
icon: <Icons.IconBook size={16} />,
},
],
},
{
group: 'Visitors',
items: [
{
label: 'Messages',
href: '/admin/messages',
match: /^\/admin\/messages/,
icon: <Icons.IconFileText size={16} />,
badge: 'messages',
},
{
label: 'Analytics',
href: '/admin/analytics',
match: /^\/admin\/analytics/,
icon: <Icons.IconBarChart size={16} />,
},
],
},
{
group: 'Site',
items: [
{
label: 'Settings',
href: '/admin/settings',
match: /^\/admin\/settings/,
icon: <Icons.IconGear size={16} />,
},
],
},
{
group: 'System',
items: [
{
label: 'Diagnostics',
href: '/admin/diagnostics',
match: /^\/admin\/diagnostics/,
icon: <Icons.IconShieldCheck size={16} />,
badge: 'health',
},
{
label: 'Help',
href: '/admin/help',
match: /^\/admin\/help/,
icon: <Icons.IconQuote size={16} />,
},
],
},
];
const SIDEBAR_KEY = 'folio_admin_sidebar';
export default function AdminDashboard({ onLogout, children }: Props) {
const notify = useNotify();
const pathname = usePathname();
const [saving, setSaving] = useState(false);
@@ -40,6 +126,29 @@ export default function AdminDashboard({ onLogout, children }: Props) {
// an expired session or a 500 rendered exactly like a real outage — every
// indicator flipped to its alarming value at once (#341).
const [statusError, setStatusError] = useState(false);
/**
* Icons-only sidebar, remembered per browser. The dashboard only renders
* after the client-side session check, so reading storage in the initial
* state cannot mismatch a server render.
*/
const [collapsed, setCollapsed] = useState<boolean>(() => {
try {
return window.localStorage.getItem(SIDEBAR_KEY) === 'collapsed';
} catch {
return false;
}
});
const toggleCollapsed = () =>
setCollapsed((c) => {
try {
window.localStorage.setItem(SIDEBAR_KEY, c ? 'expanded' : 'collapsed');
} catch {
// Private mode or blocked storage: the toggle still works for this visit.
}
return !c;
});
/** Unread contact messages, for the count next to Messages. */
const [unread, setUnread] = useState(0);
async function handleLogout() {
await fetch('/api/admin/auth', { method: 'DELETE' });
@@ -54,7 +163,7 @@ export default function AdminDashboard({ onLogout, children }: Props) {
// result was never checked at all (#596).
if (!res.ok) {
if (!reportIfSessionExpired(res)) {
alert(`Reload failed (HTTP ${res.status}).`);
notify('error', `Reload failed (HTTP ${res.status}).`);
}
return;
}
@@ -112,6 +221,18 @@ export default function AdminDashboard({ onLogout, children }: Props) {
fetchDoctor();
}, [fetchDoctor]);
// Re-counted on every navigation: reading a message in the inbox and moving
// on should clear the count without a reload.
useEffect(() => {
let live = true;
adminGet<{ messages: { read: boolean }[] }>('/api/admin/messages').then((r) => {
if (live && r.data) setUnread(r.data.messages.filter((m) => !m.read).length);
});
return () => {
live = false;
};
}, [pathname]);
// The diagnostics page runs the doctor itself; take its answer rather than
// asking Immich the same three questions again.
useEffect(() => {
@@ -146,36 +267,81 @@ export default function AdminDashboard({ onLogout, children }: Props) {
const missingCredentials = status?.setup?.credentials === 'missing';
return (
<div className="admin-dashboard">
<header className="admin-header">
<div className="admin-header-left">
<h1>Immich Folio</h1>
<nav className="admin-tabs">
{TABS.map((t) => {
const active = t.match.test(pathname);
return (
<Link
key={t.href}
href={t.href}
className={`admin-tab ${active ? 'active' : ''}`}
aria-current={active ? 'page' : undefined}
>
{t.label}
</Link>
);
})}
</nav>
</div>
<div className="admin-header-right">
<div className={`admin-dashboard admin-app${collapsed ? ' is-collapsed' : ''}`}>
<aside className="admin-sidebar" aria-label="Admin">
<button
type="button"
className="admin-sidebar-toggle"
onClick={toggleCollapsed}
aria-expanded={!collapsed}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
data-tip={collapsed ? 'Expand' : undefined}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<polyline points={collapsed ? '9 6 15 12 9 18' : '15 6 9 12 15 18'} />
</svg>
</button>
<Link href="/admin" className="admin-brand" data-tip="Immich Folio">
<span className="admin-brand-mark" aria-hidden="true" />
<span className="admin-brand-text">
<span className="admin-brand-name">Immich Folio</span>
<span className="admin-brand-sub">Admin</span>
</span>
</Link>
<nav className="admin-nav">
{NAV.map((section, i) => (
<div key={section.group ?? i} className="admin-nav-group">
{section.group && <span className="admin-nav-label">{section.group}</span>}
{section.items.map((item) => {
const active = item.match.test(pathname);
const count = item.badge === 'messages' ? unread : 0;
return (
<Link
key={item.href}
href={item.href}
className={`admin-nav-item ${active ? 'active' : ''}`}
data-tip={item.label}
aria-current={active ? 'page' : undefined}
>
<span className="admin-nav-icon">{item.icon}</span>
<span className="admin-nav-text">{item.label}</span>
{count > 0 && (
<span className="admin-nav-count" aria-label={`${count} unread`}>
{count}
</span>
)}
{item.badge === 'health' && health.tone === 'disconnected' && (
<span className={`admin-nav-dot ${health.tone}`} aria-label={health.label} />
)}
</Link>
);
})}
</div>
))}
</nav>
<div className="admin-sidebar-foot">
{/* Diagnostics Badge */}
<div className="status-indicator-container">
<button
className={`status-badge-btn ${health.tone}`}
className={`status-badge-btn sidebar-status ${health.tone}`}
onClick={() => {
setShowStatus(!showStatus);
if (!showStatus) fetchStatus();
}}
title="Show system status"
data-tip={health.label}
>
<span className="status-dot"></span>
<span className="status-text">{health.label}</span>
@@ -266,39 +432,46 @@ export default function AdminDashboard({ onLogout, children }: Props) {
)}
</div>
<span className="admin-header-divider" aria-hidden="true" />
<a
href="/?fresh=1"
target="_blank"
rel="noopener noreferrer"
className="admin-btn admin-btn-ghost"
title="Open site in new tab (bypassing cache)"
>
<Icons.IconLink size={14} /> Site
</a>
<button
className="admin-btn admin-btn-ghost"
onClick={() => setShowBackupModal(true)}
title="Manage config backups & restore"
>
<Icons.IconArchive size={14} /> Backups
</button>
<button
className="admin-btn admin-btn-ghost"
onClick={handleReload}
disabled={saving}
title="Reload config & clear cache"
>
<Icons.IconRefresh size={14} /> Reload
</button>
<button className="admin-btn admin-btn-ghost" onClick={handleLogout}>
Logout
</button>
<div className="admin-sidebar-actions">
<a
href="/?fresh=1"
target="_blank"
rel="noopener noreferrer"
className="admin-side-action"
data-tip="View site"
title="Open site in new tab (bypassing cache)"
>
<Icons.IconLink size={14} />
<span className="admin-side-label">View site</span>
</a>
<button
className="admin-side-action"
data-tip="Backups"
onClick={() => setShowBackupModal(true)}
title="Manage config backups & restore"
>
<Icons.IconArchive size={14} />
<span className="admin-side-label">Backups</span>
</button>
<button
className="admin-side-action"
data-tip="Reload"
onClick={handleReload}
disabled={saving}
title="Reload config & clear cache"
>
<Icons.IconRefresh size={14} />
<span className="admin-side-label">Reload</span>
</button>
<button className="admin-side-action" data-tip="Sign out" onClick={handleLogout}>
<Icons.IconX size={14} />
<span className="admin-side-label">Sign out</span>
</button>
</div>
</div>
</header>
</aside>
<main className="admin-main">{children}</main>
<main className="admin-main admin-workspace">{children}</main>
<BackupManagerModal
isOpen={showBackupModal}

View File

@@ -0,0 +1,42 @@
'use client';
import * as Icons from './Icons';
/**
* The one spinner and the one error-with-retry for admin screens (#609), so a
* failed load looks the same everywhere and always offers a way out.
*
* Renders nothing once there is data and no error; the caller renders the
* content then.
*/
export default function AdminLoadState({
loading,
error,
onRetry,
hasData,
}: {
loading: boolean;
error: string | null;
onRetry: () => void;
/** With data already on screen, a reload shows no spinner over it. */
hasData?: boolean;
}) {
if (error) {
return (
<div className="admin-load-error" role="alert">
<p>{error}</p>
<button type="button" className="admin-btn admin-btn-primary" onClick={onRetry}>
<Icons.IconRefresh size={14} /> Retry
</button>
</div>
);
}
if (loading && !hasData) {
return (
<div className="admin-loading-container" role="status" aria-label="Loading">
<div className="admin-spinner" />
</div>
);
}
return null;
}

View File

@@ -0,0 +1,229 @@
'use client';
/**
* /admin: the first screen after signing in (docs/admin-ux-concept.md).
*
* Says what needs attention before it is asked for (unread messages, doctor
* findings), then the site at a glance. Everything on it links to the screen
* where it is acted on. It only reads the existing admin endpoints; a failed
* one leaves its part out rather than failing the page.
*/
import { useEffect, useState } from 'react';
import Link from 'next/link';
import * as Icons from './Icons';
import { adminGet } from './useAdminFetch';
import type { DoctorFinding, DoctorLevel } from '@/lib/admin/doctor';
import { DOCTOR_LEVEL_EVENT } from './systemHealth';
interface OverviewData {
gallery: { hero?: unknown; subpages?: Array<{ enabled?: boolean }> } | null;
journal: Array<{ frontmatter: { draft?: boolean } }> | null;
messages: Array<{ read: boolean; name: string }> | null;
analytics: { days?: Record<string, { pageviews?: number }> } | null;
doctor: { level: DoctorLevel; findings: DoctorFinding[] } | null;
}
/** Where a doctor finding is fixed; mirrors FIXES in DiagnosticsView. */
const FIX_ROUTE: Record<string, string> = {
legal: '/admin/settings/legal',
contact: '/admin/settings/legal',
privacy: '/admin/settings/legal',
passwords: '/admin/settings/security',
'album-ids': '/admin/pages',
'albums-shared': '/admin/pages',
};
function viewsLastDays(
days: Record<string, { pageviews?: number }> | undefined,
n: number,
): number {
if (!days) return 0;
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - (n - 1));
const from = cutoff.toISOString().slice(0, 10);
return Object.entries(days)
.filter(([date]) => date >= from)
.reduce((sum, [, d]) => sum + (d.pageviews || 0), 0);
}
function greeting(): string {
const h = new Date().getHours();
if (h < 5) return 'Good night';
if (h < 12) return 'Good morning';
if (h < 18) return 'Good afternoon';
return 'Good evening';
}
export default function AdminOverview() {
const [data, setData] = useState<OverviewData | null>(null);
useEffect(() => {
let live = true;
Promise.all([
adminGet<{ gallery: OverviewData['gallery'] }>('/api/admin/gallery'),
adminGet<{ entries: NonNullable<OverviewData['journal']> }>('/api/admin/journal'),
adminGet<{ messages: NonNullable<OverviewData['messages']> }>('/api/admin/messages'),
adminGet<NonNullable<OverviewData['analytics']>>('/api/admin/analytics'),
adminGet<NonNullable<OverviewData['doctor']>>('/api/admin/doctor'),
]).then(([gallery, journal, messages, analytics, doctor]) => {
if (!live) return;
setData({
gallery: gallery.data?.gallery ?? null,
journal: journal.data?.entries ?? null,
messages: messages.data?.messages ?? null,
analytics: analytics.data ?? null,
doctor: doctor.data ?? null,
});
// The sidebar badge takes the level from here instead of asking again.
if (doctor.data?.level) {
window.dispatchEvent(new CustomEvent(DOCTOR_LEVEL_EVENT, { detail: doctor.data.level }));
}
});
return () => {
live = false;
};
}, []);
const subpages = data?.gallery?.subpages ?? [];
const published = subpages.filter((sp) => sp.enabled !== false).length;
const heroRaw = data?.gallery?.hero;
const heroCount = Array.isArray(heroRaw) ? heroRaw.length : heroRaw ? 1 : 0;
const entries = data?.journal ?? [];
const drafts = entries.filter((e) => e.frontmatter.draft).length;
const unread = (data?.messages ?? []).filter((m) => !m.read);
const views7 = viewsLastDays(data?.analytics?.days, 7);
const findings = (data?.doctor?.findings ?? []).filter((f) => f.level !== 'ok');
const attention: Array<{
tone: 'accent' | 'warn' | 'error';
title: string;
detail?: string;
href: string;
}> = [];
if (unread.length) {
attention.push({
tone: 'accent',
title: `${unread.length} unread message${unread.length === 1 ? '' : 's'}`,
detail: `From ${[...new Set(unread.map((m) => m.name))].slice(0, 3).join(', ')}`,
href: '/admin/messages',
});
}
for (const f of findings) {
attention.push({
tone: f.level === 'error' ? 'error' : 'warn',
title: f.title,
detail: f.detail,
href: FIX_ROUTE[f.id] ?? '/admin/diagnostics',
});
}
return (
<div className="admin-overview">
<header className="overview-head">
<div>
<p className="overview-kicker">Overview</p>
<h1 className="overview-title">{greeting()}.</h1>
<p className="overview-sub">
{data
? attention.length
? `${attention.length} thing${attention.length === 1 ? '' : 's'} could use a look.`
: 'Everything is in order.'
: 'Checking the site…'}
</p>
</div>
<div className="overview-actions">
<a
className="admin-btn admin-btn-ghost"
href="/?fresh=1"
target="_blank"
rel="noopener noreferrer"
>
<Icons.IconLink size={14} /> View site
</a>
<Link className="admin-btn admin-btn-primary" href="/admin/pages">
<Icons.IconPencil size={14} /> Edit pages
</Link>
</div>
</header>
<section aria-labelledby="overview-attention">
<h2 id="overview-attention" className="overview-section-label">
Needs attention
</h2>
{!data ? (
<div className="admin-spinner" />
) : attention.length ? (
<div className="overview-attention">
{attention.map((a, i) => (
<Link key={i} href={a.href} className="overview-attention-item">
<span className={`overview-attention-marker ${a.tone}`} aria-hidden="true" />
<span className="overview-attention-text">
{a.title}
{a.detail && <span className="overview-attention-detail">{a.detail}</span>}
</span>
<span className="overview-attention-go">Open</span>
</Link>
))}
</div>
) : (
<p className="overview-calm">Nothing waiting. New messages and problems show up here.</p>
)}
</section>
<section aria-labelledby="overview-glance">
<h2 id="overview-glance" className="overview-section-label">
At a glance
</h2>
<div className="overview-stats">
<Link href="/admin/pages" className="overview-stat">
<span className="overview-stat-label">Pages</span>
<span className="overview-stat-value">{data ? published : '–'}</span>
<span className="overview-stat-note">
{subpages.length - published > 0
? `${subpages.length - published} switched off`
: 'all published'}
</span>
</Link>
<Link href="/admin/pages" className="overview-stat">
<span className="overview-stat-label">Hero photos</span>
<span className="overview-stat-value">{data ? heroCount : '–'}</span>
<span className="overview-stat-note">on the home page</span>
</Link>
<Link href="/admin/journal" className="overview-stat">
<span className="overview-stat-label">Journal</span>
<span className="overview-stat-value">{data ? entries.length : '–'}</span>
<span className="overview-stat-note">
{drafts ? `${drafts} draft${drafts === 1 ? '' : 's'}` : 'no drafts'}
</span>
</Link>
<Link href="/admin/analytics" className="overview-stat">
<span className="overview-stat-label">Views · 7 days</span>
<span className="overview-stat-value">{data ? views7.toLocaleString() : '–'}</span>
<span className="overview-stat-note">cookieless count</span>
</Link>
</div>
</section>
<section aria-labelledby="overview-next">
<h2 id="overview-next" className="overview-section-label">
Quick actions
</h2>
<div className="overview-actions">
<Link className="admin-btn admin-btn-ghost" href="/admin/journal">
<Icons.IconPlus size={14} /> New journal entry
</Link>
<Link className="admin-btn admin-btn-ghost" href="/admin/messages">
<Icons.IconFileText size={14} /> Messages
</Link>
<Link className="admin-btn admin-btn-ghost" href="/admin/settings/theme">
<Icons.IconPalette size={14} /> Theme
</Link>
<Link className="admin-btn admin-btn-ghost" href="/admin/diagnostics">
<Icons.IconShieldCheck size={14} /> Diagnostics
</Link>
</div>
</section>
</div>
);
}

View File

@@ -1,7 +1,9 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import * as Icons from './Icons';
import { useAdminFetch } from './useAdminFetch';
import AdminLoadState from './AdminLoadState';
import PageHeader from './PageHeader';
interface AnalyticsData {
trackingEnabled?: boolean;
@@ -22,47 +24,17 @@ interface AnalyticsData {
}
export default function AnalyticsView() {
const [data, setData] = useState<AnalyticsData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const analytics = useAdminFetch<AnalyticsData>('/api/admin/analytics');
const data = analytics.data;
const fetchAnalytics = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/admin/analytics');
if (!res.ok) throw new Error('Failed to fetch analytics');
const json = await res.json();
setData(json);
} catch (err: any) {
setError(err.message || 'Error loading analytics');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchAnalytics();
}, [fetchAnalytics]);
if (loading) {
if (!data || analytics.error) {
return (
<div className="admin-loading-container">
<div className="admin-spinner" />
</div>
);
}
if (error || !data) {
return (
<div className="admin-panel" style={{ padding: '2rem', textAlign: 'center' }}>
<p style={{ color: 'var(--admin-text-muted)', marginBottom: '1rem' }}>
{error || 'No analytics data'}
</p>
<button className="admin-btn admin-btn-primary" onClick={fetchAnalytics}>
<Icons.IconRefresh size={14} /> Retry
</button>
</div>
<AdminLoadState
loading={analytics.loading}
error={analytics.error}
onRetry={analytics.reload}
hasData={!!data}
/>
);
}
@@ -80,20 +52,16 @@ export default function AnalyticsView() {
return (
<div className="analytics-view">
{/* Header */}
<div className="analytics-header">
<div>
<h2>
<Icons.IconSparkles size={20} /> Visitor Insights &amp; Analytics
</h2>
<p className="analytics-subtitle">
Privacy-first aggregate traffic stats. No personal data collected.
</p>
</div>
<button className="admin-btn admin-btn-ghost admin-btn-sm" onClick={fetchAnalytics}>
<Icons.IconRefresh size={14} /> Refresh
</button>
</div>
<PageHeader
kicker="Visitors"
title="Analytics"
description="Cookieless page counts, kept on this server. No personal data is collected."
actions={
<button className="admin-btn admin-btn-ghost admin-btn-sm" onClick={analytics.reload}>
<Icons.IconRefresh size={14} /> Refresh
</button>
}
/>
{data.trackingEnabled === false && (
<div

View File

@@ -1,10 +1,12 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect } from 'react';
import type { BackupItem, BackupTarget } from '@/app/api/admin/backups/route';
import * as Icons from './Icons';
import { useScrollLock } from './useScrollLock';
import { useModalDialog } from '@/hooks/useModalDialog';
import { reportContentRestored } from './contentRestored';
import { useAdminFetch } from './useAdminFetch';
interface Props {
isOpen: boolean;
@@ -16,48 +18,47 @@ const TABS: { target: BackupTarget; label: string; file: string }[] = [
{ target: 'gallery', label: 'Gallery', file: 'gallery.yaml' },
{ target: 'settings', label: 'Settings', file: 'settings.yaml' },
{ target: 'about', label: 'About', file: 'about.md' },
{ target: 'privacy', label: 'Privacy', file: 'privacy.md' },
{ target: 'journal', label: 'Journal', file: 'journal entries' },
];
type BackupLists = Record<BackupTarget, BackupItem[]>;
const EMPTY_LISTS: BackupLists = { gallery: [], settings: [], about: [], journal: [] };
const EMPTY_LISTS: BackupLists = {
gallery: [],
settings: [],
about: [],
privacy: [],
journal: [],
};
export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }: Props) {
const [activeTab, setActiveTab] = useState<BackupTarget>('gallery');
const [showAllBackups, setShowAllBackups] = useState(false);
const [backups, setBackups] = useState<BackupLists>(EMPTY_LISTS);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const list = useAdminFetch<{ backups: Partial<BackupLists> }>(
isOpen ? '/api/admin/backups' : null,
);
const backups: BackupLists = { ...EMPTY_LISTS, ...list.data?.backups };
/** A failed restore; a failed load is list.error. */
const [restoreError, setRestoreError] = useState<string | null>(null);
const error = list.error ?? restoreError;
const loading = list.loading;
const reloadList = list.reload;
const [restoringFilename, setRestoringFilename] = useState<string | null>(null);
const [confirmItem, setConfirmItem] = useState<BackupItem | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
const fetchBackups = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/admin/backups');
if (!res.ok) {
throw new Error('Failed to fetch backup history');
}
const data = await res.json();
setBackups({ ...EMPTY_LISTS, ...data.backups });
} catch (err: any) {
setError(err.message || 'Error loading backups');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (isOpen) {
fetchBackups();
// Each opening shows the backups as they are now, not as they were
// the last time the modal was open.
reloadList();
setRestoreError(null);
setConfirmItem(null);
setSuccessMsg(null);
setShowAllBackups(false);
}
}, [isOpen, fetchBackups]);
}, [isOpen, reloadList]);
useScrollLock(isOpen);
/* Before the early `return null`: hooks must not run conditionally. */
@@ -68,7 +69,7 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
async function handleRestore(item: BackupItem) {
const filename = item.filename;
setRestoringFilename(filename);
setError(null);
setRestoreError(null);
setSuccessMsg(null);
try {
@@ -90,10 +91,12 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
: `Backup restored successfully! (${filename})`,
);
setConfirmItem(null);
await fetchBackups();
// Open editors still hold the pre-restore state; they reload on this.
reportContentRestored({ target: item.target, slug: item.slug });
reloadList();
onRestoreSuccess();
} catch (err: any) {
setError(err.message || 'Error restoring backup');
} catch (err) {
setRestoreError(err instanceof Error ? err.message : 'Error restoring backup');
} finally {
setRestoringFilename(null);
}

View File

@@ -0,0 +1,115 @@
'use client';
/**
* One confirmation for the admin (UX stage 3, #694 §3), replacing the
* browser's `confirm()`, which cannot be styled, blocks the tab and reads the
* same for "discard a draft" and "delete a file".
*
* Policy: ask only before something that is permanent or takes effect at
* once (deleting a message, a journal entry, the uploaded favicon). Removals
* of unsaved state are undone by not saving and do not need a dialog.
*
* const confirm = useConfirm();
* if (!(await confirm({ title: 'Delete message?', confirmLabel: 'Delete', danger: true }))) return;
*/
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react';
import { useModalDialog } from '@/hooks/useModalDialog';
export interface ConfirmOptions {
title: string;
/** What happens, in one or two sentences. */
message?: ReactNode;
confirmLabel?: string;
cancelLabel?: string;
/** Destructive: the confirm button uses the error colour. */
danger?: boolean;
}
type Confirm = (options: ConfirmOptions) => Promise<boolean>;
/** Outside the provider (a component tested on its own) it falls back to window.confirm. */
const ConfirmContext = createContext<Confirm>(async (o) =>
typeof window === 'undefined' ? false : window.confirm(o.title),
);
export function useConfirm(): Confirm {
return useContext(ConfirmContext);
}
export function ConfirmProvider({ children }: { children: ReactNode }) {
const [pending, setPending] = useState<ConfirmOptions | null>(null);
const resolver = useRef<((ok: boolean) => void) | null>(null);
const confirm = useCallback<Confirm>(
(options) =>
new Promise<boolean>((resolve) => {
resolver.current?.(false);
resolver.current = resolve;
setPending(options);
}),
[],
);
const settle = useCallback((ok: boolean) => {
resolver.current?.(ok);
resolver.current = null;
setPending(null);
}, []);
return (
<ConfirmContext.Provider value={confirm}>
{children}
{pending && <Dialog options={pending} onSettle={settle} />}
</ConfirmContext.Provider>
);
}
function Dialog({
options,
onSettle,
}: {
options: ConfirmOptions;
onSettle: (ok: boolean) => void;
}) {
// Esc cancels, focus is trapped and returned (the same hook the modals use).
const cardRef = useModalDialog(() => onSettle(false));
return (
<div className="confirm-backdrop" onClick={() => onSettle(false)}>
<div
ref={cardRef}
className="confirm-card"
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-title"
aria-describedby={options.message ? 'confirm-message' : undefined}
onClick={(e) => e.stopPropagation()}
>
<h2 id="confirm-title" className="confirm-title">
{options.title}
</h2>
{options.message && (
<p id="confirm-message" className="confirm-message">
{options.message}
</p>
)}
<div className="confirm-actions">
<button
type="button"
className="admin-btn admin-btn-ghost"
onClick={() => onSettle(false)}
>
{options.cancelLabel ?? 'Cancel'}
</button>
<button
type="button"
className={`admin-btn ${options.danger ? 'confirm-danger' : 'admin-btn-primary'}`}
onClick={() => onSettle(true)}
>
{options.confirmLabel ?? 'Confirm'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -7,6 +7,7 @@ import * as Icons from './Icons';
import { DOCTOR_LEVEL_EVENT } from './systemHealth';
import type { DoctorFinding, DoctorLevel } from '@/lib/admin/doctor';
import type { AltTextReport } from '@/lib/admin/alt-text';
import PageHeader from './PageHeader';
/**
* Diagnostics as a page (/admin/diagnostics).
@@ -40,8 +41,12 @@ type AltTextData = AltTextReport & {
/** Which group a doctor check belongs to. An unknown id lands in Content. */
const GROUPS: { id: string; title: string; checks: string[] }[] = [
{ id: 'connection', title: 'Connection', checks: ['immich-api', 'album-ids'] },
{ id: 'security', title: 'Security', checks: ['auth-secret', 'passwords', 'proxy-hops'] },
{ id: 'content', title: 'Content', checks: ['albums-shared', 'content-writable', 'alt-text'] },
{ id: 'security', title: 'Security', checks: ['auth-secret', 'passwords', 'proxy-hops', 'cdn'] },
{
id: 'content',
title: 'Content',
checks: ['albums-shared', 'content-writable', 'alt-text', 'legal', 'contact', 'privacy'],
},
];
const LEVEL_LABEL: Record<DoctorLevel, string> = { ok: 'OK', warn: 'Check', error: 'Problem' };
@@ -56,9 +61,13 @@ const FIXES: Record<string, { label: string; href: string }> = {
href: `${DOCS}/README.md#environment-variables-envlocal`,
},
'proxy-hops': { label: 'How to set it', href: `${DOCS}/docs/gallery-config.md#trusted-proxies` },
cdn: { label: 'CDN setup', href: `${DOCS}/docs/deployment.md#cdn-mode` },
passwords: { label: 'Open settings', href: '/admin/settings/security' },
'album-ids': { label: 'Open pages', href: '/admin/pages' },
'albums-shared': { label: 'Open pages', href: '/admin/pages' },
legal: { label: 'Open settings', href: '/admin/settings/legal' },
contact: { label: 'Open settings', href: '/admin/settings/legal' },
privacy: { label: 'Open settings', href: '/admin/settings/legal' },
'content-writable': { label: 'How to fix', href: `${DOCS}/docs/admin-panel.md#docker-usage` },
'immich-api': {
label: 'API key permissions',
@@ -371,27 +380,26 @@ export default function DiagnosticsView() {
return (
<div className="diag-page">
<div className="diag-head">
<div>
<h2>{headline}</h2>
<p className="diag-sub">
{ranAt ? `Last run ${ranAt.toLocaleString()}` : 'Running the checks…'}
</p>
</div>
<div className="diag-actions">
<button className="admin-btn admin-btn-sm" onClick={run} disabled={loading}>
<Icons.IconRefresh size={14} /> {loading ? 'Checking…' : 'Run again'}
</button>
<button
className="admin-btn admin-btn-sm"
onClick={copyReport}
disabled={loading || !all.length}
title="Markdown, ready to paste into an issue"
>
<Icons.IconCopy size={14} /> {copied ? 'Copied' : 'Copy report'}
</button>
</div>
</div>
<PageHeader
kicker="System"
title={headline}
description={ranAt ? `Last run ${ranAt.toLocaleString()}` : 'Running the checks…'}
actions={
<>
<button className="admin-btn admin-btn-sm" onClick={run} disabled={loading}>
<Icons.IconRefresh size={14} /> {loading ? 'Checking…' : 'Run again'}
</button>
<button
className="admin-btn admin-btn-sm"
onClick={copyReport}
disabled={loading || !all.length}
title="Markdown, ready to paste into an issue"
>
<Icons.IconCopy size={14} /> {copied ? 'Copied' : 'Copy report'}
</button>
</>
}
/>
{error && <div className="admin-error">{error}</div>}

View File

@@ -3,6 +3,7 @@
import * as Icons from './Icons';
import { en } from '@/lib/i18n/locales/en';
import { LIGHTBOX_SHORTCUTS, shortcutDisplayKeys } from '@/lib/lightboxShortcuts';
import PageHeader from './PageHeader';
/**
* The admin help section.
@@ -19,14 +20,11 @@ import { LIGHTBOX_SHORTCUTS, shortcutDisplayKeys } from '@/lib/lightboxShortcuts
export default function HelpView() {
return (
<div className="settings-panel">
<div className="settings-section-header">
<h3>
<Icons.IconFileText size={18} /> Help
</h3>
<p className="settings-section-sub">
What your portfolio can do that has no button of its own.
</p>
</div>
<PageHeader
kicker="System"
title="Help"
description="What your portfolio can do that has no button of its own."
/>
<div className="settings-group">
<div className="settings-group-head">

View File

@@ -0,0 +1,192 @@
'use client';
/**
* The contact form inbox at /admin/messages (#702). Messages live in
* content/messages/ (lib/contact.ts); replying happens in the owner's own
* mail client through a mailto: link, since Folio sends no mail.
*/
import { useState } from 'react';
import Link from 'next/link';
import * as Icons from './Icons';
import { reportIfSessionExpired } from './sessionExpiry';
import { useAdminFetch } from './useAdminFetch';
import AdminLoadState from './AdminLoadState';
import type { ContactMessage } from '@/lib/contact';
import { useNotify } from './Notifications';
import PageHeader from './PageHeader';
import { useConfirm } from './ConfirmDialog';
interface InboxData {
enabled: boolean;
notifyConfigured: boolean;
retentionDays: number;
messages: ContactMessage[];
}
function replyHref(m: ContactMessage): string {
const subject = encodeURIComponent('Re: your message');
const quoted = m.message
.split('\n')
.map((line) => `> ${line}`)
.join('\n');
const body = encodeURIComponent(`\n\n${m.name} wrote:\n${quoted}`);
// Encoded so a ? or & in the address cannot start the query, with the @
// put back: not every mail client decodes %40 in the address part.
const to = encodeURIComponent(m.email).replace(/%40/g, '@');
return `mailto:${to}?subject=${subject}&body=${body}`;
}
export default function MessagesView() {
const confirm = useConfirm();
const notify = useNotify();
const inbox = useAdminFetch<InboxData>('/api/admin/messages');
const data = inbox.data;
const [open, setOpen] = useState<string | null>(null);
async function markRead(m: ContactMessage, read: boolean) {
const res = await fetch('/api/admin/messages', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: m.id, read }),
});
if (!res.ok) {
reportIfSessionExpired(res);
return;
}
inbox.mutate((d) => ({
...d,
messages: d.messages.map((x) => (x.id === m.id ? { ...x, read } : x)),
}));
}
function toggle(m: ContactMessage) {
const opening = open !== m.id;
setOpen(opening ? m.id : null);
if (opening && !m.read) markRead(m, true);
}
async function remove(m: ContactMessage) {
const ok = await confirm({
title: `Delete the message from ${m.name}?`,
message: 'It is removed from this server and cannot be restored.',
confirmLabel: 'Delete',
danger: true,
});
if (!ok) return;
const res = await fetch(`/api/admin/messages?id=${encodeURIComponent(m.id)}`, {
method: 'DELETE',
});
if (!res.ok) {
if (!reportIfSessionExpired(res)) notify('error', `Delete failed (HTTP ${res.status}).`);
return;
}
inbox.mutate((d) => ({ ...d, messages: d.messages.filter((x) => x.id !== m.id) }));
}
if (!data || inbox.error) {
return (
<AdminLoadState
loading={inbox.loading}
error={inbox.error}
onRetry={inbox.reload}
hasData={!!data}
/>
);
}
const unread = data.messages.filter((m) => !m.read).length;
return (
<div className="messages-view">
<PageHeader
kicker="Visitors"
title={
<>
Messages
{unread > 0 && <span className="messages-count">{unread} unread</span>}
</>
}
description={`Sent through the contact form at /contact. Stored on this server and deleted after ${data.retentionDays} days.`}
actions={
<button className="admin-btn admin-btn-ghost admin-btn-sm" onClick={inbox.reload}>
<Icons.IconRefresh size={14} /> Refresh
</button>
}
/>
{!data.enabled && (
<div className="messages-notice">
The contact form is switched off, so no new messages arrive.{' '}
<Link href="/admin/settings/legal">Turn it on in Settings → Legal</Link>.
</div>
)}
{data.enabled && !data.notifyConfigured && (
<div className="messages-notice">
No notification is set up, so new messages only show up here.{' '}
<Link href="/admin/settings/legal">Add an ntfy URL in Settings → Legal</Link>.
</div>
)}
{data.messages.length === 0 ? (
<div className="analytics-panel messages-empty">No messages.</div>
) : (
<ul className="messages-list">
{data.messages.map((m) => {
const expanded = open === m.id;
return (
<li
key={m.id}
className={`messages-item${m.read ? '' : ' messages-item--unread'}${expanded ? ' messages-item--open' : ''}`}
>
<button
type="button"
className="messages-summary"
onClick={() => toggle(m)}
aria-expanded={expanded}
>
<span className="messages-dot" aria-hidden="true" />
<span className="messages-from">
{m.name}
{!m.read && <span className="sr-only"> (unread)</span>}
</span>
<span className="messages-preview">{m.message.slice(0, 120)}</span>
<time className="messages-date" dateTime={m.receivedAt}>
{new Date(m.receivedAt).toLocaleString()}
</time>
</button>
{expanded && (
<div className="messages-body">
<p className="messages-meta">
{m.name} &lt;{m.email}&gt;
</p>
<p className="messages-text">{m.message}</p>
<div className="messages-actions">
<a className="admin-btn admin-btn-primary admin-btn-sm" href={replyHref(m)}>
Reply by email
</a>
<button
type="button"
className="admin-btn admin-btn-ghost admin-btn-sm"
onClick={() => markRead(m, false)}
>
Mark unread
</button>
<button
type="button"
className="admin-btn admin-btn-ghost admin-btn-sm"
onClick={() => remove(m)}
>
<Icons.IconTrash size={14} /> Delete
</button>
</div>
</div>
)}
</li>
);
})}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,108 @@
'use client';
/**
* The admin's one place to say that something happened (#600).
*
* Replaces `alert()`, which blocked the page, could not be styled and said
* nothing to a screen reader beyond the dialog itself. A notification here has
* an explicit kind rather than one guessed from its wording, can be dismissed,
* and is announced: errors through an assertive live region, successes through
* a polite one. Successes leave on their own after a few seconds; errors stay
* until dismissed, because an error nobody saw did not get reported.
*/
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react';
import { IconX } from './Icons';
export type NotificationKind = 'success' | 'error';
/** One button on a notification, e.g. Undo after a removal. */
export interface NotificationAction {
label: string;
run: () => void;
}
interface Notification {
id: number;
kind: NotificationKind;
message: string;
action?: NotificationAction;
}
type Notify = (kind: NotificationKind, message: string, action?: NotificationAction) => void;
/**
* Outside the provider (a component rendered on its own in a test) notifying
* does nothing rather than throwing.
*/
const NotifyContext = createContext<Notify>(() => {});
export function useNotify(): Notify {
return useContext(NotifyContext);
}
const SUCCESS_MS = 4000;
const ACTION_MS = 8000;
export function NotificationProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<Notification[]>([]);
const nextId = useRef(1);
const dismiss = useCallback((id: number) => {
setItems((list) => list.filter((n) => n.id !== id));
}, []);
const notify = useCallback<Notify>(
(kind, message, action) => {
const id = nextId.current++;
setItems((list) => [...list, { id, kind, message, action }]);
// Longer with an action: there has to be time to reach the button.
if (kind === 'success') setTimeout(() => dismiss(id), action ? ACTION_MS : SUCCESS_MS);
},
[dismiss],
);
const render = (kind: NotificationKind) =>
items
.filter((n) => n.kind === kind)
.map((n) => (
<div key={n.id} className={`admin-notification admin-notification--${n.kind}`}>
<span className="admin-notification__text">{n.message}</span>
{n.action && (
<button
type="button"
className="admin-notification__action"
onClick={() => {
n.action!.run();
dismiss(n.id);
}}
>
{n.action.label}
</button>
)}
<button
type="button"
className="admin-notification__close"
onClick={() => dismiss(n.id)}
aria-label="Dismiss"
>
<IconX size={14} />
</button>
</div>
));
return (
<NotifyContext.Provider value={notify}>
{children}
<div className="admin-notifications">
{/* Two regions, because politeness belongs to the region, not the message. */}
<div role="alert" aria-live="assertive" className="admin-notifications__group">
{render('error')}
</div>
<div role="status" aria-live="polite" className="admin-notifications__group">
{render('success')}
</div>
</div>
</NotifyContext.Provider>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
import type { ReactNode } from 'react';
/**
* The one screen header (UX stage 2, docs/admin-ux-concept.md): kicker with
* the site's square marker, title, one sentence, actions on the right. Every
* screen used to build its own, each with a different size and position.
*/
export default function PageHeader({
kicker,
title,
description,
actions,
}: {
kicker: string;
title: ReactNode;
description?: ReactNode;
actions?: ReactNode;
}) {
return (
<header className="page-header">
<div className="page-header-text">
<p className="page-header-kicker">{kicker}</p>
<h1 className="page-header-title">{title}</h1>
{description && <p className="page-header-sub">{description}</p>}
</div>
{actions && <div className="page-header-actions">{actions}</div>}
</header>
);
}

View File

@@ -0,0 +1,168 @@
'use client';
/**
* Editor for content/privacy.md (#699), in Settings → Legal.
*
* Folio writes no legal text. Next to the editor it lists what this
* installation processes, read off the configuration, and "Insert headings"
* adds the section headings that list implies, nothing more.
*
* Saves on its own, like the About page: the policy is a Markdown file, not a
* key in settings.yaml.
*/
import { useEffect, useState } from 'react';
import * as Icons from './Icons';
import { reportIfSessionExpired } from './sessionExpiry';
import { useUnsavedGuard } from './useUnsavedGuard';
import type { ProcessingFact } from '@/lib/privacy';
interface PrivacyData {
body: string;
enabled: boolean;
facts: ProcessingFact[];
starter: string;
}
type Status = { kind: 'success' | 'error'; message: string } | null;
export default function PrivacyEditor() {
const [data, setData] = useState<PrivacyData | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [body, setBody] = useState('');
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState<Status>(null);
useUnsavedGuard(dirty);
useEffect(() => {
let live = true;
fetch('/api/admin/privacy')
.then(async (res) => {
if (!res.ok) {
reportIfSessionExpired(res);
throw new Error(`HTTP ${res.status}`);
}
return (await res.json()) as PrivacyData;
})
.then((d) => {
if (!live) return;
setData(d);
setBody(d.body);
})
.catch((err: Error) => live && setLoadError(err.message));
return () => {
live = false;
};
}, []);
async function save() {
setSaving(true);
setStatus(null);
try {
const res = await fetch('/api/admin/privacy', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body }),
});
if (!res.ok) {
if (!reportIfSessionExpired(res)) {
setStatus({ kind: 'error', message: `Could not save (HTTP ${res.status}).` });
}
return;
}
setDirty(false);
setStatus({ kind: 'success', message: 'Privacy policy saved.' });
} catch {
setStatus({ kind: 'error', message: 'Could not reach the server.' });
} finally {
setSaving(false);
}
}
function insertHeadings() {
if (!data) return;
setBody((b) => (b.trim() ? `${b.trimEnd()}\n\n${data.starter}\n` : `${data.starter}\n`));
setDirty(true);
}
if (loadError) {
// No editor over an unknown file: saving an empty one would erase the policy.
return (
<p className="admin-field-hint admin-field-hint--error">
The privacy policy could not be loaded ({loadError}). Reload the page to try again.
</p>
);
}
if (!data) return <div className="admin-spinner" />;
return (
<div className="privacy-editor">
<div className="privacy-editor__facts">
<h4>What this site processes</h4>
<p className="admin-field-hint">
Read off your current configuration. A checklist for writing the policy, not text for the
page.
</p>
<ul>
{data.facts.map((f) => (
<li key={f.topic}>
<strong>{f.topic}</strong>
{f.thirdParty && <span className="privacy-editor__badge">third party</span>}
<span>{f.detail}</span>
</li>
))}
</ul>
</div>
<div className="admin-field">
<label htmlFor="privacy-body">Privacy policy (Markdown)</label>
<textarea
id="privacy-body"
value={body}
onChange={(e) => {
setBody(e.target.value);
setDirty(true);
}}
rows={18}
placeholder="## Controller&#10;&#10;Name, address, email…"
/>
<p className="admin-field-hint">
Headings with <code>##</code>, lists with <code>-</code>, <code>**bold**</code> and{' '}
<code>[links](https://…)</code>. Shown at /privacy and linked in the footer once there is
text. Earlier versions are kept under Backups.
</p>
</div>
<div className="privacy-editor__actions">
<button type="button" className="admin-btn admin-btn-ghost" onClick={insertHeadings}>
<Icons.IconPlus size={14} /> Insert headings
</button>
{body.trim() && data.enabled && (
<a
className="admin-btn admin-btn-ghost"
href="/privacy"
target="_blank"
rel="noopener noreferrer"
>
View /privacy ↗
</a>
)}
<button
type="button"
className="admin-btn admin-btn-primary"
onClick={save}
disabled={!dirty || saving}
>
{saving ? 'Saving…' : 'Save privacy policy'}
</button>
{status && (
<span className={`save-message ${status.kind}`} role="status">
{status.message}
</span>
)}
</div>
</div>
);
}

View File

@@ -1,9 +1,16 @@
'use client';
/**
* The outcome of the last save. Typed rather than read off the wording: the bar
* used to call a message an error when it started with "Error", so a failure
* worded any other way was shown as a success (#600).
*/
export type SaveStatus = { kind: 'success' | 'error'; message: string } | null;
interface SaveBarProps {
dirty: boolean;
saving: boolean;
saveMessage: string;
status: SaveStatus;
onSave: () => void;
label: string;
/** Show the cache-bypassing preview link (page builder only). */
@@ -21,20 +28,18 @@ interface SaveBarProps {
export default function SaveBar({
dirty,
saving,
saveMessage,
status,
onSave,
label,
showPreview = false,
}: SaveBarProps) {
if (!dirty && !saving && !saveMessage) return null;
if (!dirty && !saving && !status) return null;
return (
<div className={`floating-save-bar ${dirty ? 'dirty' : ''}`} role="status">
<div className="save-bar-left">
{saveMessage ? (
<span className={`save-message ${saveMessage.startsWith('Error') ? 'error' : 'success'}`}>
{saveMessage}
</span>
{status ? (
<span className={`save-message ${status.kind}`}>{status.message}</span>
) : (
<span className="unsaved-badge">
<span className="badge-pulse-dot" aria-hidden="true" />

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
import AdminDashboard from '../AdminDashboard';
import { SESSION_EXPIRED_EVENT } from '../sessionExpiry';
import { NotificationProvider } from '../Notifications';
vi.mock('next/navigation', () => ({
usePathname: () => '/admin',
@@ -16,8 +17,6 @@ afterEach(cleanup);
*/
describe('AdminDashboard reload failure reporting', () => {
it('reports a non-ok, non-401 reload failure', async () => {
const alertMock = vi.fn();
vi.stubGlobal('alert', alertMock);
vi.stubGlobal(
'fetch',
vi.fn(async (url: string, init?: RequestInit) => {
@@ -29,19 +28,22 @@ describe('AdminDashboard reload failure reporting', () => {
);
render(
<AdminDashboard onLogout={() => {}}>
<div>Panel</div>
</AdminDashboard>,
<NotificationProvider>
<AdminDashboard onLogout={() => {}}>
<div>Panel</div>
</AdminDashboard>
</NotificationProvider>,
);
fireEvent.click(screen.getByTitle('Reload config & clear cache'));
await waitFor(() => expect(alertMock).toHaveBeenCalledWith('Reload failed (HTTP 500).'));
// Reported through the notification surface (#600), not alert().
await waitFor(() =>
expect(screen.getByRole('alert').textContent).toContain('Reload failed (HTTP 500).'),
);
});
it('raises the session-expired event instead of alerting on a 401', async () => {
const alertMock = vi.fn();
vi.stubGlobal('alert', alertMock);
it('raises the session-expired event instead of reporting an error on a 401', async () => {
const listener = vi.fn();
window.addEventListener(SESSION_EXPIRED_EVENT, listener);
vi.stubGlobal(
@@ -55,21 +57,21 @@ describe('AdminDashboard reload failure reporting', () => {
);
render(
<AdminDashboard onLogout={() => {}}>
<div>Panel</div>
</AdminDashboard>,
<NotificationProvider>
<AdminDashboard onLogout={() => {}}>
<div>Panel</div>
</AdminDashboard>
</NotificationProvider>,
);
fireEvent.click(screen.getByTitle('Reload config & clear cache'));
await waitFor(() => expect(listener).toHaveBeenCalledOnce());
expect(alertMock).not.toHaveBeenCalled();
expect(screen.getByRole('alert').textContent).toBe('');
window.removeEventListener(SESSION_EXPIRED_EVENT, listener);
});
it('does not alert when the reload succeeds', async () => {
const alertMock = vi.fn();
vi.stubGlobal('alert', alertMock);
it('reports no error when the reload succeeds', async () => {
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
if (url === '/api/admin/reload' && init?.method === 'POST') {
return new Response(null, { status: 200 });
@@ -79,9 +81,11 @@ describe('AdminDashboard reload failure reporting', () => {
vi.stubGlobal('fetch', fetchMock);
render(
<AdminDashboard onLogout={() => {}}>
<div>Panel</div>
</AdminDashboard>,
<NotificationProvider>
<AdminDashboard onLogout={() => {}}>
<div>Panel</div>
</AdminDashboard>
</NotificationProvider>,
);
fireEvent.click(screen.getByTitle('Reload config & clear cache'));
@@ -93,6 +97,6 @@ describe('AdminDashboard reload failure reporting', () => {
),
).toHaveLength(1),
);
expect(alertMock).not.toHaveBeenCalled();
expect(screen.getByRole('alert').textContent).toBe('');
});
});

View File

@@ -0,0 +1,40 @@
'use client';
import { useEffect, useRef } from 'react';
import type { BackupTarget } from '@/app/api/admin/backups/route';
/**
* A backup restore rewrote a content file behind the editors' backs.
*
* The page builder, settings and journal editors stay mounted in their
* layouts, holding what they loaded before the restore. Left alone, the next
* save wrote that older state straight over the restored file. So the backup
* manager announces the restore and each editor reloads the file it owns —
* through its draft, so unsaved edits come back as a `conflict` the operator
* decides on instead of vanishing or winning silently.
*
* Same pattern as SESSION_EXPIRED_EVENT in sessionExpiry.ts.
*/
export const CONTENT_RESTORED_EVENT = 'folio:admin-content-restored';
export interface ContentRestored {
target: BackupTarget;
/** The journal entry, for `journal`. */
slug?: string;
}
export function reportContentRestored(detail: ContentRestored): void {
window.dispatchEvent(new CustomEvent<ContentRestored>(CONTENT_RESTORED_EVENT, { detail }));
}
export function useContentRestored(handler: (detail: ContentRestored) => void): void {
const handlerRef = useRef(handler);
useEffect(() => {
handlerRef.current = handler;
});
useEffect(() => {
const onRestored = (e: Event) => handlerRef.current((e as CustomEvent<ContentRestored>).detail);
window.addEventListener(CONTENT_RESTORED_EVENT, onRestored);
return () => window.removeEventListener(CONTENT_RESTORED_EVENT, onRestored);
}, []);
}

View File

@@ -1,6 +1,6 @@
'use client';
import type { ReactNode } from 'react';
import { useId, type ReactNode } from 'react';
type GridOverride = { columns?: number; gap?: number };
@@ -50,16 +50,20 @@ export default function GridOverrideFields({
});
};
const labelId = useId();
return (
<div className="admin-field" style={{ marginTop: '1rem' }}>
<label>{label}</label>
<div style={{ display: 'flex', gap: '12px' }}>
<span className="admin-field-label" id={labelId}>
{label}
</span>
<div style={{ display: 'flex', gap: '12px' }} role="group" aria-labelledby={labelId}>
<input
type="number"
min={columnsMin}
max={columnsMax}
value={value?.columns ?? ''}
placeholder="Columns (site default)"
aria-label="Columns"
onChange={(e) => patch('columns', e.target.value, columnsMin, columnsMax)}
style={NUMBER_INPUT_STYLE}
/>
@@ -69,6 +73,7 @@ export default function GridOverrideFields({
max={gapMax}
value={value?.gap ?? ''}
placeholder="Gap in px (theme default)"
aria-label="Gap in px"
onChange={(e) => patch('gap', e.target.value, 0, gapMax)}
style={NUMBER_INPUT_STYLE}
/>

View File

@@ -1,6 +1,6 @@
'use client';
import type { CSSProperties, ReactNode } from 'react';
import { useId, type CSSProperties, type ReactNode } from 'react';
export type CardOption = { value: string; label: string; desc: string };
@@ -55,10 +55,14 @@ export default function OptionGrid({
renderSpecs?: (option: CardOption) => ReactNode;
cardStyle?: (option: CardOption) => CSSProperties | undefined;
}) {
// A group of buttons, not one control, so it is named as a group (#694 §4).
const labelId = useId();
return (
<div className="admin-field">
<label>{label}</label>
<div className="preset-card-grid">
<span className="admin-field-label" id={labelId}>
{label}
</span>
<div className="preset-card-grid" role="group" aria-labelledby={labelId}>
{options.map((option) => {
const isActive = value === option.value;
return (

View File

@@ -0,0 +1,120 @@
'use client';
import { useId, useState } from 'react';
import { IconEye, IconEyeOff, IconLock } from '../Icons';
/**
* One password input for every place the admin sets one: subpage, album,
* journal entry and site password (#690).
*
* The save routes store passwords as scrypt hashes (lib/admin/passwordHashing.ts),
* so a saved password comes back as `scrypt:…`. That value is never put into
* the input: showing it would invite editing a hash, and saving an edited hash
* would lock the page with the hash string. Instead the field reads
* "Protected", with Change and Remove.
*
* A plaintext value from a hand-written file is shown in the input as before;
* the next save stores it hashed.
*/
export default function PasswordField({
value,
onChange,
placeholder = 'Leave empty for public access',
disabled,
label,
id: idProp,
}: {
value: string | undefined;
/** `undefined` removes the password. */
onChange: (value: string | undefined) => void;
placeholder?: string;
disabled?: boolean;
/** Accessible name when there is no visible <label> for the field. */
label?: string;
/** For a visible <label htmlFor>; generated otherwise. */
id?: string;
}) {
const generatedId = useId();
const id = idProp ?? generatedId;
/** The stored hash while it is being changed, so "Keep" can put it back. */
const [original, setOriginal] = useState<string | null>(null);
const [revealed, setRevealed] = useState(false);
const isHash = !!value && value.startsWith('scrypt:');
// Derived rather than stored: a save that brings back a new hash ends the
// change without the field having to be told.
const changing = original !== null && (value === original || !isHash);
if (isHash && !changing) {
return (
<div className="password-field password-field--set">
<span className="password-field__status">
<IconLock size={12} /> Protected
</span>
<button
type="button"
className="admin-btn admin-btn-ghost admin-btn-sm"
// The hash stays in the form until something is typed, so opening
// the field and saving is not a way to lose the password.
onClick={() => setOriginal(value)}
disabled={disabled}
>
Change
</button>
<button
type="button"
className="admin-btn admin-btn-ghost admin-btn-sm"
onClick={() => onChange(undefined)}
disabled={disabled}
>
Remove
</button>
</div>
);
}
return (
<div className="password-field">
<div className="password-input-wrapper">
<span className="password-icon" aria-hidden="true">
<IconLock size={12} />
</span>
<input
id={id}
type={revealed ? 'text' : 'password'}
value={changing && value === original ? '' : (value ?? '')}
// Emptying the field while changing falls back to the stored hash;
// only Remove takes a password away.
onChange={(e) => onChange(e.target.value || (changing ? original! : undefined))}
placeholder={changing ? 'New password' : placeholder}
aria-label={label}
autoComplete="new-password"
disabled={disabled}
autoFocus={changing}
/>
<button
type="button"
className="password-field__reveal"
onClick={() => setRevealed((r) => !r)}
aria-label={revealed ? 'Hide password' : 'Show password'}
aria-pressed={revealed}
aria-controls={id}
disabled={disabled}
>
{revealed ? <IconEyeOff size={14} /> : <IconEye size={14} />}
</button>
</div>
{changing && (
<button
type="button"
className="admin-btn admin-btn-ghost admin-btn-sm password-field__cancel"
onClick={() => {
onChange(original);
setOriginal(null);
}}
>
Keep current password
</button>
)}
</div>
);
}

View File

@@ -30,12 +30,14 @@ import { useUnsavedGuard } from '../useUnsavedGuard';
import { useDraft } from '../useDraft';
import DraftNotice from '../DraftNotice';
import { reportIfSessionExpired } from '../sessionExpiry';
import { useContentRestored } from '../contentRestored';
import './journal-studio.css';
import { BlockFields, type AssetPickTarget } from './BlockFields';
import { StorySettingsModal } from './StorySettingsModal';
import { JournalPreview } from './JournalPreview';
import { createBlock, moveBlock } from './blockOps';
import { useSplitPane, SPLIT_MIN, SPLIT_MAX } from './splitPane';
import { useNotify } from '../Notifications';
interface JournalEditorProps {
slug: string;
@@ -44,6 +46,7 @@ interface JournalEditorProps {
}
export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps) {
const notify = useNotify();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
@@ -82,6 +85,12 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
const loadDraft = draft.load;
const serverMarkdown = useRef('');
// Bumped when this entry is restored from a backup, to load it again.
const [reloadKey, setReloadKey] = useState(0);
useContentRestored(({ target, slug: restored }) => {
if (target === 'journal' && restored === slug) setReloadKey((k) => k + 1);
});
// Load entry
useEffect(() => {
async function load() {
@@ -113,7 +122,7 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
}
}
load();
}, [slug, loadDraft]);
}, [slug, loadDraft, reloadKey]);
// Update markdown and sync blocks
const handleMarkdownChange = (newMd: string) => {
@@ -154,6 +163,9 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
const handleSave = async () => {
// The editor is not rendered in this state, but Cmd+S still reaches here.
if (loadError) return;
// Nothing to save: every save rotates a backup, so repeated Cmd+S on an
// unchanged entry pushed real history out of the ten kept per file.
if (!dirty || saving) return;
setSaving(true);
try {
@@ -166,15 +178,25 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
});
if (res.ok) {
serverMarkdown.current = rawMarkdown;
draft.saved(rawMarkdown);
// The file as written, not as sent: a password line is hashed on the
// way to disk (#690). Taking the sent text as the base would make the
// next draft look like a conflicting edit from elsewhere.
const data = await res.json().catch(() => null);
const written: string =
typeof data?.entry?.rawMarkdown === 'string' ? data.entry.rawMarkdown : rawMarkdown;
serverMarkdown.current = written;
draft.saved(written);
if (written !== rawMarkdown) {
setRawMarkdown(written);
setParsed(parseJournalMarkdown(written));
}
setDirty(false);
} else if (!reportIfSessionExpired(res)) {
const data = await res.json();
alert(data.error || 'Failed to save');
notify('error', data.error || 'Failed to save');
}
} catch {
alert('Error saving entry');
notify('error', 'Could not save the entry. Check the connection and try again.');
} finally {
setSaving(false);
}
@@ -324,6 +346,7 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
className="journal-editor-title-input"
value={parsed.frontmatter.title || ''}
placeholder="Story Title..."
aria-label="Story title"
onChange={(e) => handleFrontmatterChange({ title: e.target.value })}
/>
@@ -375,7 +398,7 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
type="button"
className="admin-btn admin-btn-sm admin-btn-primary"
onClick={handleSave}
disabled={saving}
disabled={saving || !dirty}
>
{saving ? (
'Saving...'

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { JournalEntrySummary } from '@/lib/journal';
import { serializeJournalMarkdown, sanitizeSlug } from '@/lib/journal';
@@ -16,6 +16,12 @@ import {
} from '../Icons';
import './journal-studio.css';
import { JournalEditor } from './JournalEditor';
import { useNotify } from '../Notifications';
import { useAdminFetch } from '../useAdminFetch';
import { useContentRestored } from '../contentRestored';
import AdminLoadState from '../AdminLoadState';
import PageHeader from '../PageHeader';
import { useConfirm } from '../ConfirmDialog';
interface JournalStudioProps {
/** Entry to open, taken from the /admin/journal/[slug] route. */
@@ -25,10 +31,17 @@ interface JournalStudioProps {
}
export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioProps) {
const confirm = useConfirm();
const notify = useNotify();
const router = useRouter();
const [entries, setEntries] = useState<JournalEntrySummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const list = useAdminFetch<{ entries?: JournalEntrySummary[] }>('/api/admin/journal');
const entries = list.data?.entries ?? [];
const fetchEntries = list.reload;
// A restored entry may be one that was deleted, or have a new title.
useContentRestored(({ target }) => {
if (target === 'journal') fetchEntries();
});
// New Entry Modal
const [showCreateModal, setShowCreateModal] = useState(false);
@@ -38,28 +51,6 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
/** null = start blank, matching the previous (only) behavior. */
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
const fetchEntries = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/admin/journal');
if (res.ok) {
const data = await res.json();
setEntries(data.entries || []);
} else {
setError('Failed to load journal entries');
}
} catch {
setError('Failed to load journal entries');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchEntries();
}, [fetchEntries]);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!newTitle.trim()) return;
@@ -98,41 +89,42 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
setNewTitle('');
setNewSlug('');
setSelectedTemplateId(null);
await fetchEntries();
fetchEntries();
if (data.entry?.slug) {
router.push(`/admin/journal/${data.entry.slug}`);
}
} else {
const data = await res.json();
alert(data.error || 'Failed to create journal entry');
notify('error', data.error || 'Failed to create journal entry');
}
} catch {
alert('Error creating journal entry');
notify('error', 'Could not create the journal entry. Check the connection and try again.');
} finally {
setCreating(false);
}
};
const handleDelete = async (slug: string) => {
if (
!confirm(
`Delete "${slug}"?\n\nA copy is kept and can be restored from Backups on the dashboard.`,
)
)
return;
const ok = await confirm({
title: `Delete “${slug}”?`,
message: 'A copy is kept and can be restored under Backups.',
confirmLabel: 'Delete',
danger: true,
});
if (!ok) return;
try {
const res = await fetch(`/api/admin/journal/${slug}`, {
method: 'DELETE',
});
if (res.ok) {
await fetchEntries();
fetchEntries();
if (activeSlug === slug) router.push('/admin/journal');
} else {
alert('Failed to delete journal entry');
notify('error', 'Failed to delete journal entry');
}
} catch {
alert('Error deleting journal entry');
notify('error', 'Could not delete the journal entry. Check the connection and try again.');
}
};
@@ -148,30 +140,28 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
return (
<div className="journal-studio">
<div className="journal-studio-header">
<div>
<h2>
<IconBook size={20} /> Journal &amp; Photo Essays
</h2>
<p style={{ margin: '4px 0 0', opacity: 0.7, fontSize: '0.9rem' }}>
Author visual stories, field notes, and longform photo essays with live preview.
</p>
</div>
<button
type="button"
className="admin-btn admin-btn-primary"
onClick={() => setShowCreateModal(true)}
>
<IconPlus size={16} /> New Journal Entry
</button>
</div>
<PageHeader
kicker="Content"
title="Journal"
description="Stories, field notes and longform photo essays, written with a live preview."
actions={
<button
type="button"
className="admin-btn admin-btn-primary"
onClick={() => setShowCreateModal(true)}
>
<IconPlus size={16} /> New Journal Entry
</button>
}
/>
{loading ? (
<div style={{ padding: '3rem', textAlign: 'center', opacity: 0.6 }}>
Loading journal entries...
</div>
) : error ? (
<div style={{ padding: '3rem', textAlign: 'center', color: '#ef4444' }}>{error}</div>
{(list.loading && !list.data) || list.error ? (
<AdminLoadState
loading={list.loading}
error={list.error}
onRetry={list.reload}
hasData={!!list.data}
/>
) : entries.length === 0 ? (
<div style={{ padding: '5rem 2rem', textAlign: 'center', opacity: 0.6 }}>
<h3>No journal entries yet</h3>
@@ -269,6 +259,7 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
<form onSubmit={handleCreate}>
<div style={{ marginBottom: '1rem' }}>
<label
htmlFor="journal-new-title"
style={{
display: 'block',
fontSize: '0.85rem',
@@ -279,6 +270,7 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
Title
</label>
<input
id="journal-new-title"
type="text"
className="admin-input"
placeholder="e.g. Expedition Nordkap"
@@ -296,6 +288,7 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
<div style={{ marginBottom: '1.5rem' }}>
<label
htmlFor="journal-new-url-slug"
style={{
display: 'block',
fontSize: '0.85rem',
@@ -306,6 +299,7 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
URL Slug
</label>
<input
id="journal-new-url-slug"
type="text"
className="admin-input"
placeholder="e.g. expedition-nordkap"

View File

@@ -2,6 +2,7 @@
import type { ParsedJournal } from '@/lib/journal';
import type { AssetPickTarget } from './BlockFields';
import PasswordField from '../fields/PasswordField';
type Frontmatter = ParsedJournal['frontmatter'];
@@ -27,6 +28,7 @@ export function StorySettingsModal({
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div>
<label
htmlFor="story-subtitle"
style={{
display: 'block',
fontSize: '0.8rem',
@@ -37,6 +39,7 @@ export function StorySettingsModal({
Subtitle
</label>
<input
id="story-subtitle"
type="text"
className="admin-input"
value={frontmatter.subtitle || ''}
@@ -48,6 +51,7 @@ export function StorySettingsModal({
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<div>
<label
htmlFor="story-author"
style={{
display: 'block',
fontSize: '0.8rem',
@@ -58,6 +62,7 @@ export function StorySettingsModal({
Author
</label>
<input
id="story-author"
type="text"
className="admin-input"
value={frontmatter.author || ''}
@@ -67,6 +72,7 @@ export function StorySettingsModal({
</div>
<div>
<label
htmlFor="story-publish-date"
style={{
display: 'block',
fontSize: '0.8rem',
@@ -77,6 +83,7 @@ export function StorySettingsModal({
Publish Date
</label>
<input
id="story-publish-date"
type="date"
className="admin-input"
value={frontmatter.date || ''}
@@ -155,12 +162,10 @@ export function StorySettingsModal({
>
Password Protection (Optional)
</label>
<input
type="password"
className="admin-input"
value={frontmatter.password || ''}
placeholder="Leave empty for public access"
onChange={(e) => onChange({ password: e.target.value })}
<PasswordField
value={frontmatter.password || undefined}
onChange={(password) => onChange({ password: password ?? '' })}
label="Entry password"
/>
</div>

View File

@@ -674,3 +674,39 @@
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
}
/* Phones: the top bar was one non-wrapping row, so the title input shrank to
nothing and Save ran off the screen — and there is no ⌘S on a phone (#675).
Below 700px it wraps: back button and mode toggle, the title on a line of
its own, then the actions with Save taking the remaining width. */
@media (max-width: 700px) {
.journal-editor-topbar {
flex-wrap: wrap;
padding: 0.75rem 1rem;
gap: 0.75rem;
}
.journal-editor-topbar-left {
flex-wrap: wrap;
gap: 0.5rem 0.75rem;
width: 100%;
}
.journal-editor-title-input {
order: 3;
flex: 1 1 100%;
max-width: none;
min-width: 0;
border-color: var(--admin-border, rgba(255, 255, 255, 0.08));
}
.journal-editor-topbar-right {
width: 100%;
gap: 0.5rem;
}
.journal-editor-topbar-right > .admin-btn-primary {
flex: 1;
justify-content: center;
}
}

View File

@@ -5,6 +5,7 @@ import { IconCamera, IconCopy, IconGripVertical, IconImage, IconTrash, IconX } f
import { Listbox } from '../Listbox';
import { SORT_OPTIONS } from './sortOptions';
import type { AlbumEntry, HeroPickerTarget, OrderEditorTarget } from './types';
import PasswordField from '../fields/PasswordField';
interface AlbumDrawerProps {
album: AlbumEntry;
@@ -71,8 +72,9 @@ export default function AlbumDrawer({
</div>
<div className="admin-field">
<label>Title override</label>
<label htmlFor="album-title-override">Title override</label>
<input
id="album-title-override"
value={album.title || ''}
onChange={(e) => onUpdate({ title: e.target.value || undefined })}
placeholder={name}
@@ -80,8 +82,9 @@ export default function AlbumDrawer({
</div>
<div className="admin-field">
<label>Description</label>
<label htmlFor="album-description">Description</label>
<textarea
id="album-description"
value={album.description || ''}
onChange={(e) => onUpdate({ description: e.target.value || undefined })}
placeholder="Optional description for visitors"
@@ -90,19 +93,17 @@ export default function AlbumDrawer({
</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>
<label htmlFor="album-password">Password protection</label>
<PasswordField
id="album-password"
value={album.password}
onChange={(password) => onUpdate({ password })}
label="Album password"
/>
</div>
<div className="admin-field">
<label>Downloads</label>
<span className="admin-field-label">Downloads</span>
<button
type="button"
className={`admin-toggle-card ${album.download ? 'active' : ''}`}
@@ -141,8 +142,9 @@ export default function AlbumDrawer({
<div className="admin-sheet-col admin-sheet-col--divided">
<div className="admin-field">
<label>Layout override (Experimental)</label>
<label htmlFor="album-layout-override">Layout override (Experimental)</label>
<select
id="album-layout-override"
aria-label="Layout override"
value={album.grid?.layout || ''}
onChange={(e) => {
@@ -166,8 +168,9 @@ export default function AlbumDrawer({
</div>
<div className="admin-field">
<label>Cover focal point (Experimental)</label>
<label htmlFor="album-cover-focal-point">Cover focal point (Experimental)</label>
<input
id="album-cover-focal-point"
value={album.coverPosition || ''}
onChange={(e) => onUpdate({ coverPosition: e.target.value || undefined })}
placeholder={'e.g. "50% 25%" or "top" — where the cover crop should anchor'}
@@ -176,7 +179,7 @@ export default function AlbumDrawer({
{/* Hero Image Selection */}
<div className="admin-field">
<label>Custom Hero Image</label>
<span className="admin-field-label">Custom Hero Image</span>
<div className="album-hero-field">
{album.heroImage ? (
<div className="album-hero-preview">
@@ -213,7 +216,9 @@ export default function AlbumDrawer({
{/* Photo order */}
<div className="admin-field">
<label id="album-sort-label">Photo order</label>
<span className="admin-field-label" id="album-sort-label">
Photo order
</span>
<Listbox
labelledBy="album-sort-label"
value={album.sort || DEFAULT_ALBUM_SORT}

View File

@@ -0,0 +1,213 @@
'use client';
/**
* The two drag-sortable tiles of the page builder (#608): a hero photo, and
* a subpage in the page list. Album cards are in ./AlbumCard.tsx.
*/
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { IconFolder, IconGripVertical, IconLock, IconPencil, IconX } from '../Icons';
import type { Subpage } from './types';
// ── Sortable Hero Tile ─────────────────────────────────────────
export function SortableHeroTile({
id,
index,
onRemove,
}: {
id: string;
index: number;
onRemove: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: `hero-${index}`,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 10 : undefined,
};
return (
<div ref={setNodeRef} style={style} className="hero-tile" {...attributes}>
<div className="hero-tile-drag" {...listeners} title="Drag to reorder">
<IconGripVertical size={18} className="svg-icon svg-drag" />
</div>
<img src={`/api/admin/thumbnail/${id}`} alt="" loading="lazy" />
<button className="hero-tile-remove" onClick={onRemove} title="Remove">
<IconX size={14} />
</button>
<span className="hero-tile-index">{index + 1}</span>
</div>
);
}
// ── Sortable Subpage Tile ──────────────────────────────────────
export function SortableSubpageTile({
sp,
spIndex,
isActive,
onClick,
getFirstThumb,
}: {
sp: Subpage;
spIndex: number;
isActive: boolean;
onClick: () => void;
getFirstThumb: (sp: Subpage) => string | null;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: `subpage-${spIndex}`,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 10 : undefined,
};
const totalAlbums =
sp.albums.length + (sp.sections?.reduce((sum, sec) => sum + sec.albums.length, 0) || 0);
const firstThumb = getFirstThumb(sp);
const slug = sp.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
return (
<div
ref={setNodeRef}
style={style}
className={`subpage-tile ${isActive ? 'active' : ''}`}
onClick={onClick}
{...attributes}
>
<div className="subpage-tile-drag" {...listeners} title="Drag to reorder">
<IconGripVertical size={18} className="svg-icon svg-drag" />
</div>
{sp.enabled === false && (
<span
className="subpage-badge-protected"
style={{ background: '#e60012', color: '#fff' }}
title="Page is disabled"
>
Disabled
</span>
)}
{sp.hidden === true && sp.enabled !== false && (
<span
className="subpage-badge-protected"
title="Hidden from navigation, reachable by direct link"
>
Unlisted
</span>
)}
{sp.password && (
<span className="subpage-badge-protected" title="Password protected">
<IconLock size={12} /> Password
</span>
)}
<div className="subpage-tile-cover">
{firstThumb ? (
<img src={`/api/admin/thumbnail/${firstThumb}`} alt="" loading="lazy" />
) : (
<div className="subpage-tile-placeholder">
<IconFolder />
</div>
)}
<div className="subpage-hover-overlay">
<span className="hover-action-btn">
<IconPencil size={14} /> Edit Page
</span>
</div>
</div>
<div className="subpage-tile-info">
<div className="subpage-tile-title-row">
<span className="subpage-tile-name">{sp.title || sp.name}</span>
<span className="subpage-tile-slug">/{slug}</span>
</div>
<span className="subpage-tile-meta">
<IconFolder /> {totalAlbums} album{totalAlbums !== 1 ? 's' : ''}
</span>
</div>
</div>
);
}
// ── Component ──────────────────────────────────────────────────
// ── Sortable Subpage Row (stage 4 structure list) ──────────────
/**
* One subpage in the page builder's structure list (UX stage 4): drag handle,
* cover, name, state and album count, in one line. Same sortable id as the
* tile it replaces, so the builder's drag handler is unchanged.
*/
export function SortableSubpageRow({
sp,
spIndex,
isActive,
onClick,
getFirstThumb,
}: {
sp: Subpage;
spIndex: number;
isActive: boolean;
onClick: () => void;
getFirstThumb: (sp: Subpage) => string | null;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: `subpage-${spIndex}`,
});
const totalAlbums =
sp.albums.length + (sp.sections?.reduce((sum, sec) => sum + sec.albums.length, 0) || 0);
const thumb = getFirstThumb(sp);
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 10 : undefined,
}}
className={`pb-row ${isActive ? 'active' : ''} ${sp.enabled === false ? 'is-off' : ''}`}
{...attributes}
>
<span className="pb-row-grip" {...listeners} title="Drag to reorder">
<IconGripVertical size={14} />
</span>
<button type="button" className="pb-row-main" onClick={onClick} aria-current={isActive}>
<span className="pb-row-thumb">
{thumb ? (
<img src={`/api/admin/thumbnail/${thumb}`} alt="" loading="lazy" />
) : (
<IconFolder size={14} />
)}
</span>
<span className="pb-row-name">
{sp.title || sp.name}
{sp.password && <IconLock size={11} />}
</span>
{sp.enabled === false ? (
<span className="pb-row-state">Off</span>
) : sp.hidden ? (
<span className="pb-row-state">Unlisted</span>
) : (
<span className="pb-row-count">{totalAlbums}</span>
)}
</button>
</div>
);
}

View File

@@ -15,7 +15,6 @@ import {
IconBook,
IconEyeOff,
IconGlobe,
IconLock,
IconPencil,
IconSearch,
IconTrash,
@@ -23,6 +22,7 @@ import {
} from '../Icons';
import AlbumCard, { SortableAlbumCard } from './AlbumCard';
import SubpagePreview from './SubpagePreview';
import PasswordField from '../fields/PasswordField';
import {
normalizeSubpageGrid,
type ActiveEditAlbumAddress,
@@ -68,6 +68,12 @@ interface SubpageDrawerProps {
getAlbumThumbnailId: (id: string) => string | null;
/** Album to mark, when the sheet was opened from a diagnostics link. */
highlightedAlbumId?: string | null;
/**
* Rendered as the page builder's right-hand panel (UX stage 4) instead of
* a slide-over: no backdrop, no close or Done, the structure list beside
* it is how you move on.
*/
inline?: boolean;
}
/**
@@ -101,485 +107,497 @@ export default function SubpageDrawer({
getAlbumCount,
getAlbumThumbnailId,
highlightedAlbumId,
inline = false,
}: 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)"
const sheet = (
<div
className={`subpage-drawer-container${inline ? ' subpage-drawer-container--inline' : ''}`}
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' }}
>
/{slugify(sp.name)} ↗
</a>
<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>
{!inline && (
<button className="admin-btn-icon" onClick={() => onClose()} title="Close drawer">
<IconX size={14} />
</button>
</div>
)}
</div>
</div>
<div className="subpage-drawer-body">
{drawerMode === 'preview' ? (
<SubpagePreview sp={sp} immichAlbums={immichAlbums} index={kickerIndex} />
) : (
<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 className="subpage-drawer-body">
{drawerMode === 'preview' ? (
<SubpagePreview sp={sp} immichAlbums={immichAlbums} index={kickerIndex} />
) : (
<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 htmlFor="subpage-name">Page Name (URL Identifier)</label>
<div className="input-slug-wrapper">
<input
id="subpage-name"
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>
{/* Subpage metadata */}
<div className="subpage-drawer-section">
<div className="admin-field-row">
<div className="admin-field">
<label htmlFor="subpage-display-title">Display Title (optional)</label>
<input
id="subpage-display-title"
value={sp.title || ''}
onChange={(e) =>
updateSubpage(spIndex, { title: e.target.value || undefined })
}
placeholder="Display title (defaults to name)"
/>
</div>
<div className="admin-field" style={{ marginTop: '1rem' }}>
<label>Visibility &amp; Access</label>
{/*
<div className="admin-field">
<label htmlFor="subpage-subtitle">Subtitle</label>
<input
id="subpage-subtitle"
value={sp.subtitle || ''}
onChange={(e) =>
updateSubpage(spIndex, {
subtitle: e.target.value || undefined,
})
}
placeholder="Subtitle text"
/>
</div>
</div>
<div className="admin-field" style={{ marginTop: '1rem' }}>
<span className="admin-field-label">Visibility &amp; Access</span>
{/*
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
aria-label="Page layout style"
value={sp.grid?.layout || 'masonry'}
onChange={(e) => {
const newLayout = e.target.value;
updateSubpage(spIndex, {
grid: { ...(sp.grid || {}), layout: newLayout },
});
}}
{(
[
{
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: '8px 12px',
borderRadius: '6px',
padding: '10px 14px',
width: '100%',
fontSize: '0.9rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
borderRadius: '8px',
cursor: 'pointer',
marginBottom: '6px',
}}
>
{/*
<div className="toggle-card-info" style={{ textAlign: 'left' }}>
<span
className="toggle-card-title"
style={{
fontWeight: 600,
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
}}
>
<span
style={{ color: opt.iconColor, display: 'inline-flex' }}
aria-hidden="true"
>
{opt.icon}
</span>
{opt.title}
</span>
<span
className="toggle-card-desc"
style={{
fontSize: '0.8rem',
display: 'block',
opacity: 0.75,
}}
>
{opt.desc}
</span>
</div>
<div className={`switch-toggle ${opt.active ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
))}
{/* Password lives in the same group: it is the access half
of "who gets to see this page". Applies to Published and
Unlisted; a Disabled page 404s before the gate. */}
<div style={{ marginTop: '6px' }}>
<PasswordField
value={sp.password}
onChange={(password) => updateSubpage(spIndex, { password })}
placeholder="Password protection (optional) — leave empty for public access"
label="Page password"
disabled={sp.enabled === false}
/>
</div>
</div>
<div className="admin-field" style={{ marginTop: '1rem' }}>
<label htmlFor="subpage-page-layout-style">Page Layout Style</label>
<select
id="subpage-page-layout-style"
aria-label="Page layout style"
value={sp.grid?.layout || 'masonry'}
onChange={(e) => {
const newLayout = e.target.value;
updateSubpage(spIndex, {
grid: { ...(sp.grid || {}), layout: newLayout },
});
}}
style={{
padding: '8px 12px',
borderRadius: '6px',
width: '100%',
fontSize: '0.9rem',
}}
>
{/*
Plain text, no emoji: an <option> cannot contain markup, so the
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>
<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
{/* 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),
})
}
/>
)}
{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.
{/* 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>
{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>
<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>
{/* 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)}
highlighted={album.id === highlightedAlbumId}
onRemove={() => removeSubpageAlbum(spIndex, aIndex)}
onEdit={() =>
onEditAlbum({
type: 'subpage',
subpageIndex: spIndex,
albumIndex: aIndex,
})
}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
)}
{/* Sections */}
{sp.sections && sp.sections.length > 0 && (
<div className="subpage-sections">
{sp.sections.map((sec, secIndex) => (
<div key={secIndex} className="section-card">
<div className="section-header">
<input
className="section-title-input"
value={sec.title}
onChange={(e) =>
updateSection(spIndex, secIndex, {
title: e.target.value,
})
}
placeholder="Section title"
aria-label="Section title"
/>
<button
className="admin-btn-icon"
onClick={() => removeSection(spIndex, secIndex)}
title="Remove section"
>
<IconX size={14} />
</button>
</div>
<div className="admin-field">
<input
value={sec.description || ''}
onChange={(e) =>
updateSection(spIndex, secIndex, {
description: e.target.value || undefined,
})
}
placeholder="Section description (optional)"
aria-label="Section description"
className="section-desc-input"
/>
</div>
<div className="album-list">
{sec.albums.map((album, aIndex) => (
<AlbumCard
key={`${album.id}-${aIndex}`}
album={album}
name={getAlbumName(album.id)}
count={getAlbumCount(album.id)}
thumbnailId={getAlbumThumbnailId(album.id)}
highlighted={album.id === highlightedAlbumId}
onRemove={() => removeSectionAlbum(spIndex, secIndex, aIndex)}
onEdit={() =>
onEditAlbum({
type: 'section',
subpageIndex: spIndex,
sectionIndex: secIndex,
albumIndex: aIndex,
})
}
/>
))}
</div>
<button
className="admin-btn admin-btn-xs admin-btn-primary"
onClick={() => onPickAlbum({ type: 'subpage', subpageIndex: spIndex })}
className="admin-btn admin-btn-xs"
onClick={() =>
onPickAlbum({
type: 'section',
subpageIndex: spIndex,
sectionIndex: secIndex,
})
}
>
+ Add Album
</button>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onAlbumDragEnd}
>
<SortableContext
items={sp.albums.map((a, i) => `album-${a.id}-${i}`)}
strategy={verticalListSortingStrategy}
>
<div className="album-list">
{sp.albums.length === 0 && (
<p className="empty-hint">
No albums added yet. Click + Add Album to pick from Immich.
</p>
)}
{sp.albums.map((album, aIndex) => (
<SortableAlbumCard
key={`${album.id}-${aIndex}`}
album={album}
index={aIndex}
name={getAlbumName(album.id)}
count={getAlbumCount(album.id)}
thumbnailId={getAlbumThumbnailId(album.id)}
highlighted={album.id === highlightedAlbumId}
onRemove={() => removeSubpageAlbum(spIndex, aIndex)}
onEdit={() =>
onEditAlbum({
type: 'subpage',
subpageIndex: spIndex,
albumIndex: aIndex,
})
}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
)}
))}
</div>
)}
{/* Sections */}
<div className="subpage-actions" style={{ marginTop: '1rem' }}>
<button
className="admin-btn admin-btn-xs admin-btn-secondary"
onClick={() => addSection(spIndex)}
>
+ Add Section
</button>
{sp.sections && sp.sections.length > 0 && (
<div className="subpage-sections">
{sp.sections.map((sec, secIndex) => (
<div key={secIndex} className="section-card">
<div className="section-header">
<input
className="section-title-input"
value={sec.title}
onChange={(e) =>
updateSection(spIndex, secIndex, {
title: e.target.value,
})
}
placeholder="Section title"
/>
<button
className="admin-btn-icon"
onClick={() => removeSection(spIndex, secIndex)}
title="Remove section"
>
<IconX size={14} />
</button>
</div>
<div className="admin-field">
<input
value={sec.description || ''}
onChange={(e) =>
updateSection(spIndex, secIndex, {
description: e.target.value || undefined,
})
}
placeholder="Section description (optional)"
className="section-desc-input"
/>
</div>
<div className="album-list">
{sec.albums.map((album, aIndex) => (
<AlbumCard
key={`${album.id}-${aIndex}`}
album={album}
name={getAlbumName(album.id)}
count={getAlbumCount(album.id)}
thumbnailId={getAlbumThumbnailId(album.id)}
highlighted={album.id === highlightedAlbumId}
onRemove={() => removeSectionAlbum(spIndex, secIndex, aIndex)}
onEdit={() =>
onEditAlbum({
type: 'section',
subpageIndex: spIndex,
sectionIndex: secIndex,
albumIndex: aIndex,
})
}
/>
))}
</div>
<button
className="admin-btn admin-btn-xs"
onClick={() =>
onPickAlbum({
type: 'section',
subpageIndex: spIndex,
sectionIndex: secIndex,
})
}
>
+ Add Album
</button>
</div>
))}
</div>
)}
<div className="subpage-actions" style={{ marginTop: '1rem' }}>
<button
className="admin-btn admin-btn-xs admin-btn-secondary"
onClick={() => addSection(spIndex)}
onClick={() => onPickAlbum({ type: 'subpage', subpageIndex: spIndex })}
>
+ Add Section
+ Add Loose Album
</button>
{sp.sections && sp.sections.length > 0 && (
<button
className="admin-btn admin-btn-xs admin-btn-secondary"
onClick={() => onPickAlbum({ type: 'subpage', subpageIndex: spIndex })}
>
+ Add Loose Album
</button>
)}
</div>
)}
</div>
</div>
)}
</div>
</div>
)}
</div>
<div className="subpage-drawer-footer">
<button
className="admin-btn admin-btn-danger admin-btn-sm"
onClick={() => {
removeSubpage(spIndex);
onClose();
}}
>
<IconTrash size={14} /> Delete Subpage
</button>
<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>
{!inline && (
<button className="admin-btn admin-btn-primary admin-btn-sm" onClick={() => onClose()}>
Done
</button>
</div>
)}
</div>
</div>
);
if (inline) return sheet;
return (
<div className="subpage-drawer-backdrop" onClick={() => onClose()}>
{sheet}
</div>
);
}

View File

@@ -0,0 +1,175 @@
/**
* The page builder's edits as pure functions on GalleryState (#608).
*
* Each takes a state and returns a new one; PageBuilder wraps them in
* setGallery() and adds what is not the state's business: confirming a
* removal and marking the form dirty. Out here they have tests
* (app/admin/__tests__/gallery-ops.test.ts).
*
* An index that does not exist leaves the state as it is.
*/
import { arrayMove } from '@dnd-kit/sortable';
import type { GalleryState } from './galleryYaml';
import type { AlbumEntry, PickerTarget, Section, Subpage } from './types';
/**
* Where a list of albums lives: the standalone list, a subpage's own list, or
* a section of a subpage. PickerTarget and ActiveEditAlbumAddress both carry
* this shape.
*/
type AlbumListAddress = Pick<PickerTarget, 'type' | 'subpageIndex' | 'sectionIndex'>;
function mapSubpage(g: GalleryState, index: number, fn: (sp: Subpage) => Subpage): GalleryState {
if (!g.subpages[index]) return g;
return { ...g, subpages: g.subpages.map((sp, i) => (i === index ? fn(sp) : sp)) };
}
function mapSection(
g: GalleryState,
subpageIndex: number,
sectionIndex: number,
fn: (sec: Section) => Section,
): GalleryState {
if (!g.subpages[subpageIndex]?.sections?.[sectionIndex]) return g;
return mapSubpage(g, subpageIndex, (sp) => ({
...sp,
sections: sp.sections!.map((sec, i) => (i === sectionIndex ? fn(sec) : sec)),
}));
}
/** Replace the album list at an address. */
export function mapAlbums(
g: GalleryState,
at: AlbumListAddress,
fn: (albums: AlbumEntry[]) => AlbumEntry[],
): GalleryState {
if (at.type === 'standalone') return { ...g, albums: fn(g.albums) };
if (at.subpageIndex == null) return g;
if (at.type === 'subpage') {
return mapSubpage(g, at.subpageIndex, (sp) => ({ ...sp, albums: fn(sp.albums) }));
}
if (at.sectionIndex == null) return g;
return mapSection(g, at.subpageIndex, at.sectionIndex, (sec) => ({
...sec,
albums: fn(sec.albums),
}));
}
/** The album list at an address, or undefined when the address is stale. */
export function albumsAt(g: GalleryState, at: AlbumListAddress): AlbumEntry[] | undefined {
if (at.type === 'standalone') return g.albums;
const sp = at.subpageIndex == null ? undefined : g.subpages[at.subpageIndex];
if (at.type === 'subpage') return sp?.albums;
return at.sectionIndex == null ? undefined : sp?.sections?.[at.sectionIndex]?.albums;
}
// ── Albums ──────────────────────────────────────────────────────
export const addAlbum = (g: GalleryState, at: AlbumListAddress, entry: AlbumEntry) =>
mapAlbums(g, at, (albums) => [...albums, entry]);
export const updateAlbum = (
g: GalleryState,
at: AlbumListAddress,
albumIndex: number,
updates: Partial<AlbumEntry>,
) =>
mapAlbums(g, at, (albums) => albums.map((a, i) => (i === albumIndex ? { ...a, ...updates } : a)));
export const removeAlbum = (g: GalleryState, at: AlbumListAddress, albumIndex: number) =>
mapAlbums(g, at, (albums) => albums.filter((_, i) => i !== albumIndex));
export const moveAlbum = (g: GalleryState, at: AlbumListAddress, from: number, to: number) =>
mapAlbums(g, at, (albums) => arrayMove(albums, from, to));
// ── Hero ────────────────────────────────────────────────────────
export const addHero = (g: GalleryState, assetId: string): GalleryState => ({
...g,
hero: [...g.hero, assetId],
});
export const removeHero = (g: GalleryState, index: number): GalleryState => ({
...g,
hero: g.hero.filter((_, i) => i !== index),
});
export const moveHero = (g: GalleryState, from: number, to: number): GalleryState => ({
...g,
hero: arrayMove(g.hero, from, to),
});
// ── Subpages and sections ───────────────────────────────────────
export const addSubpage = (g: GalleryState): GalleryState => ({
...g,
subpages: [
...g.subpages,
{ name: `New Page ${g.subpages.length + 1}`, albums: [], sections: undefined },
],
});
export const removeSubpage = (g: GalleryState, index: number): GalleryState => ({
...g,
subpages: g.subpages.filter((_, i) => i !== index),
});
export const updateSubpage = (g: GalleryState, index: number, updates: Partial<Subpage>) =>
mapSubpage(g, index, (sp) => ({ ...sp, ...updates }));
export const moveSubpage = (g: GalleryState, from: number, to: number): GalleryState => ({
...g,
subpages: arrayMove(g.subpages, from, to),
});
export const addSection = (g: GalleryState, subpageIndex: number) =>
mapSubpage(g, subpageIndex, (sp) => ({
...sp,
sections: [...(sp.sections || []), { title: 'New Section', albums: [] }],
}));
export const removeSection = (g: GalleryState, subpageIndex: number, sectionIndex: number) =>
mapSubpage(g, subpageIndex, (sp) => ({
...sp,
sections: (sp.sections || []).filter((_, i) => i !== sectionIndex),
}));
export const updateSection = (
g: GalleryState,
subpageIndex: number,
sectionIndex: number,
updates: Partial<Section>,
) => mapSection(g, subpageIndex, sectionIndex, (sec) => ({ ...sec, ...updates }));
// ── Reading ─────────────────────────────────────────────────────
/** The "03 — Collection" number the page renders: counted over enabled subpages only. */
export function enabledPosition(g: GalleryState, index: number): number | undefined {
const sp = g.subpages[index];
if (!sp || sp.enabled === false) return undefined;
return g.subpages.slice(0, index).filter((s) => s.enabled !== false).length + 1;
}
/** Every album published anywhere, so the picker can mark the ones already in use. */
export function usedAlbumIds(g: GalleryState): Set<string> {
const ids = new Set<string>();
g.albums.forEach((a) => ids.add(a.id));
g.subpages.forEach((sp) => {
sp.albums.forEach((a) => ids.add(a.id));
sp.sections?.forEach((sec) => sec.albums.forEach((a) => ids.add(a.id)));
});
return ids;
}
/**
* Where the open subpage sheet goes after a drag, so the sheet stays on the
* page it was showing.
*/
export function followMovedIndex(open: number | null, from: number, to: number): number | null {
if (open === null) return null;
if (open === from) return to;
if (from < open && to >= open) return open - 1;
if (from > open && to <= open) return open + 1;
return open;
}

View File

@@ -0,0 +1,130 @@
/**
* gallery.yaml ⇄ the page builder's model (#608).
*
* This is the code that decides what ends up in gallery.yaml when the
* builder saves. It lived inside the 1,200-line PageBuilder component, where
* nothing could test it; as pure functions it has a table of cases in
* app/admin/__tests__/gallery-yaml.test.ts.
*/
import { parseAlbumEntries, serializeAlbumEntries } from './albumEntries';
import { seedCoverGrid, type AlbumEntry, type Subpage } from './types';
export interface GalleryState {
hero: string[];
albums: AlbumEntry[];
subpages: Subpage[];
}
/**
* Accepts both subpage shapes gallery.yaml allows: the list of objects the
* builder writes, and the older map of name → albums (or name → object).
*/
export function parseGalleryYaml(raw: Record<string, unknown>): GalleryState {
const hero = Array.isArray(raw.hero) ? raw.hero : raw.hero ? [raw.hero as string] : [];
const albums = parseAlbumEntries(
raw.albums as Array<string | Record<string, string>> | undefined,
);
let subpages: Subpage[] = [];
if (Array.isArray(raw.subpages)) {
subpages = (raw.subpages as Array<Record<string, unknown>>).map((sp) => ({
name: (sp.name as string) || '',
title: sp.title as string | undefined,
subtitle: sp.subtitle as string | undefined,
password: sp.password as string | undefined,
enabled: sp.enabled !== false,
hidden: sp.hidden === true,
proofing: typeof sp.proofing === 'boolean' ? sp.proofing : undefined,
location: sp.location as string | undefined,
essayText: sp.essayText as string | undefined,
essayFile: sp.essayFile as string | undefined,
albums: parseAlbumEntries(sp.albums as Array<string | Record<string, string>> | undefined),
sections: sp.sections
? (sp.sections as Array<Record<string, unknown>>).map((sec) => ({
title: (sec.title as string) || '',
description: sec.description as string | undefined,
albums: parseAlbumEntries(sec.albums as Array<string | Record<string, string>>),
}))
: undefined,
grid: sp.grid as Subpage['grid'],
coverGrid:
(sp.coverGrid as Subpage['coverGrid']) ?? seedCoverGrid(sp.grid as Subpage['grid']),
}));
} else if (raw.subpages && typeof raw.subpages === 'object') {
subpages = Object.entries(raw.subpages as Record<string, unknown>).map(([name, value]) => {
if (Array.isArray(value)) {
return { name, albums: parseAlbumEntries(value), sections: undefined, enabled: true };
}
const sp = value as Record<string, unknown>;
return {
name,
title: sp.title as string | undefined,
subtitle: sp.subtitle as string | undefined,
password: sp.password as string | undefined,
enabled: sp.enabled !== false,
hidden: sp.hidden === true,
proofing: typeof sp.proofing === 'boolean' ? sp.proofing : undefined,
location: sp.location as string | undefined,
essayText: sp.essayText as string | undefined,
essayFile: sp.essayFile as string | undefined,
albums: parseAlbumEntries(sp.albums as Array<string | Record<string, string>> | undefined),
sections: undefined,
grid: sp.grid as Subpage['grid'],
coverGrid:
(sp.coverGrid as Subpage['coverGrid']) ?? seedCoverGrid(sp.grid as Subpage['grid']),
};
});
}
return { hero, albums, subpages };
}
/** The gallery.yaml object for a builder state. Empty groups are left out. */
export function serializeGallery(gallery: GalleryState): Record<string, unknown> {
const yamlData: Record<string, unknown> = {};
if (gallery.hero.length > 0) {
yamlData.hero = gallery.hero;
}
if (gallery.albums.length > 0) {
yamlData.albums = serializeAlbumEntries(gallery.albums);
}
if (gallery.subpages.length > 0) {
yamlData.subpages = gallery.subpages.map((sp) => {
const entry: Record<string, unknown> = { name: sp.name };
if (sp.title) entry.title = sp.title;
if (sp.subtitle) entry.subtitle = sp.subtitle;
if (sp.password) entry.password = sp.password;
if (sp.enabled === false) entry.enabled = false;
if (sp.hidden === true) entry.hidden = true;
// Both values mean something: false switches proofing off for this page.
if (sp.proofing !== undefined) entry.proofing = sp.proofing;
if (sp.essayText) entry.essayText = sp.essayText;
if (sp.essayFile) entry.essayFile = sp.essayFile;
if (sp.grid) entry.grid = sp.grid;
if (sp.coverGrid) entry.coverGrid = sp.coverGrid;
if (sp.location) entry.location = sp.location;
if (sp.sections && sp.sections.length > 0) {
entry.sections = sp.sections.map((sec) => {
const s: Record<string, unknown> = {
title: sec.title,
albums: serializeAlbumEntries(sec.albums),
};
if (sec.description) s.description = sec.description;
return s;
});
if (sp.albums.length > 0) {
entry.albums = serializeAlbumEntries(sp.albums);
}
} else {
entry.albums = serializeAlbumEntries(sp.albums);
}
return entry;
});
}
return yamlData;
}

View File

@@ -38,6 +38,11 @@ export interface Subpage {
subtitle?: string;
password?: string;
enabled?: boolean;
/**
* Client proofing on this page, overriding the site-wide setting in either
* direction. No control in the builder yet; carried so a save keeps it.
*/
proofing?: boolean;
/** EXPERIMENTAL: reachable by direct link, but not shown in navigation */
hidden?: boolean;
essayText?: string;

View File

@@ -0,0 +1,287 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import * as Icons from '../Icons';
import type { SaveStatus } from '../SaveBar';
import { useDraft, readDraft } from '../useDraft';
import { useContentRestored } from '../contentRestored';
interface AboutMeta {
portrait?: string;
name?: string;
location?: string;
gear?: string[];
}
/** The About editor's state, as kept in a draft. */
interface AboutDraft {
meta: AboutMeta;
body: string;
gearText: string;
}
/**
* What a draft of the About page is compared against: the file's content, not
* the textarea's. `gearText` is derived from `meta.gear` and would make a
* trailing newline read as a change on disk.
*/
function aboutFingerprint(about: AboutDraft): string {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { gear: _gear, ...meta } = about.meta;
const gear = about.gearText
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
return JSON.stringify({ meta, gear, body: about.body });
}
/**
* The About page editor's state: content/about.md, loaded over its own
* endpoint and saved on its own, but reached through the settings nav and
* saved by the same save bar and ⌘S (#554).
*
* A hook rather than state inside the section, because the section unmounts
* when another one is opened while an About edit is still unsaved, and the
* save bar has to keep offering it.
*/
export function useAboutEditor(active: boolean) {
const [aboutMeta, setAboutMeta] = useState<AboutMeta>({});
const [aboutBody, setAboutBody] = useState('');
const [aboutLoading, setAboutLoading] = useState(false);
const [aboutLoaded, setAboutLoaded] = useState(false);
const [aboutSaving, setAboutSaving] = useState(false);
const [aboutDirty, setAboutDirty] = useState(false);
const [aboutStatus, setAboutStatus] = useState<SaveStatus>(null);
const [aboutGearText, setAboutGearText] = useState('');
const [serverAbout, setServerAbout] = useState<AboutDraft | null>(null);
// Unsaved About edits survive a tab switch or Reload, like settings (#592).
const draft = useDraft<AboutDraft>(
'about',
{ meta: aboutMeta, body: aboutBody, gearText: aboutGearText },
aboutDirty,
);
const loadDraft = draft.load;
const applyAbout = (value: AboutDraft) => {
setAboutMeta(value.meta);
setAboutBody(value.body);
setAboutGearText(value.gearText);
};
const loadAboutContent = useCallback(async () => {
setAboutLoading(true);
setAboutStatus(null);
try {
const res = await fetch('/api/admin/about');
if (!res.ok) {
throw new Error(
res.status === 401
? 'Your session has expired. Sign in again to continue.'
: `The server answered ${res.status}.`,
);
}
const data = await res.json();
const loaded: AboutDraft = {
meta: data.meta || {},
body: data.body || '',
gearText: data.meta?.gear?.join('\n') || '',
};
setServerAbout(loaded);
const restored = loadDraft(aboutFingerprint(loaded));
const value = restored ?? loaded;
setAboutMeta(value.meta);
setAboutBody(value.body);
setAboutGearText(value.gearText);
setAboutDirty(restored !== null);
setAboutLoaded(true);
} catch (err) {
console.error('Failed to load about content:', err);
// aboutLoaded stays false, which is what blocks the save below: an empty
// editor written to about.md would replace the page with nothing.
setAboutStatus({
kind: 'error',
message: `Error: ${err instanceof Error ? err.message : 'The About page could not be loaded.'}`,
});
} finally {
setAboutLoading(false);
}
}, [loadDraft]);
async function saveAboutContent() {
if (!aboutLoaded) return;
setAboutSaving(true);
setAboutStatus(null);
const cleanedMeta = { ...aboutMeta };
for (const [k, v] of Object.entries(cleanedMeta)) {
if (v === '' || v === undefined) delete cleanedMeta[k as keyof typeof cleanedMeta];
}
const gearLines = aboutGearText
.split('\n')
.map((s) => s.trim())
.filter(Boolean);
if (gearLines.length > 0) {
cleanedMeta.gear = gearLines;
} else {
delete cleanedMeta.gear;
}
try {
const res = await fetch('/api/admin/about', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ meta: cleanedMeta, body: aboutBody }),
});
if (res.ok) {
const data = await res.json();
const saved: AboutDraft = { meta: cleanedMeta, body: aboutBody, gearText: aboutGearText };
setServerAbout(saved);
draft.saved(aboutFingerprint(saved));
setAboutDirty(false);
setAboutStatus({ kind: 'success', message: data.message || 'Saved!' });
setTimeout(() => setAboutStatus(null), 4000);
} else {
const err = await res.json();
setAboutStatus({ kind: 'error', message: `Error: ${err.error}` });
}
} catch {
setAboutStatus({ kind: 'error', message: 'Error: Failed to save' });
} finally {
setAboutSaving(false);
}
}
// Loaded the first time the About section opens, and only then: the editor
// stays mounted across sections, so reloading on every visit would overwrite
// unsaved About edits with the file on disk.
useEffect(() => {
if (active && !aboutLoaded) loadAboutContent();
}, [active, aboutLoaded, loadAboutContent]);
// A kept About draft loads the file at once, from any section, so the save
// bar owns up to those edits before the About section is ever opened.
useEffect(() => {
if (!active && readDraft('about')) loadAboutContent();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// A restored about.md replaces what this editor loaded.
useContentRestored(({ target }) => {
if (target === 'about' && aboutLoaded) loadAboutContent();
});
function updateAboutMeta(key: string, value: unknown) {
setAboutMeta((m) => ({ ...m, [key]: value }));
setAboutDirty(true);
setAboutStatus(null);
}
return {
meta: aboutMeta,
body: aboutBody,
gearText: aboutGearText,
loading: aboutLoading,
saving: aboutSaving,
dirty: aboutDirty,
status: aboutStatus as SaveStatus,
updateMeta: updateAboutMeta,
setBody: (value: string) => {
setAboutBody(value);
setAboutDirty(true);
setAboutStatus(null);
},
setGearText: (value: string) => {
setAboutGearText(value);
setAboutDirty(true);
setAboutStatus(null);
},
save: saveAboutContent,
draftStatus: draft.status,
dismissDraft: draft.dismiss,
discardDraft: () => {
draft.discard();
if (serverAbout) applyAbout(serverAbout);
setAboutDirty(false);
setAboutStatus(null);
},
restoreDraft: () => {
const value = draft.takeConflicting();
if (!value) return;
applyAbout(value);
setAboutDirty(true);
},
};
}
export type AboutEditor = ReturnType<typeof useAboutEditor>;
export default function AboutSection({ about }: { about: AboutEditor }) {
return (
<div className="settings-panel">
<div className="settings-section-header">
<h3>
<Icons.IconCamera size={18} /> About Page
</h3>
<p className="settings-section-sub">
Edit the portrait, biography and gear shown on the About page. Use General to show or hide
the page itself.
</p>
</div>
{about.loading ? (
<div className="admin-spinner" style={{ margin: '2rem auto' }} />
) : (
<>
<div className="admin-field">
<label htmlFor="about-portrait-asset-id">Portrait Asset ID</label>
<input
id="about-portrait-asset-id"
value={about.meta.portrait || ''}
onChange={(e) => about.updateMeta('portrait', e.target.value)}
placeholder="Immich asset UUID for the portrait photo"
/>
</div>
<div className="admin-field">
<label htmlFor="about-name">Name</label>
<input
id="about-name"
value={about.meta.name || ''}
onChange={(e) => about.updateMeta('name', e.target.value)}
placeholder="Your name"
/>
</div>
<div className="admin-field">
<label htmlFor="about-location">Location</label>
<input
id="about-location"
value={about.meta.location || ''}
onChange={(e) => about.updateMeta('location', e.target.value)}
placeholder="City, Country"
/>
</div>
<div className="admin-field">
<label htmlFor="about-gear">Gear (one per line)</label>
<textarea
id="about-gear"
value={about.gearText}
onChange={(e) => about.setGearText(e.target.value)}
placeholder={`Leica Q3\nSummilux 35mm f/1.4`}
rows={4}
/>
</div>
<div className="admin-field">
<label htmlFor="about-biography">Biography (Markdown)</label>
<textarea
id="about-biography"
value={about.body}
onChange={(e) => about.setBody(e.target.value)}
placeholder="Photographer based in..."
rows={6}
/>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,118 @@
'use client';
import * as Icons from '../Icons';
import type { SectionProps } from './types';
export default function FooterSection({ settings, update }: SectionProps) {
return (
<div className="settings-panel">
<div className="settings-section-header">
<h3>
<Icons.IconLink size={18} /> Footer &amp; Social Links
</h3>
<p className="settings-section-sub">
Display branding, Instagram, email and website links in portfolio footer.
</p>
</div>
<div className="admin-field">
<label htmlFor="footer-brand-name">Footer Brand Name</label>
<input
id="footer-brand-name"
value={settings.footer?.name || ''}
onChange={(e) => update('footer.name', e.target.value)}
placeholder="My Photography"
/>
</div>
<div className="admin-field">
<label htmlFor="footer-instagram-url">Instagram URL</label>
<input
id="footer-instagram-url"
value={settings.footer?.instagram || ''}
onChange={(e) => update('footer.instagram', e.target.value)}
placeholder="https://instagram.com/your-handle"
/>
</div>
<div className="admin-field-row">
<div className="admin-field">
<label htmlFor="footer-contact-email">Contact Email</label>
<input
id="footer-contact-email"
value={settings.footer?.email || ''}
onChange={(e) => update('footer.email', e.target.value)}
placeholder="hello@example.com"
/>
</div>
<div className="admin-field">
<label htmlFor="footer-personal-website">Personal Website</label>
<input
id="footer-personal-website"
value={settings.footer?.website || ''}
onChange={(e) => update('footer.website', e.target.value)}
placeholder="https://example.com"
/>
</div>
</div>
<div className="settings-section-header" style={{ marginTop: '2rem' }}>
<h3>
<Icons.IconLink size={18} /> Header Navigation Links (Experimental)
</h3>
<p className="settings-section-sub">
External links shown after your pages in the header menu. Only http(s) URLs are allowed;
they open in a new tab.
</p>
</div>
{(settings.navLinks || []).map((link, i) => (
<div className="admin-field-row" key={i}>
<div className="admin-field">
<label htmlFor="footer-label">Label</label>
<input
id="footer-label"
value={link.label || ''}
onChange={(e) => {
const next = [...(settings.navLinks || [])];
next[i] = { ...next[i], label: e.target.value };
update('navLinks', next);
}}
placeholder="Shop"
/>
</div>
<div className="admin-field">
<label htmlFor="footer-url">URL</label>
<input
id="footer-url"
value={link.url || ''}
onChange={(e) => {
const next = [...(settings.navLinks || [])];
next[i] = { ...next[i], url: e.target.value };
update('navLinks', next);
}}
placeholder="https://shop.example.com"
/>
</div>
<button
type="button"
className="admin-btn admin-btn-sm"
style={{ alignSelf: 'flex-end' }}
onClick={() => {
const next = (settings.navLinks || []).filter((_, j) => j !== i);
update('navLinks', next.length > 0 ? next : undefined);
}}
title="Remove link"
>
Remove
</button>
</div>
))}
<button
type="button"
className="admin-btn admin-btn-sm"
onClick={() => update('navLinks', [...(settings.navLinks || []), { label: '', url: '' }])}
>
+ Add external link
</button>
</div>
);
}

View File

@@ -0,0 +1,311 @@
'use client';
import { useState } from 'react';
import * as Icons from '../Icons';
import type { SaveStatus } from '../SaveBar';
import { resolveExifDisplay } from '@/lib/config/schema';
import { SUPPORTED_LOCALES } from '@/lib/i18n';
import { FeatureGroup, FeatureRow, SettingRow } from './fields';
import type { SectionProps } from './types';
import { useConfirm } from '../ConfirmDialog';
export default function GeneralSection({ settings, update, updateMany }: SectionProps) {
const confirm = useConfirm();
// Collapsed by default: the four metadata switches are a detail of one
// decision, and showing them permanently is what made the section a wall (#510).
const [metadataOpen, setMetadataOpen] = useState(false);
const [faviconUploading, setFaviconUploading] = useState(false);
const [faviconStatus, setFaviconStatus] = useState<SaveStatus>(null);
// Resolved the same way the site resolves it, so the switches show what a
// visitor actually sees — including a config that only ever set the older
// `exifOnHover`.
const exif = resolveExifDisplay(settings.exif, settings.exifOnHover);
// The master switch has no key of its own and needs none: "off" is all four
// groups off, a state the site already acts on — with nothing left to show,
// the lightbox withdraws its info button and the `i` key entirely (#506).
const anyMetadata = exif.camera || exif.settings || exif.location || exif.caption;
const metadataSummary =
[
exif.camera && 'Camera & lens',
exif.settings && 'Exposure',
exif.location && 'Location',
exif.caption && 'Description',
]
.filter(Boolean)
.join(' · ') || 'Nothing published — the lightbox hides its info panel';
/** Turning it back on selects everything; the details below narrow it again. */
const toggleMetadata = () =>
updateMany({
'exif.camera': !anyMetadata,
'exif.settings': !anyMetadata,
'exif.location': !anyMetadata,
'exif.caption': !anyMetadata,
});
return (
<div className="settings-panel">
<div className="settings-section-header">
<h3>
<Icons.IconGear size={18} /> General Site Settings
</h3>
<p className="settings-section-sub">
Configure basic site identity, language, and core feature toggles.
</p>
</div>
<div className="admin-field">
<label htmlFor="general-site-title">Site Title</label>
<input
id="general-site-title"
value={settings.title || ''}
onChange={(e) => update('title', e.target.value)}
placeholder="My Portfolio"
/>
</div>
<div className="admin-field">
<label htmlFor="general-subtitle">Subtitle</label>
<input
id="general-subtitle"
value={settings.subtitle || ''}
onChange={(e) => update('subtitle', e.target.value)}
placeholder="A visual journal"
/>
</div>
<div className="admin-field">
<label htmlFor="general-language">Language</label>
<select
id="general-language"
aria-label="Language"
value={settings.lang || 'en'}
onChange={(e) => update('lang', e.target.value)}
>
<option value="en">English (US)</option>
<option value="de">Deutsch (DE)</option>
<option value="fr">Français (FR)</option>
<option value="es">Español (ES)</option>
<option value="it">Italiano (IT)</option>
<option value="nl">Nederlands (NL)</option>
<option value="ja">日本語 (JA)</option>
</select>
<p className="admin-field-hint">
The visitor-facing interface is translated for {SUPPORTED_LOCALES.join(', ')}. Other
languages still set <code>&lt;html lang&gt;</code> and date formatting, but show the
English interface. The admin panel is always English.
</p>
</div>
<div className="settings-section-divider" />
<div className="settings-section-header">
<h3>
<Icons.IconSparkles size={18} /> Portfolio Features &amp; Modules
</h3>
<p className="settings-section-sub">
Optional pages, and what the site reveals about your photos and counts about visits.
</p>
</div>
<FeatureGroup
icon={<Icons.IconFrame size={13} />}
title="Modules"
description="Each one adds a page or a control visitors can use."
>
<div className="feature-list">
<FeatureRow
icon={<Icons.IconCamera size={15} />}
title="About page"
description="Portrait, bio and gear at /about"
checked={settings.about?.enabled !== false}
onToggle={() => update('about.enabled', settings.about?.enabled === false)}
href="/admin/settings/about"
/>
<FeatureRow
icon={<Icons.IconMap size={15} />}
title="Map"
description="Photo locations on a world map at /map"
checked={settings.map === true}
onToggle={() => update('map', !settings.map)}
/>
<FeatureRow
icon={<Icons.IconHeart size={15} />}
title="Client proofing"
description="Visitors heart, filter and export a selection of photos"
checked={settings.proofing?.enabled !== false}
onToggle={() => update('proofing.enabled', settings.proofing?.enabled === false)}
/>
</div>
</FeatureGroup>
<FeatureGroup
icon={<Icons.IconShieldCheck size={13} />}
title="What visitors learn about you"
description="What each photo reveals, and what is counted about a visit."
chip="visible to visitors"
>
<div className={`metadata-card ${anyMetadata ? 'active' : ''}`}>
<button
type="button"
className="metadata-card-main"
onClick={toggleMetadata}
aria-pressed={anyMetadata}
>
<span className="toggle-card-info">
<span className="toggle-card-title">
<Icons.IconCamera size={16} /> Photo metadata
</span>
<span className="toggle-card-desc">{metadataSummary}</span>
</span>
<span className={`switch-toggle ${anyMetadata ? 'on' : ''}`}>
<span className="switch-slider" />
</span>
</button>
<button
type="button"
className="metadata-details-btn"
onClick={() => setMetadataOpen((open) => !open)}
aria-expanded={metadataOpen}
>
Details
<Icons.IconChevronDown
size={13}
className={`metadata-details-chevron${metadataOpen ? ' open' : ''}`}
/>
</button>
{metadataOpen && (
<div className="metadata-details">
<SettingRow
title="Camera &amp; Lens"
description="Body, lens and focal length"
checked={exif.camera}
onToggle={() => update('exif.camera', !exif.camera)}
/>
<SettingRow
title="Exposure Settings"
description="Aperture, shutter speed and ISO"
checked={exif.settings}
onToggle={() => update('exif.settings', !exif.settings)}
/>
<SettingRow
title="Location"
description="City and country from the photo's GPS data"
checked={exif.location}
onToggle={() => update('exif.location', !exif.location)}
/>
<SettingRow
title="Photo Description"
description="The description written in Immich"
checked={exif.caption}
onToggle={() => update('exif.caption', !exif.caption)}
/>
{/* Dependent, not a peer: this only controls the grid overlay, and
the overlay carries camera and lens. With those off it has
nothing to show, which used to leave it switched on and
silently doing nothing (#510). */}
<SettingRow
indented
title="Summary on grid hover"
description="Camera and lens over the photo, not only in the lightbox"
checked={settings.exifOnHover !== false}
disabled={!exif.camera}
hint={exif.camera ? undefined : 'Needs Camera & Lens'}
onToggle={() => update('exifOnHover', settings.exifOnHover === false)}
/>
</div>
)}
</div>
<div className="feature-list">
<FeatureRow
icon={<Icons.IconBarChart size={15} />}
title="Analytics"
description="Cookieless view counts, stored on your server only"
checked={settings.analytics !== false}
onToggle={() => update('analytics', settings.analytics === false)}
href="/admin/analytics"
/>
</div>
</FeatureGroup>
<div className="admin-field favicon-field">
<span className="admin-field-label">Favicon</span>
<div className="favicon-row">
<input
type="file"
accept=".svg,.png,.ico,.jpg,.jpeg"
disabled={faviconUploading}
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setFaviconStatus(null);
setFaviconUploading(true);
const form = new FormData();
form.append('file', file);
try {
const res = await fetch('/api/admin/favicon', {
method: 'PUT',
body: form,
});
const data = await res.json();
setFaviconStatus(
res.ok
? { kind: 'success', message: data.message }
: { kind: 'error', message: `Error: ${data.error}` },
);
} catch {
setFaviconStatus({ kind: 'error', message: 'Error: Upload failed' });
} finally {
setFaviconUploading(false);
e.target.value = '';
}
}}
/>
<button
type="button"
className="admin-btn"
disabled={faviconUploading}
onClick={async () => {
// Deletes the uploaded file at once, outside the staged form.
const ok = await confirm({
title: 'Reset the favicon?',
message: 'The uploaded icon is deleted and the bundled default is used again.',
confirmLabel: 'Reset',
danger: true,
});
if (!ok) return;
setFaviconStatus(null);
setFaviconUploading(true);
try {
const res = await fetch('/api/admin/favicon', { method: 'DELETE' });
const data = await res.json();
setFaviconStatus(
res.ok
? { kind: 'success', message: data.message }
: { kind: 'error', message: `Error: ${data.error}` },
);
} catch {
setFaviconStatus({ kind: 'error', message: 'Error: Reset failed' });
} finally {
setFaviconUploading(false);
}
}}
>
Reset
</button>
{faviconUploading && <div className="admin-spinner" />}
</div>
{faviconStatus && (
<p className={`save-message ${faviconStatus.kind}`}>{faviconStatus.message}</p>
)}
<span className="admin-field-hint">
SVG, PNG, ICO, or JPEG — max 512 kB. Stored in the content volume. Reset restores the
bundled default.
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,204 @@
'use client';
import * as Icons from '../Icons';
import OptionGrid, { toOptions } from '../fields/OptionGrid';
import {
PHOTO_GRID_COLUMNS_MAX,
PHOTO_GRID_COLUMNS_MIN,
PHOTO_GRID_GAP_MAX,
} from '@/lib/config/schema';
import type { SectionProps } from './types';
const LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow', 'justified'];
const ASPECT_RATIOS = ['1', '3/2', '2/3', '16/9', 'auto'];
const LAYOUT_INFO: Record<string, { label: string; desc: string }> = {
masonry: {
label: 'Masonry',
desc: 'Dynamic pinterest-style staggered columns',
},
uniform: { label: 'Uniform Grid', desc: 'Clean equal aspect ratio grid' },
showcase: {
label: 'Showcase',
desc: 'Featured hero photos mixed with smaller tiles',
},
filmstrip: {
label: 'Filmstrip',
desc: 'Horizontal scrollable film strip timeline',
},
'editorial-flow': {
label: 'Editorial Flow',
desc: 'Magazine story layout with varying photo sizes',
},
justified: {
label: 'Justified (Experimental)',
desc: 'Equal-height rows that fill the full width',
},
};
const LAYOUT_OPTIONS = toOptions(LAYOUTS, LAYOUT_INFO);
function LayoutPreview({ value }: { value: string }) {
return (
<div className="grid-card-preview">
<div className={`mini-layout-demo layout-demo-${value}`}>
{value === 'masonry' && (
<div className="demo-masonry-col-group">
<div className="demo-col">
<div className="demo-tile h-high" />
<div className="demo-tile h-low" />
</div>
<div className="demo-col">
<div className="demo-tile h-low" />
<div className="demo-tile h-high" />
</div>
<div className="demo-col">
<div className="demo-tile h-med" />
<div className="demo-tile h-med" />
</div>
</div>
)}
{value === 'uniform' && (
<div className="demo-uniform-grid">
<div className="demo-tile" />
<div className="demo-tile" />
<div className="demo-tile" />
<div className="demo-tile" />
<div className="demo-tile" />
<div className="demo-tile" />
</div>
)}
{value === 'showcase' && (
<div className="demo-showcase-grid">
<div className="demo-tile demo-hero" />
<div className="demo-col">
<div className="demo-tile" />
<div className="demo-tile" />
</div>
</div>
)}
{value === 'filmstrip' && (
<div className="demo-filmstrip-row">
<div className="demo-tile strip" />
<div className="demo-tile strip" />
<div className="demo-tile strip" />
</div>
)}
{value === 'editorial-flow' && (
<div className="demo-editorial-flow">
<div className="demo-tile wide" />
<div className="demo-row">
<div className="demo-tile" />
<div className="demo-tile" />
</div>
</div>
)}
{value === 'justified' && (
<div className="demo-editorial-flow">
<div className="demo-row">
<div className="demo-tile wide" />
<div className="demo-tile" />
</div>
<div className="demo-row">
<div className="demo-tile" />
<div className="demo-tile wide" />
</div>
</div>
)}
</div>
</div>
);
}
const ASPECT_RATIO_INFO: Record<string, { label: string; desc: string }> = {
'1': { label: 'Square (1:1)', desc: '1:1 ratio square crops' },
'3/2': { label: 'Landscape (3:2)', desc: 'Standard 35mm DSLR landscape' },
'2/3': { label: 'Portrait (2:3)', desc: 'Vertical portrait orientation' },
'16/9': { label: 'Cinema (16:9)', desc: 'Widescreen 16:9 cinematic ratio' },
auto: {
label: 'Original Auto',
desc: 'Uncropped original image proportions',
},
};
const ASPECT_RATIO_OPTIONS = toOptions(ASPECT_RATIOS, ASPECT_RATIO_INFO);
function AspectRatioPreview({ value }: { value: string }) {
return (
<div className="ratio-card-preview">
<div className={`mini-aspect-box ratio-${value.replace('/', '-')}`}>
<div className="mini-aspect-inner" />
</div>
</div>
);
}
export default function GridSection({ settings, update }: SectionProps) {
return (
<div className="settings-panel">
<div className="settings-section-header">
<h3>
<Icons.IconGrid size={18} /> Grid &amp; Layout Engine
</h3>
<p className="settings-section-sub">
Configure photography gallery column structures and thumbnail aspect ratios.
</p>
</div>
<OptionGrid
label="Layout Algorithm"
options={LAYOUT_OPTIONS}
value={settings.grid?.layout || 'masonry'}
onSelect={(v) => update('grid.layout', v)}
cardClassName="grid-layout-card"
renderPreview={(o) => <LayoutPreview value={o.value} />}
/>
<OptionGrid
label="Aspect Ratio"
options={ASPECT_RATIO_OPTIONS}
value={settings.grid?.aspectRatio || '1'}
onSelect={(v) => update('grid.aspectRatio', v)}
cardClassName="ratio-card"
renderPreview={(o) => <AspectRatioPreview value={o.value} />}
/>
<div className="settings-section-divider" />
<div className="settings-section-header">
<h3>
<Icons.IconColumns size={18} /> Spacing &amp; Columns
</h3>
<p className="settings-section-sub">
Adjust column counts and grid gap spacing. The column count also tiles the album covers on
a subpage; their spacing stays with the theme unless a page overrides it.
</p>
</div>
<div className="admin-field-row">
<div className="admin-field">
<label htmlFor="grid-columns">
Columns ({PHOTO_GRID_COLUMNS_MIN} - {PHOTO_GRID_COLUMNS_MAX})
</label>
<input
id="grid-columns"
type="number"
min={PHOTO_GRID_COLUMNS_MIN}
max={PHOTO_GRID_COLUMNS_MAX}
value={settings.grid?.columns ?? 3}
onChange={(e) => update('grid.columns', parseInt(e.target.value) || 3)}
/>
</div>
<div className="admin-field">
<label htmlFor="grid-gap-spacing">Gap Spacing (px)</label>
<input
id="grid-gap-spacing"
type="number"
min={0}
max={PHOTO_GRID_GAP_MAX}
value={settings.grid?.gap ?? 12}
onChange={(e) => update('grid.gap', parseInt(e.target.value) || 0)}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,255 @@
'use client';
import * as Icons from '../Icons';
import ToggleCard from '../fields/ToggleCard';
import PrivacyEditor from '../PrivacyEditor';
import { CONTACT_RETENTION_DEFAULT, CONTACT_RETENTION_MAX, isHttpUrl } from '@/lib/config/schema';
import type { SectionProps } from './types';
export default function LegalSection({ settings, update }: SectionProps) {
// The Impressum drops a non-http(s) contact URL with a warning in the server
// log, where nobody looks; say it here instead.
const contactUrl = settings.legal?.contactUrl?.trim();
const contactUrlInvalid = !!contactUrl && !isHttpUrl(contactUrl);
const notifyUrl = settings.contact?.notifyUrl?.trim();
const notifyUrlInvalid = !!notifyUrl && !isHttpUrl(notifyUrl);
return (
<div className="settings-panel">
<div className="settings-section-header">
<h3>
<Icons.IconScale size={18} /> Legal Notice &amp; Impressum
</h3>
<p className="settings-section-sub">
Configure the legal disclosure page required by the German Digitale-Dienste-Gesetz (DDG).
</p>
{settings.legal?.enabled && (
<a
href="/impressum"
target="_blank"
rel="noopener noreferrer"
className="admin-btn admin-btn-sm settings-section-action"
>
View /impressum ↗
</a>
)}
</div>
<div className="admin-toggle-cards-grid" style={{ marginBottom: '1.25rem' }}>
<ToggleCard
icon={<Icons.IconFileText size={16} />}
title="Enable Impressum Page (/impressum)"
description="Automatically generates and links /impressum in footer"
checked={settings.legal?.enabled === true}
onToggle={() => update('legal.enabled', !settings.legal?.enabled)}
/>
</div>
{settings.legal?.enabled && (
<>
<div className="admin-field">
<label htmlFor="legal-heading">Heading</label>
<input
id="legal-heading"
value={settings.legal?.heading || ''}
onChange={(e) => update('legal.heading', e.target.value)}
placeholder="Angaben gemäß § 5 DDG"
/>
<p className="admin-field-hint">
Leave empty for the § 5 DDG line in the site language. Set it when a different law
applies, e.g. § 5 ECG in Austria.
</p>
</div>
<div className="admin-field-row">
<div className="admin-field">
<label htmlFor="legal-full-name-business-name">Full Name / Business Name</label>
<input
id="legal-full-name-business-name"
value={settings.legal?.name || ''}
onChange={(e) => update('legal.name', e.target.value)}
placeholder="Max Mustermann"
/>
</div>
<div className="admin-field">
<label htmlFor="legal-street-address">Street Address</label>
<input
id="legal-street-address"
value={settings.legal?.address || ''}
onChange={(e) => update('legal.address', e.target.value)}
placeholder="Musterstraße 1"
/>
</div>
</div>
<div className="admin-field-row">
<div className="admin-field">
<label htmlFor="legal-zip-city">ZIP &amp; City</label>
<input
id="legal-zip-city"
value={settings.legal?.zipCity || ''}
onChange={(e) => update('legal.zipCity', e.target.value)}
placeholder="12345 Berlin"
/>
</div>
<div className="admin-field">
<label htmlFor="legal-country">Country</label>
<input
id="legal-country"
value={settings.legal?.country || ''}
onChange={(e) => update('legal.country', e.target.value)}
placeholder="Germany"
/>
</div>
</div>
<div className="admin-field-row">
<div className="admin-field">
<label htmlFor="legal-email">Legal Email</label>
<input
id="legal-email"
value={settings.legal?.email || ''}
onChange={(e) => update('legal.email', e.target.value)}
placeholder="legal@example.com"
/>
</div>
<div className="admin-field">
<label htmlFor="legal-phone-number">Phone Number</label>
<input
id="legal-phone-number"
value={settings.legal?.phone || ''}
onChange={(e) => update('legal.phone', e.target.value)}
placeholder="+49 123 456789"
/>
</div>
</div>
<div className="admin-field-row">
<div className="admin-field">
<label htmlFor="legal-contact-url">Contact Form URL</label>
<input
id="legal-contact-url"
type="url"
value={settings.legal?.contactUrl || ''}
onChange={(e) => update('legal.contactUrl', e.target.value)}
placeholder="https://example.com/contact"
aria-invalid={contactUrlInvalid || undefined}
aria-describedby={contactUrlInvalid ? 'legal-contact-url-error' : undefined}
/>
{contactUrlInvalid && (
<p
id="legal-contact-url-error"
className="admin-field-hint admin-field-hint--error"
>
Must start with https:// or http://. Any other link is left off the page.
</p>
)}
</div>
<div className="admin-field">
<label htmlFor="legal-contact-link-text">Contact Link Text</label>
<input
id="legal-contact-link-text"
value={settings.legal?.contactLabel || ''}
onChange={(e) => update('legal.contactLabel', e.target.value)}
placeholder="Contact form"
/>
</div>
</div>
<div className="admin-field-row">
<div className="admin-field">
<label htmlFor="legal-vat-id">VAT ID</label>
<input
id="legal-vat-id"
value={settings.legal?.vatId || ''}
onChange={(e) => update('legal.vatId', e.target.value)}
placeholder="DE123456789"
/>
</div>
<div className="admin-field">
<label htmlFor="legal-tax-number">Tax Number</label>
<input
id="legal-tax-number"
value={settings.legal?.taxId || ''}
onChange={(e) => update('legal.taxId', e.target.value)}
placeholder="12/345/67890"
/>
</div>
</div>
<div className="admin-field">
<label htmlFor="legal-additional-disclosures">Additional Disclosures</label>
<textarea
id="legal-additional-disclosures"
value={settings.legal?.extraInfo || ''}
onChange={(e) => update('legal.extraInfo', e.target.value)}
placeholder="Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV..."
rows={3}
/>
</div>
</>
)}
<div className="settings-section-divider" />
<div className="admin-toggle-cards-grid" style={{ marginBottom: '1.25rem' }}>
<ToggleCard
icon={<Icons.IconFileText size={16} />}
title="Contact Form (/contact)"
description="Messages are stored on this server and read under Messages. No mail is sent."
checked={settings.contact?.enabled === true}
onToggle={() => update('contact.enabled', !settings.contact?.enabled)}
/>
</div>
{settings.contact?.enabled && (
<div className="admin-field-row">
<div className="admin-field">
<label htmlFor="contact-notify-url">Notification URL (ntfy)</label>
<input
id="contact-notify-url"
type="url"
value={settings.contact?.notifyUrl || ''}
onChange={(e) => update('contact.notifyUrl', e.target.value)}
placeholder="https://ntfy.sh/your-secret-topic"
aria-invalid={notifyUrlInvalid || undefined}
aria-describedby="contact-notify-url-hint"
/>
<p
id="contact-notify-url-hint"
className={`admin-field-hint${notifyUrlInvalid ? ' admin-field-hint--error' : ''}`}
>
{notifyUrlInvalid
? 'Must start with https:// or http://.'
: 'Gets a fixed "new message" push, with nothing about the sender. Pick a topic name nobody can guess. CONTACT_NOTIFY_URL overrides this.'}
</p>
</div>
<div className="admin-field">
<label htmlFor="contact-retention">Delete messages after (days)</label>
<input
id="contact-retention"
type="number"
min={1}
max={CONTACT_RETENTION_MAX}
value={settings.contact?.retentionDays ?? CONTACT_RETENTION_DEFAULT}
onChange={(e) =>
update(
'contact.retentionDays',
parseInt(e.target.value) || CONTACT_RETENTION_DEFAULT,
)
}
/>
</div>
</div>
)}
<div className="settings-section-divider" />
<div className="admin-toggle-cards-grid" style={{ marginBottom: '1.25rem' }}>
<ToggleCard
icon={<Icons.IconShieldCheck size={16} />}
title="Privacy Policy (/privacy)"
description="Shown and linked in the footer once the text below is saved"
checked={settings.privacy?.enabled !== false}
onToggle={() => update('privacy.enabled', settings.privacy?.enabled === false)}
/>
</div>
<PrivacyEditor />
</div>
);
}

View File

@@ -0,0 +1,139 @@
'use client';
import * as Icons from '../Icons';
import ToggleCard from '../fields/ToggleCard';
import PasswordField from '../fields/PasswordField';
import { resolveWatermarkOpacity } from '@/lib/config/schema';
import type { SectionProps } from './types';
export default function SecuritySection({ settings, update }: SectionProps) {
return (
<div className="settings-panel">
<div className="settings-section-header">
<h3>
<Icons.IconLock size={18} /> Site Password
</h3>
<p className="settings-section-sub">
Puts the whole public site behind one password — useful while a portfolio is still being
built, or for a folio only ever shown to clients.
</p>
</div>
<div className="admin-field">
<label htmlFor="security-site-password">Site Password</label>
<PasswordField
id="security-site-password"
value={settings.sitePassword}
onChange={(password) => update('sitePassword', password ?? '')}
placeholder="Leave empty for a public site"
label="Site password"
/>
<p className="admin-field-hint">
Stored as a <code>scrypt:…</code> hash in <code>settings.yaml</code>, never as typed. The{' '}
<code>SITE_PASSWORD</code> environment variable overrides this field. The admin panel
keeps its own password and is never behind this gate.
</p>
</div>
<div className="settings-section-divider" />
<div className="settings-section-header">
<h3>
<Icons.IconShieldCheck size={18} /> Asset Protection &amp; Watermark
</h3>
<p className="settings-section-sub">
Configure image protection rules, right-click prevention, and watermark overlay.
</p>
</div>
<div className="admin-toggle-cards-grid">
<ToggleCard
icon={<Icons.IconLock size={16} />}
title="Disable Right-Click Menu"
description="Prevents context menu on portfolio images to hinder unauthorized downloads"
checked={settings.protection?.disableRightClick === true}
onToggle={() =>
update('protection.disableRightClick', !settings.protection?.disableRightClick)
}
/>
<ToggleCard
icon={<Icons.IconBan size={16} />}
title="Disable Image Dragging"
description="Prevents visitors from dragging images off the portfolio page"
checked={settings.protection?.disableImageDrag === true}
onToggle={() =>
update('protection.disableImageDrag', !settings.protection?.disableImageDrag)
}
/>
</div>
<div className="settings-section-divider" />
<div className="settings-section-header">
<h3>
<Icons.IconSparkles size={18} /> Dynamic Watermark Overlay
</h3>
<p className="settings-section-sub">Overlay copyright branding text on Lightbox images.</p>
</div>
<div className="admin-toggle-cards-grid" style={{ marginBottom: '1.25rem' }}>
<ToggleCard
icon={<Icons.IconFrame size={16} />}
title="Enable Watermark"
description="Overlay copyright text on portfolio image views"
checked={settings.watermark?.enabled === true}
onToggle={() => update('watermark.enabled', !settings.watermark?.enabled)}
/>
</div>
{settings.watermark?.enabled && (
<>
<div className="admin-field">
<label htmlFor="security-watermark-text">Watermark Text</label>
<input
id="security-watermark-text"
value={settings.watermark?.text || ''}
onChange={(e) => update('watermark.text', e.target.value)}
placeholder="© Ralfo Photography"
/>
</div>
<div className="admin-field-row">
<div className="admin-field">
<label htmlFor="security-position">Position</label>
<select
id="security-position"
aria-label="Watermark position"
value={settings.watermark?.position || 'bottom-right'}
onChange={(e) => update('watermark.position', e.target.value)}
>
<option value="bottom-right">Bottom Right</option>
<option value="bottom-left">Bottom Left</option>
<option value="center">Center Overlay</option>
</select>
</div>
<div className="admin-field">
{/* Read through the same normaliser the lightbox uses, so a
config written as a percentage shows as "90%" here rather
than "9000%" — and is written back as a fraction on save. */}
<label htmlFor="security-opacity-100">
Opacity ({Math.round(resolveWatermarkOpacity(settings.watermark?.opacity) * 100)}
%)
</label>
<input
id="security-opacity-100"
type="range"
min="0.1"
max="1"
step="0.05"
value={resolveWatermarkOpacity(settings.watermark?.opacity)}
onChange={(e) => update('watermark.opacity', parseFloat(e.target.value))}
/>
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,141 @@
'use client';
import * as Icons from '../Icons';
import ToggleCard from '../fields/ToggleCard';
import type { SectionProps } from './types';
export default function SeoSection({
settings,
update,
siteUrlInfo,
}: SectionProps & {
/** Resolved site URL and its origin, so the panel can name SITE_URL (#472). */
siteUrlInfo: { effective: string | null; source: 'env' | 'settings' | 'none' } | null;
}) {
return (
<div className="settings-panel">
<div className="settings-section-header">
<h3>
<Icons.IconSearch size={18} /> Search Engine Optimization (SEO)
</h3>
<p className="settings-section-sub">
Customize search engine metadata, OpenGraph tags, and indexing rules.
</p>
</div>
{/* Live Google Search Result Snippet Card */}
<div className="google-snippet-preview">
<div className="google-snippet-header">
<span>
<Icons.IconGlobe size={14} /> Google Search Result Preview
</span>
</div>
<div className="google-snippet-card">
<div className="google-snippet-url">
https://yourportfolio.com <span className="google-snippet-arrow">▼</span>
</div>
<div className="google-snippet-title">
{settings.seo?.title || settings.title || 'My Photography Portfolio'}
</div>
<div className="google-snippet-desc">
{settings.seo?.description ||
settings.subtitle ||
'A curated selection of photography work.'}
</div>
</div>
</div>
<div className="admin-field">
<label htmlFor="seo-site-url">Site URL</label>
<input
id="seo-site-url"
value={settings.url || ''}
onChange={(e) => update('url', e.target.value)}
placeholder="https://folio.example"
/>
<p style={{ fontSize: '0.8rem', opacity: 0.7, marginTop: '4px' }}>
Needed for <code>sitemap.xml</code>, the feed and structured data — those are generated
without a request, so the address cannot be derived from it. Leave empty and the sitemap
stays empty rather than guessing.
{siteUrlInfo?.source === 'env' && (
<>
<br />
Currently supplied by the <code>SITE_URL</code> environment variable (
<code>{siteUrlInfo.effective}</code>). A value entered here takes precedence once
saved.
</>
)}
</p>
</div>
<div className="admin-field">
<label htmlFor="seo-meta-title">SEO Meta Title</label>
<input
id="seo-meta-title"
value={settings.seo?.title || ''}
onChange={(e) => update('seo.title', e.target.value)}
placeholder="Overrides default site title for Google search results"
/>
</div>
<div className="admin-field">
<label htmlFor="seo-subpage-title-template">Subpage Title Template</label>
<input
id="seo-subpage-title-template"
value={settings.seo?.titleTemplate || ''}
onChange={(e) => update('seo.titleTemplate', e.target.value)}
placeholder={`%s | ${settings.seo?.title || settings.title || 'My Portfolio'}`}
/>
<p style={{ fontSize: '0.8rem', opacity: 0.7, marginTop: '4px' }}>
Template for subpages &amp; albums. Use <code>%s</code> as placeholder for the page title.
<br />
<strong>Preview:</strong>{' '}
{(
settings.seo?.titleTemplate ||
`%s | ${settings.seo?.title || settings.title || 'My Portfolio'}`
).replace('%s', 'Landscapes')}
</p>
</div>
<div className="admin-field">
<label htmlFor="seo-meta-description">SEO Meta Description</label>
<textarea
id="seo-meta-description"
value={settings.seo?.description || ''}
onChange={(e) => update('seo.description', e.target.value)}
placeholder="A curated selection of photography work..."
rows={3}
/>
</div>
<div className="settings-section-divider" />
<div className="settings-section-header">
<h3>
<Icons.IconGlobe size={18} /> Search Crawler Directives
</h3>
<p className="settings-section-sub">
Control how Googlebot and other web crawlers index your site.
</p>
</div>
<div className="admin-toggle-cards-grid">
<ToggleCard
icon={<Icons.IconBan size={16} />}
title="noindex (Hide from Google)"
description="Instructs search engines NOT to index this site in search results"
checked={settings.seo?.noIndex === true}
onToggle={() => update('seo.noIndex', !settings.seo?.noIndex)}
/>
<ToggleCard
icon={<Icons.IconLink size={16} />}
title="nofollow (Block Link Following)"
description="Instructs search engine crawlers not to follow outgoing links"
checked={settings.seo?.noFollow === true}
onToggle={() => update('seo.noFollow', !settings.seo?.noFollow)}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,495 @@
'use client';
import { useState, type CSSProperties } from 'react';
import * as Icons from '../Icons';
import OptionGrid, { toOptions } from '../fields/OptionGrid';
import { DEFAULT_PRESET } from '@/lib/config/theme';
import { effectiveTheme, FeatureRow } from './fields';
import type { SectionProps } from './types';
const PRESETS = ['studio-modern', 'studio', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
const PHOTO_FRAMES = ['none', 'passepartout', 'shadow'];
const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic', 'cover'];
const PHOTO_FRAME_INFO: Record<string, { label: string; desc: string }> = {
none: { label: 'None', desc: 'Flush image with crisp edges' },
passepartout: {
label: 'Passepartout',
desc: 'Classic gallery matting border',
},
shadow: { label: 'Shadow', desc: 'Soft floating drop shadow' },
};
const PHOTO_FRAME_OPTIONS = toOptions(PHOTO_FRAMES, PHOTO_FRAME_INFO);
function PhotoFramePreview({ value }: { value: string }) {
return (
<div className="frame-card-preview">
<div className={`mini-frame-demo frame-${value}`}>
<div className="mini-frame-photo" />
</div>
</div>
);
}
const HERO_STYLE_INFO: Record<string, { label: string; desc: string }> = {
split: { label: 'Split', desc: 'Side-by-side title & photo' },
fullbleed: { label: 'Fullbleed', desc: 'Edge-to-edge full width banner' },
minimal: { label: 'Minimal', desc: 'Centered title with subtle photo' },
stacked: { label: 'Stacked', desc: 'Title stacked directly over photo' },
typographic: { label: 'Typographic', desc: 'Oversized magazine masthead' },
mosaic: { label: 'Mosaic', desc: 'Dynamic photo collage layout' },
cover: {
label: 'Cover (Experimental)',
desc: 'Fullscreen splash with a single Enter link',
},
};
const HERO_STYLE_OPTIONS = toOptions(HERO_STYLES, HERO_STYLE_INFO);
function HeroStylePreview({ value }: { value: string }) {
return (
<div className="hero-card-preview">
<div className={`mini-hero-demo hero-demo-${value}`}>
{value === 'split' && (
<>
<div className="hero-demo-text">
<div className="demo-line title" />
<div className="demo-line sub" />
</div>
<div className="hero-demo-photo" />
</>
)}
{value === 'fullbleed' && (
<div className="hero-demo-full">
<div className="demo-line title light" />
</div>
)}
{value === 'minimal' && (
<>
<div className="hero-demo-center-text">
<div className="demo-line title short" />
</div>
<div className="hero-demo-photo small" />
</>
)}
{value === 'stacked' && (
<>
<div className="hero-demo-text">
<div className="demo-line title" />
</div>
<div className="hero-demo-photo banner" />
</>
)}
{value === 'typographic' && (
<>
<div className="demo-line title giant" />
<div className="hero-demo-grid2">
<div className="hero-demo-photo" />
<div className="hero-demo-photo" />
</div>
</>
)}
{value === 'mosaic' && (
<div className="hero-demo-mosaic">
<div className="hero-demo-photo big" />
<div className="hero-demo-photo-col">
<div className="hero-demo-photo" />
<div className="hero-demo-photo" />
</div>
</div>
)}
</div>
</div>
);
}
/**
* Card metadata for the theme picker. `font`, `radius` and `frame` mirror the
* real preset definitions in lib/config/theme.ts so the mini mockups show what
* the preset actually does; `gap` is the visual density of its gallery grid.
*/
const THEME_INFO: Record<
string,
{
desc: string;
label: string;
accent: string;
bg: string;
tile: string;
font: string;
type: 'serif' | 'sans' | 'mono';
radius: number;
frame: 'none' | 'passepartout' | 'shadow';
gap: number;
}
> = {
studio: {
label: 'Studio',
desc: 'Clean, high-contrast grid with sans-serif type.',
bg: '#141414',
tile: '#242424',
accent: '#e60012',
font: 'Playfair Display',
type: 'serif',
radius: 0,
frame: 'passepartout',
gap: 4,
},
'studio-modern': {
label: 'Studio Modern',
desc: 'Leica precision: Archivo grotesque, mono EXIF, red signal accents.',
bg: '#121212',
tile: '#191919',
accent: '#e60012',
font: 'Archivo',
type: 'sans',
radius: 0,
frame: 'none',
gap: 3,
},
minimal: {
label: 'Minimal',
desc: 'Pure raw layouts with tiny gaps and high whitespace.',
bg: '#ffffff',
tile: '#f3f3f3',
accent: '#111111',
font: 'Geist',
type: 'sans',
radius: 0,
frame: 'none',
gap: 2,
},
editorial: {
label: 'Editorial',
desc: 'Warm backgrounds, elegant serifs and large headers.',
bg: '#fbf9f4',
tile: '#e5dfd4',
accent: '#b89053',
font: 'Bodoni Moda',
type: 'serif',
radius: 0,
frame: 'shadow',
gap: 8,
},
classic: {
label: 'Classic',
desc: 'Soft traditional photographer portfolio with shadows.',
bg: '#f7f7f7',
tile: '#ffffff',
accent: '#444444',
font: 'Cinzel',
type: 'serif',
radius: 12,
frame: 'passepartout',
gap: 7,
},
noir: {
label: 'Noir',
desc: 'High drama absolute pitch black, stark high-fashion look.',
bg: '#000000',
tile: '#151515',
accent: '#ffffff',
font: 'Libre Baskerville',
type: 'serif',
radius: 0,
frame: 'passepartout',
gap: 6,
},
monograph: {
label: 'Monograph',
desc: 'Typewriter monospace font, grid borders and document feel.',
bg: '#f4f4f6',
tile: '#ffffff',
accent: '#555555',
font: 'Instrument Serif',
type: 'mono',
radius: 0,
frame: 'none',
gap: 5,
},
};
/* The preset group sits below THEME_INFO on purpose. PRESET_OPTIONS is built
at module evaluation and reads it through themeInfo(); declared above, it
hit the temporal dead zone and the whole Settings page failed to load with
"Cannot access 'THEME_INFO' before initialization". tsc does not catch it
because the read goes through a hoisted function, and a production build
succeeds because the module is only evaluated in the browser (#542). */
/** The theme picker's own metadata, with the fallback the card grid needs. */
function themeInfo(value: string) {
return (
THEME_INFO[value] || {
label: value,
desc: '',
bg: '#fff',
tile: '#eee',
accent: '#333',
font: 'System',
type: 'sans' as const,
radius: 0,
frame: 'none' as const,
gap: 4,
}
);
}
const PRESET_OPTIONS = PRESETS.map((value) => ({
value,
label: themeInfo(value).label,
desc: themeInfo(value).desc,
}));
function PresetPreview({ value }: { value: string }) {
const i = themeInfo(value);
return (
<div className="preset-card-preview" data-frame={i.frame}>
<div className="mini-header">
<span className={`mini-specimen type-${i.type}`}>Aa</span>
<span className="mini-line" />
</div>
<div className="mini-grid">
<div className="mini-tile" />
<div className="mini-tile" />
<div className="mini-tile" />
</div>
</div>
);
}
function PresetSpecs({ value }: { value: string }) {
const i = themeInfo(value);
return (
<span className="preset-card-specs">
{i.font} &middot; radius {i.radius} &middot; {i.frame}
</span>
);
}
export default function ThemeSection({ settings, update }: SectionProps) {
// Read off the page on mount: the toggle previews light and dark on the
// admin itself, and the page may already be in either.
const [currentMode, setCurrentMode] = useState<'dark' | 'light'>(() =>
typeof document !== 'undefined' &&
document.documentElement.getAttribute('data-theme') === 'light'
? 'light'
: 'dark',
);
const toggleMode = (mode: 'dark' | 'light') => {
setCurrentMode(mode);
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('data-theme', mode);
localStorage.setItem('theme', mode);
}
};
// Same idea for the theme: an unset field shows the preset's own value, not
// a fallback of this form's — Classic used to read "split hero, no frame".
const theme = effectiveTheme(settings.theme);
return (
<div className="settings-panel theme-settings-panel">
<div className="settings-section-header">
<h3>
<Icons.IconPalette size={18} /> Theme Presets &amp; Color Mode
</h3>
<p className="settings-section-sub">
Choose a typography preset and preview in Light or Dark mode.
</p>
</div>
<div className="admin-field">
<label htmlFor="theme-visitor-default-mode">Visitor Default Mode</label>
<select
id="theme-visitor-default-mode"
aria-label="Visitor default mode"
value={settings.mode || 'dark'}
onChange={(e) => update('mode', e.target.value)}
>
<option value="dark">Dark</option>
<option value="light">Light</option>
<option value="auto">Follow the visitor&apos;s system</option>
</select>
<p className="admin-field-hint">
What a first-time visitor sees. The light/dark toggle in the site header still lets them
choose, and their choice is remembered on their device. The preview below is your own, and
does not change this.
</p>
</div>
<OptionGrid
label="Preset"
options={PRESET_OPTIONS}
value={settings.theme?.preset || DEFAULT_PRESET}
onSelect={(v) => update('theme.preset', v)}
cardClassName="theme-preset-card"
renderPreview={(o) => <PresetPreview value={o.value} />}
renderSpecs={(o) => <PresetSpecs value={o.value} />}
cardStyle={(o) => {
const i = themeInfo(o.value);
return {
'--preset-accent': i.accent,
'--preset-bg': i.bg,
'--preset-tile': i.tile,
'--preset-radius': `${i.radius}px`,
'--preset-gap': `${i.gap}px`,
} as CSSProperties;
}}
/>
<div className="admin-field">
<span className="admin-field-label">Color Mode</span>
<p
style={{
fontSize: '0.78rem',
color: 'var(--admin-text-muted)',
margin: '0.25rem 0 0.6rem',
lineHeight: '1.4',
}}
>
Presets like <strong>Editorial</strong>, <strong>Minimal</strong> &amp;{' '}
<strong>Classic</strong> feature warm light/cream backgrounds in Light Mode and charcoal
in Dark Mode.
</p>
<div className="segmented-control">
<button
type="button"
className={`segment-btn ${currentMode === 'light' ? 'active' : ''}`}
onClick={() => toggleMode('light')}
>
<Icons.IconSun size={14} /> Light Mode (Cream / Beige)
</button>
<button
type="button"
className={`segment-btn ${currentMode === 'dark' ? 'active' : ''}`}
onClick={() => toggleMode('dark')}
>
<Icons.IconMoon size={14} /> Dark Mode (Charcoal)
</button>
</div>
</div>
<div className="settings-section-divider" />
<div className="settings-section-header">
<h3>
<Icons.IconTarget size={18} /> Accent Color
</h3>
<p className="settings-section-sub">
Pick a primary accent color for links, buttons, and highlights.
</p>
</div>
<div className="admin-field">
<div className="accent-picker-wrapper">
<div className="color-swatches-row">
{[
{ hex: '#e60012', name: 'Studio Red' },
{ hex: '#b89053', name: 'Editorial Gold' },
{ hex: '#10b981', name: 'Emerald' },
{ hex: '#3b82f6', name: 'Sapphire' },
{ hex: '#8b5cf6', name: 'Violet' },
{ hex: '#ffffff', name: 'Monochrome White' },
{ hex: '#000000', name: 'Obsidian Black' },
].map((swatch) => {
const isSelected = theme.accent.toLowerCase() === swatch.hex.toLowerCase();
return (
<button
key={swatch.hex}
type="button"
className={`color-swatch-btn ${isSelected ? 'active' : ''}`}
style={{ backgroundColor: swatch.hex }}
onClick={() => update('theme.accent', swatch.hex)}
title={swatch.name}
/>
);
})}
</div>
<div className="color-field">
<input
type="color"
aria-label="Accent colour"
value={theme.accent}
onChange={(e) => update('theme.accent', e.target.value)}
/>
<input
aria-label="Accent colour (hex)"
type="text"
value={settings.theme?.accent || ''}
onChange={(e) => update('theme.accent', e.target.value)}
placeholder={theme.accent}
/>
</div>
</div>
</div>
<div className="settings-section-divider" />
<div className="settings-section-header">
<h3>
<Icons.IconFrame size={18} /> Photo Frame &amp; Layout
</h3>
<p className="settings-section-sub">
Customize image presentation borders and hero layouts.
</p>
</div>
<OptionGrid
label="Photo Frame"
options={PHOTO_FRAME_OPTIONS}
value={theme.photoFrame}
onSelect={(v) => update('theme.photoFrame', v)}
cardClassName="frame-card"
renderPreview={(o) => <PhotoFramePreview value={o.value} />}
/>
<OptionGrid
label="Hero Style"
options={HERO_STYLE_OPTIONS}
value={theme.heroStyle}
onSelect={(v) => update('theme.heroStyle', v)}
cardClassName="hero-card"
renderPreview={(o) => <HeroStylePreview value={o.value} />}
/>
<div className="settings-section-divider" />
<div className="settings-section-header">
<h3>
<Icons.IconSparkles size={18} /> Look &amp; motion
</h3>
<p className="settings-section-sub">Effects and small controls on every page.</p>
</div>
<div className="feature-list">
<FeatureRow
icon={<Icons.IconSparkles size={15} />}
title="Page transitions"
description="A short fade between pages"
checked={settings.transitions !== false}
onToggle={() => update('transitions', settings.transitions === false)}
/>
<FeatureRow
icon={<Icons.IconArrowUp size={15} />}
title="Scroll-to-top button"
description="A floating arrow on long pages"
checked={settings.scrollToTop !== false}
onToggle={() => update('scrollToTop', settings.scrollToTop === false)}
/>
<FeatureRow
icon={<Icons.IconFilm size={15} />}
title="Film grain"
description="Analog noise over the background"
checked={theme.grain}
onToggle={() => update('theme.grain', !theme.grain)}
/>
<FeatureRow
icon={<Icons.IconTarget size={15} />}
title="Header accent dot"
description="A dot beside the active section heading"
checked={theme.headerDot}
onToggle={() => update('theme.headerDot', !theme.headerDot)}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,154 @@
'use client';
import type { ReactNode } from 'react';
import Link from 'next/link';
import * as Icons from '../Icons';
// Direct import from the theme module, not from '@/lib/config': the config
// index pulls in `fs` and cannot be bundled into a client component.
import { resolveTheme } from '@/lib/config/theme';
import type { SettingsYaml } from '@/lib/config/schema';
import type { Settings } from './types';
/**
* One labelled block of related switches.
*
* The features panel was a single flat grid of identical cards, so nothing said
* which switches belonged together — and a switch that publishes private notes
* looked exactly like one that controls a fade (#510).
*/
export function FeatureGroup({
icon,
title,
description,
chip,
children,
}: {
icon: ReactNode;
title: string;
description: string;
/** Short marker for a group that needs its own weight, e.g. what visitors see. */
chip?: string;
children: ReactNode;
}) {
return (
<div className="settings-group">
<div className="settings-group-head">
<span className="settings-group-title">
{icon}
{title}
{chip && <span className="settings-group-chip">{chip}</span>}
</span>
<span className="settings-group-desc">{description}</span>
</div>
{children}
</div>
);
}
/**
* A switch inside a group panel. Deliberately lighter than a toggle card: these
* are settings *of* the panel they sit in, and rendering them as peers of the
* top-level cards is what hid the structure in the first place.
*/
export function SettingRow({
title,
description,
checked,
onToggle,
disabled,
hint,
indented,
}: {
title: string;
description: string;
checked: boolean;
onToggle: () => void;
/** A row whose parent switch is off — shown, but inert and explained. */
disabled?: boolean;
hint?: string;
indented?: boolean;
}) {
return (
<button
type="button"
className={`setting-row${indented ? ' setting-row--indented' : ''}${
disabled ? ' setting-row--disabled' : ''
}`}
onClick={onToggle}
disabled={disabled}
aria-pressed={checked}
>
<span className="setting-row-info">
<span className="setting-row-title">{title}</span>
<span className="setting-row-desc">{description}</span>
{hint && (
<span className="setting-row-hint">
<Icons.IconBan size={11} /> {hint}
</span>
)}
</span>
<span className={`switch-toggle switch-toggle--sm ${checked && !disabled ? 'on' : ''}`}>
<span className="switch-slider" />
</span>
</button>
);
}
/**
* One switch in a feature list: a single dense line instead of a card. Cards
* gave every switch the same weight, so a fade and a whole page looked alike;
* rows grouped by meaning let the group heading carry that weight instead.
* The optional link sits beside the switch, not inside it — a link nested in
* a button is invalid and swallows the click.
*/
export function FeatureRow({
icon,
title,
description,
checked,
onToggle,
href,
}: {
icon: ReactNode;
title: string;
description: string;
checked: boolean;
onToggle: () => void;
/** Where this feature is configured beyond on/off. */
href?: string;
}) {
return (
<div className="feature-row">
<button type="button" className="feature-row-main" onClick={onToggle} aria-pressed={checked}>
<span className="feature-row-icon">{icon}</span>
<span className="feature-row-info">
<span className="feature-row-title">{title}</span>
<span className="feature-row-desc">{description}</span>
</span>
<span className={`switch-toggle ${checked ? 'on' : ''}`}>
<span className="switch-slider" />
</span>
</button>
{/* Reserved even without a link, so every switch lines up in one column. */}
{href ? (
<Link href={href} className="feature-row-link">
Configure →
</Link>
) : (
<span className="feature-row-link" aria-hidden="true" />
)}
</div>
);
}
/**
* The theme as the site resolves it. A preset name the form does not know (a
* hand-edited typo) falls back to the default instead of taking the panel down.
*/
export function effectiveTheme(raw: Settings['theme']) {
try {
return resolveTheme(raw as SettingsYaml['theme']);
} catch {
return resolveTheme(undefined);
}
}

View File

@@ -0,0 +1,95 @@
/**
* Shared shape of the settings form (#554). The sections edit one object
* that mirrors settings.yaml; SettingsEditor owns it and hands each section
* the object plus the two ways to change it.
*/
export interface Settings {
title?: string;
subtitle?: string;
/** Absolute site URL for sitemap, feed and structured data (#472). */
url?: string;
lang?: string;
sitePassword?: string;
mode?: 'light' | 'dark' | 'auto';
exifOnHover?: boolean;
exif?: {
camera?: boolean;
settings?: boolean;
location?: boolean;
caption?: boolean;
};
map?: boolean;
transitions?: boolean;
scrollToTop?: boolean;
analytics?: boolean;
theme?: {
preset?: string;
accent?: string;
photoFrame?: string;
grain?: boolean;
headerDot?: boolean;
heroStyle?: string;
};
grid?: {
columns?: number;
gap?: number;
aspectRatio?: string;
layout?: string;
};
footer?: {
name?: string;
instagram?: string;
email?: string;
website?: string;
};
/** EXPERIMENTAL: external links appended to the header navigation */
navLinks?: Array<{ label?: string; url?: string }>;
contact?: { enabled?: boolean; notifyUrl?: string; retentionDays?: number };
privacy?: { enabled?: boolean };
legal?: {
enabled?: boolean;
heading?: string;
name?: string;
address?: string;
zipCity?: string;
country?: string;
email?: string;
phone?: string;
contactUrl?: string;
contactLabel?: string;
taxId?: string;
vatId?: string;
extraInfo?: string;
};
seo?: {
title?: string;
description?: string;
titleTemplate?: string;
noIndex?: boolean;
noFollow?: boolean;
};
protection?: {
disableRightClick?: boolean;
disableImageDrag?: boolean;
};
proofing?: {
enabled?: boolean;
allowMailto?: boolean;
};
watermark?: {
enabled?: boolean;
text?: string;
opacity?: number;
position?: 'bottom-right' | 'bottom-left' | 'center';
};
about?: { enabled?: boolean };
}
export interface SectionProps {
settings: Settings;
/** Set one dotted path, e.g. `theme.accent`. An empty value removes the key. */
update: (path: string, value: unknown) => void;
/** Set several paths in one state update. */
updateMany: (entries: Record<string, unknown>) => void;
}

View File

@@ -0,0 +1,85 @@
'use client';
/**
* Loading, data and a typed error for an admin screen (#609).
*
* Every screen used to write this by hand: set loading, fetch, check res.ok,
* set data, catch, set error, clear loading. The copies disagreed on errors:
* `err: any` with `err.message`, a silent `catch {}`, a boolean flag. A silent
* catch is how a failed load turns into an empty form. Here a failure is
* always a message, and a 401 always goes through reportIfSessionExpired.
*
* Pair it with <AdminLoadState> for the spinner and the error-with-retry.
*/
import { useCallback, useEffect, useState } from 'react';
import { reportIfSessionExpired } from './sessionExpiry';
export type AdminResult<T> = { data: T; error: null } | { data: null; error: string };
/** One GET, with the admin's error conventions. Usable outside the hook too. */
export async function adminGet<T>(url: string): Promise<AdminResult<T>> {
try {
const res = await fetch(url);
if (!res.ok) {
if (reportIfSessionExpired(res)) {
return { data: null, error: 'Your session expired. Sign in again.' };
}
const body = (await res.json().catch(() => null)) as { error?: unknown } | null;
const detail = typeof body?.error === 'string' ? `: ${body.error}` : '';
return { data: null, error: `Could not load (HTTP ${res.status})${detail}.` };
}
return { data: (await res.json()) as T, error: null };
} catch {
return { data: null, error: 'Could not reach the server.' };
}
}
export interface AdminFetch<T> {
data: T | null;
/** Only the latest attempt's error; cleared while a reload is in flight. */
error: string | null;
/** True until the current URL has answered, including after reload(). */
loading: boolean;
reload: () => void;
/** Local edit after a successful mutation, without a round trip. */
mutate: (update: (data: T) => T) => void;
}
/**
* `url === null` fetches nothing (a closed modal, say). Data from the previous
* answer stays visible while a reload runs.
*/
export function useAdminFetch<T>(url: string | null): AdminFetch<T> {
const [attempt, setAttempt] = useState(0);
const key = url === null ? null : `${attempt}:${url}`;
const [result, setResult] = useState<(AdminResult<T> & { key: string }) | null>(null);
useEffect(() => {
if (key === null || url === null) return;
let live = true;
// State is set in the callback, never synchronously in the effect.
adminGet<T>(url).then((r) => {
if (live) setResult({ ...r, key });
});
return () => {
live = false;
};
}, [key, url]);
const loading = key !== null && result?.key !== key;
const reload = useCallback(() => setAttempt((n) => n + 1), []);
const mutate = useCallback((update: (data: T) => T) => {
setResult((r) =>
r && r.error === null ? { key: r.key, data: update(r.data), error: null } : r,
);
}, []);
return {
data: result?.data ?? null,
error: loading ? null : (result?.error ?? null),
loading,
reload,
mutate,
};
}

View File

@@ -46,7 +46,35 @@ export function readDraft<T>(key: string): Draft<T> | null {
}
}
/**
* True when a draft holds a password as typed. Passwords are stored as scrypt
* hashes (#706), but between typing and saving the editor holds the plaintext,
* and sessionStorage is readable by any script on the admin origin. Such a
* draft is not kept: the unsaved-changes guard still warns before leaving, and
* the password is typed again after a reload.
*/
export function holdsPlaintextPassword(value: unknown): boolean {
if (typeof value === 'string') {
// Journal markdown: a `password:` line in the frontmatter.
const fm = /^---\n([\s\S]*?)\n---/.exec(value)?.[1] ?? '';
return /^password:[ \t]*(?!['"]?scrypt:)['"]?\S/m.test(fm);
}
if (Array.isArray(value)) return value.some(holdsPlaintextPassword);
if (value && typeof value === 'object') {
return Object.entries(value).some(([k, v]) =>
/^(?:password|sitePassword)$/.test(k) && typeof v === 'string'
? v !== '' && !v.startsWith('scrypt:')
: holdsPlaintextPassword(v),
);
}
return false;
}
export function writeDraft<T>(key: string, draft: Draft<T>): void {
if (holdsPlaintextPassword(draft.value)) {
clearDraft(key);
return;
}
try {
window.sessionStorage.setItem(PREFIX + key, JSON.stringify(draft));
} catch {
@@ -73,13 +101,19 @@ export type DraftStatus = 'none' | 'restored' | 'conflict';
export function useDraft<T>(key: string, value: T, dirty: boolean) {
/** The server fingerprint the current edits started from; null until loaded. */
const baseRef = useRef<string | null>(null);
/**
* True while a `conflict` waits for an answer. The editor shows the server
* state meanwhile — not dirty — and without this hold the effect below would
* clear the very draft "Restore my changes" is about to read.
*/
const holdRef = useRef(false);
const [status, setStatus] = useState<DraftStatus>('none');
// Mirror the edits while there are any, and drop the draft once there are
// none — a save or a discard. Nothing is written before `load()` has set a
// base, so the empty state an editor mounts with never lands in storage.
useEffect(() => {
if (baseRef.current === null) return;
if (baseRef.current === null || holdRef.current) return;
if (dirty) writeDraft(key, { base: baseRef.current, value });
else clearDraft(key);
}, [key, value, dirty]);
@@ -91,6 +125,7 @@ export function useDraft<T>(key: string, value: T, dirty: boolean) {
const load = useCallback(
(base: string): T | null => {
baseRef.current = base;
holdRef.current = false;
const draft = readDraft<T>(key);
if (!draft) {
setStatus('none');
@@ -100,6 +135,7 @@ export function useDraft<T>(key: string, value: T, dirty: boolean) {
setStatus('restored');
return draft.value;
}
holdRef.current = true;
setStatus('conflict');
return null;
},
@@ -110,6 +146,7 @@ export function useDraft<T>(key: string, value: T, dirty: boolean) {
const saved = useCallback(
(base: string) => {
baseRef.current = base;
holdRef.current = false;
clearDraft(key);
setStatus('none');
},
@@ -118,6 +155,7 @@ export function useDraft<T>(key: string, value: T, dirty: boolean) {
/** The held-back draft of a `conflict`, for "restore anyway". */
const takeConflicting = useCallback((): T | null => {
holdRef.current = false;
const draft = readDraft<T>(key);
setStatus(draft ? 'restored' : 'none');
return draft?.value ?? null;
@@ -125,6 +163,7 @@ export function useDraft<T>(key: string, value: T, dirty: boolean) {
/** Forget the draft. The editor reloads the server state itself. */
const discard = useCallback(() => {
holdRef.current = false;
clearDraft(key);
setStatus('none');
}, [key]);