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:
138
app/admin/components/page-builder/AlbumCard.tsx
Normal file
138
app/admin/components/page-builder/AlbumCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
250
app/admin/components/page-builder/AlbumDrawer.tsx
Normal file
250
app/admin/components/page-builder/AlbumDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
576
app/admin/components/page-builder/SubpageDrawer.tsx
Normal file
576
app/admin/components/page-builder/SubpageDrawer.tsx
Normal 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 & 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'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)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
77
app/admin/components/page-builder/SubpagePreview.tsx
Normal file
77
app/admin/components/page-builder/SubpagePreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
app/admin/components/page-builder/sortOptions.tsx
Normal file
43
app/admin/components/page-builder/sortOptions.tsx
Normal 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],
|
||||
}),
|
||||
);
|
||||
106
app/admin/components/page-builder/types.ts
Normal file
106
app/admin/components/page-builder/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user