620 lines
25 KiB
TypeScript
620 lines
25 KiB
TypeScript
'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 & 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'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 › 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 & 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>
|
||
);
|
||
}
|