Files
immich-folio/app/admin/components/page-builder/SubpageDrawer.tsx
2026-10-02 18:14:23 +02:00

620 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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,
IconPencil,
IconSearch,
IconTrash,
IconX,
} from '../Icons';
import AlbumCard, { SortableAlbumCard } from './AlbumCard';
import SubpagePreview from './SubpagePreview';
import PasswordField from '../fields/PasswordField';
import ZoomOverrideField from '../fields/ZoomOverrideField';
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;
/** 1-based position among the enabled subpages; undefined while disabled. */
kickerIndex?: 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;
/** 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;
}
/**
* 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,
kickerIndex,
immichAlbums,
sensors,
drawerMode,
onDrawerModeChange,
onClose,
updateSubpage,
removeSubpage,
addSection,
removeSection,
updateSection,
removeSubpageAlbum,
removeSectionAlbum,
onAlbumDragEnd,
onPickAlbum,
onEditAlbum,
onPickHero,
getAlbumName,
getAlbumCount,
getAlbumThumbnailId,
highlightedAlbumId,
inline = false,
}: SubpageDrawerProps) {
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 segmented-control--sm" role="group" aria-label="View">
<button
type="button"
className={`segment-btn ${drawerMode === 'edit' ? 'active' : ''}`}
aria-pressed={drawerMode === 'edit'}
onClick={() => onDrawerModeChange('edit')}
>
<IconPencil size={14} /> Edit
</button>
<button
type="button"
className={`segment-btn ${drawerMode === 'preview' ? 'active' : ''}`}
aria-pressed={drawerMode === 'preview'}
onClick={() => onDrawerModeChange('preview')}
>
<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 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 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 })
}
// Short enough for the half-width column at any size; the
// old "Display title (defaults to name)" was cut off (A-18).
placeholder="Same as page name"
/>
</div>
<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 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>
{/* Zoom belongs to the album viewer; a photo essay has none. */}
{sp.grid?.layout !== 'essay' && sp.essayText == null && !sp.essayFile && (
<div style={{ marginTop: '1rem' }}>
<ZoomOverrideField
id="subpage-zoom"
value={sp.zoom}
onChange={(zoom) => updateSubpage(spIndex, { zoom })}
inheritLabel="Inherit from site settings"
hint="Applies to every album on this page that does not set its own. Settings › General has the site-wide switch."
/>
</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)}
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"
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>
{!inline && (
<button className="admin-btn admin-btn-primary admin-btn-sm" onClick={() => onClose()}>
Done
</button>
)}
</div>
</div>
);
if (inline) return sheet;
return (
<div className="subpage-drawer-backdrop" onClick={() => onClose()}>
{sheet}
</div>
);
}