'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[]; drawerMode: 'edit' | 'preview'; onDrawerModeChange: (mode: 'edit' | 'preview') => void; onClose: () => void; updateSubpage: (index: number, updates: Partial) => void; removeSubpage: (index: number) => void; addSection: (subpageIndex: number) => void; removeSection: (subpageIndex: number, sectionIndex: number) => void; updateSection: (subpageIndex: number, sectionIndex: number, updates: Partial
) => 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 (
onClose()}>
e.stopPropagation()}>
Pages / Subpages

{sp.title || sp.name || 'Untitled Page'}

/{slugify(sp.name)} ↗
{drawerMode === 'preview' ? ( ) : (
updateSubpage(spIndex, { name: e.target.value })} placeholder="e.g. dubai, travel, weddings" /> /{slugify(sp.name)}
{/* Subpage metadata */}
updateSubpage(spIndex, { title: e.target.value || undefined }) } placeholder="Display title (defaults to name)" />
updateSubpage(spIndex, { subtitle: e.target.value || undefined, }) } placeholder="Subtitle text" />
{/* 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: , 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: , 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: , iconColor: '#f87171', title: 'Disabled', desc: 'Completely offline — returns 404 even when accessed directly', patch: { enabled: false, hidden: false }, }, ] as const ).map((opt) => ( ))} {/* 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. */}
updateSubpage(spIndex, { password: e.target.value || undefined, }) } placeholder="Password protection (optional) — leave empty for public access" disabled={sp.enabled === false} />
{/* 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 && ( 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'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 && ( How the photos inside every album on this page are laid out. Leave both empty to follow Settings › 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) }) } /> )}
{/* Visual Photo Essay Builder (if layout === 'essay') */} {(sp.grid?.layout === 'essay' || sp.essayText != null) && (

Storytelling & Photo Essay Builder

Compose text, quotes, and fullbleed/wide photos visually.

updateSubpage(spIndex, { essayText: newMarkdown })} onSelectPhoto={(callback) => { onPickHero({ albumId: undefined, title: 'Select Photo for Photo Essay', onSelect: (assetId) => { callback(assetId); onPickHero(null); }, }); }} />
)}
{/* Albums (if no sections) with DnD */} {(!sp.sections || sp.sections.length === 0) && (
Albums in this Page
`album-${a.id}-${i}`)} strategy={verticalListSortingStrategy} >
{sp.albums.length === 0 && (

No albums added yet. Click + Add Album to pick from Immich.

)} {sp.albums.map((album, aIndex) => ( removeSubpageAlbum(spIndex, aIndex)} onEdit={() => onEditAlbum({ type: 'subpage', subpageIndex: spIndex, albumIndex: aIndex, }) } /> ))}
)} {/* Sections */} {sp.sections && sp.sections.length > 0 && (
{sp.sections.map((sec, secIndex) => (
updateSection(spIndex, secIndex, { title: e.target.value, }) } placeholder="Section title" />
updateSection(spIndex, secIndex, { description: e.target.value || undefined, }) } placeholder="Section description (optional)" className="section-desc-input" />
{sec.albums.map((album, aIndex) => ( removeSectionAlbum(spIndex, secIndex, aIndex)} onEdit={() => onEditAlbum({ type: 'section', subpageIndex: spIndex, sectionIndex: secIndex, albumIndex: aIndex, }) } /> ))}
))}
)}
{sp.sections && sp.sections.length > 0 && ( )}
)}
); }