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

@@ -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;
}