release: v0.14.0

Fourteen commits since v0.13.0. See CHANGELOG.md for the full list.

Two additions: `npm run doctor`, which runs the config doctor from a terminal — including inside the shipped image — and exits with the worst finding, so it can gate a deployment script; and a Playwright spec that loads all fourteen admin routes against a production build.

One change, invisible on screen: the admin panel got a form-field layer (#533).

Five fixes: grid.gap on both axes (#513), the dashboard status badge (#539), the journal editor's unsaved-changes guard (#538), essay paragraph markup (#537), and aria-pressed on the panel's switches and pickers.
This commit is contained in:
Ralksta
2026-08-30 15:11:25 +02:00
committed by GitHub
parent a813433891
commit e99d3f2f1b
59 changed files with 8652 additions and 6676 deletions

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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