🛡️ Sentinel: [HIGH] Fix timingSafeEqual DoS Vulnerability (#423)

* Fix timingSafeEqual DoS Vulnerability in Webhook

Co-authored-by: ralksta <16941050+ralksta@users.noreply.github.com>

* Fix memory exhaustion DoS in token decoding

Co-authored-by: ralksta <16941050+ralksta@users.noreply.github.com>

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
This commit is contained in:
Ralksta
2026-08-14 11:10:42 +02:00
committed by GitHub
parent f120f05157
commit d4d8c8b42f
20 changed files with 1574 additions and 221 deletions

View File

@@ -0,0 +1,277 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
rectSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
interface AssetInfo {
id: string;
type: string;
originalFileName: string;
fileCreatedAt: string;
}
interface Props {
albumId: string;
albumName: string;
/** Currently pinned asset IDs, in display order. */
assetOrder: string[];
onSave: (assetOrder: string[]) => void;
onClose: () => void;
}
function SortableAssetTile({
asset,
index,
onUnpin,
}: {
asset: AssetInfo;
index: number;
onUnpin: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: asset.id,
});
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
}}
className="order-tile order-tile-pinned"
title={asset.originalFileName}
{...attributes}
{...listeners}
>
<img src={`/api/admin/thumbnail/${asset.id}`} alt={asset.originalFileName} loading="lazy" />
<span className="order-tile-index">{index + 1}</span>
<button
className="order-tile-action"
title="Unpin — let this photo follow automatically"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onUnpin();
}}
>
×
</button>
</div>
);
}
/**
* Editor for `sort: manual`.
*
* `manual` is a pinned *prefix*: the saved list holds only the photos placed by
* hand, and everything else follows in the album's Immich order. The two zones
* exist to make that visible rather than something the owner has to be told.
*/
export default function AssetOrderEditor({
albumId,
albumName,
assetOrder,
onSave,
onClose,
}: Props) {
const [assets, setAssets] = useState<AssetInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [pinnedIds, setPinnedIds] = useState<string[]>([]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
useEffect(() => {
let cancelled = false;
(async () => {
try {
// types=all: the public grid renders videos too, so a video has to be
// pinnable — otherwise it could only ever land in the unpinned tail.
const res = await fetch(`/api/admin/albums/${albumId}/assets?types=all`);
if (!res.ok) throw new Error(`Request failed with ${res.status}`);
const data = await res.json();
if (cancelled) return;
setAssets(data.assets);
// Reconcile against the *live* album rather than rendering the stored
// list: IDs removed from the album in Immich drop out, and new photos
// appear in the unpinned zone instead of being invisible.
const live = new Set<string>(data.assets.map((a: AssetInfo) => a.id));
setPinnedIds(assetOrder.filter((id) => live.has(id)));
} catch (err) {
if (cancelled) return;
console.error('Failed to load album assets:', err);
setError('Could not load the album’s photos from Immich.');
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [albumId, assetOrder]);
const byId = useMemo(() => new Map(assets.map((a) => [a.id, a])), [assets]);
const pinned = useMemo(
() => pinnedIds.map((id) => byId.get(id)).filter((a): a is AssetInfo => Boolean(a)),
[pinnedIds, byId],
);
const unpinned = useMemo(() => {
const set = new Set(pinnedIds);
return assets.filter((a) => !set.has(a.id));
}, [assets, pinnedIds]);
// Drift is reported, never acted on by itself. A pruned list is written only
// when the user saves — auto-saving it would let one failed Immich request
// quietly destroy a hand-curated order.
const dropped = useMemo(() => {
if (loading || error) return 0;
const live = new Set(assets.map((a) => a.id));
return assetOrder.filter((id) => !live.has(id)).length;
}, [assetOrder, assets, loading, error]);
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const from = pinnedIds.indexOf(String(active.id));
const to = pinnedIds.indexOf(String(over.id));
if (from < 0 || to < 0) return;
setPinnedIds(arrayMove(pinnedIds, from, to));
}
return (
<div className="picker-overlay" onClick={onClose}>
<div className="asset-picker-modal" onClick={(e) => e.stopPropagation()}>
<div className="picker-header">
<h3>Photo order — {albumName}</h3>
<button className="admin-btn-icon" onClick={onClose}>
×
</button>
</div>
{loading ? (
<div className="asset-picker-loading">
<div className="admin-spinner" />
</div>
) : error ? (
<p className="empty-hint">{error}</p>
) : (
<div className="order-editor-body">
{dropped > 0 && (
<p className="order-editor-notice">
{dropped} pinned {dropped === 1 ? 'photo is' : 'photos are'} no longer in this
album. {dropped === 1 ? 'It' : 'They'} will be removed from the order when you save.
</p>
)}
<div className="order-editor-section">
<div className="order-editor-section-head">
<h4>Pinned ({pinned.length})</h4>
{pinned.length > 0 && (
<button className="admin-btn admin-btn-sm" onClick={() => setPinnedIds([])}>
Unpin all
</button>
)}
</div>
{pinned.length === 0 ? (
<p className="empty-hint">
Nothing pinned yet — the album shows in Immich order. Pin the photos that should
open the album.
</p>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={pinnedIds} strategy={rectSortingStrategy}>
<div className="order-editor-grid">
{pinned.map((asset, i) => (
<SortableAssetTile
key={asset.id}
asset={asset}
index={i}
onUnpin={() => setPinnedIds(pinnedIds.filter((id) => id !== asset.id))}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
<div className="order-editor-section">
<div className="order-editor-section-head">
<h4>Follows automatically ({unpinned.length})</h4>
{unpinned.length > 0 && (
<button
className="admin-btn admin-btn-sm"
onClick={() => setPinnedIds([...pinnedIds, ...unpinned.map((a) => a.id)])}
>
Pin all
</button>
)}
</div>
<p className="empty-hint">
These follow the pinned photos in the album’s Immich order. Click one to pin it.
</p>
<div className="order-editor-grid">
{unpinned.map((asset) => (
<div
key={asset.id}
className="order-tile order-tile-unpinned"
title={asset.originalFileName}
onClick={() => setPinnedIds([...pinnedIds, asset.id])}
>
<img
src={`/api/admin/thumbnail/${asset.id}`}
alt={asset.originalFileName}
loading="lazy"
/>
<span className="order-tile-action">+</span>
</div>
))}
</div>
</div>
</div>
)}
<div className="order-editor-footer">
<button className="admin-btn admin-btn-ghost" onClick={onClose}>
Cancel
</button>
<button
className="admin-btn admin-btn-primary"
// Saving after a failed load would persist an empty or truncated
// order over the real one.
disabled={loading || !!error}
onClick={() => onSave(pinnedIds)}
>
Apply order
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { IconFolder, IconImage, IconStar } from './Icons';
interface AssetInfo {
id: string;
@@ -126,20 +127,23 @@ export default function AssetPicker({
className={`asset-picker-tab ${tab === 'album' ? 'active' : ''}`}
onClick={() => setTab('album')}
>
📂 This Album
<IconFolder size={14} />
This Album
</button>
)}
<button
className={`asset-picker-tab ${tab === 'favorites' ? 'active' : ''}`}
onClick={() => setTab('favorites')}
>
⭐ Favorites
<IconStar size={14} />
Favorites
</button>
<button
className={`asset-picker-tab ${tab === 'all' ? 'active' : ''}`}
onClick={() => setTab('all')}
>
🖼️ All Photos
<IconImage size={14} />
All Photos
</button>
</div>
@@ -173,7 +177,9 @@ export default function AssetPicker({
/>
{isUsed && <div className="asset-picker-used-badge">✓</div>}
{asset.isFavorite && !isUsed && (
<div className="asset-picker-fav-badge">⭐</div>
<div className="asset-picker-fav-badge" title="Favorite in Immich">
<IconStar size={14} />
</div>
)}
</div>
);

View File

@@ -256,6 +256,25 @@ export function IconHeart({ size = 16, className = 'svg-icon', strokeWidth = 2,
);
}
/** Filled by default — it marks a state (favourite), not an action. */
export function IconStar({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="m12 2 3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14l-5-4.87 6.91-1.01Z" />
</svg>
);
}
export function IconImage({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</svg>
);
}
export function IconBarChart({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
@@ -279,6 +298,40 @@ export function IconGripVertical({ size = 16, className = 'svg-icon', strokeWidt
);
}
export function IconX({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
);
}
export function IconPlus({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M5 12h14M12 5v14" />
</svg>
);
}
export function IconHome({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
);
}
export function IconCopy({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
);
}
export function IconQuote({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>

View File

@@ -21,97 +21,43 @@ import {
import { CSS } from '@dnd-kit/utilities';
import AlbumPicker from './AlbumPicker';
import AssetPicker from './AssetPicker';
import AssetOrderEditor from './AssetOrderEditor';
import { EssayBlockEditor } from './EssayBlockEditor';
import {
ALBUM_SORT_MODES,
DEFAULT_ALBUM_SORT,
isAlbumSortMode,
type AlbumSortMode,
} from '@/lib/albumSort';
import type { AlbumEntryObject } from '@/lib/config/schema';
import {
IconCamera,
IconCopy,
IconFolder,
IconGripVertical,
IconHome,
IconImage,
IconLock,
IconPencil,
IconPlus,
IconSearch,
IconTrash,
IconX,
} from './Icons';
// ── Icons ──────────────────────────────────────────────────────
const Icons = {
Drag: () => (
<svg width="12" height="18" viewBox="0 0 12 18" fill="currentColor" className="svg-icon svg-drag">
<circle cx="2" cy="2" r="1.5" />
<circle cx="2" cy="9" r="1.5" />
<circle cx="2" cy="16" r="1.5" />
<circle cx="10" cy="2" r="1.5" />
<circle cx="10" cy="9" r="1.5" />
<circle cx="10" cy="16" r="1.5" />
</svg>
),
Edit: () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg>
),
Trash: () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>
),
Close: () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
),
Folder: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
</svg>
),
Camera: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3Z" />
<circle cx="12" cy="13" r="3" />
</svg>
),
ExternalLink: () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
),
Search: () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
),
Lock: () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
),
Plus: () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<path d="M5 12h14M12 5v14" />
</svg>
),
Home: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
),
Copy: () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
),
Check: () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<polyline points="20 6 9 17 4 12" />
</svg>
),
Image: () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</svg>
),
/**
* Labels for the per-album sort select. Order matches ALBUM_SORT_MODES.
*
* Plain text, no emoji: an <option> cannot contain markup, so the SVG set from
* `./Icons` is not available here — and a decorative emoji is not a substitute
* for it. The badge on the album card uses a real icon instead.
*/
const SORT_LABELS: Record<AlbumSortMode, string> = {
immich: 'Immich order (default)',
newest: 'Newest first',
oldest: 'Oldest first',
filename: 'Filename',
manual: 'Manual',
};
// ── Types ──────────────────────────────────────────────────────
@@ -121,6 +67,9 @@ interface AlbumEntry {
description?: string;
password?: string;
heroImage?: string;
sort?: AlbumSortMode;
/** Pinned asset UUIDs for `sort: manual`; everything else follows automatically. */
assetOrder?: string[];
}
interface Section {
@@ -166,17 +115,27 @@ interface ImmichAlbumInfo {
// ── Helpers ────────────────────────────────────────────────────
function parseAlbumEntries(
raw:
| Array<
| string
| Record<
string,
string | { title: string; description?: string; password?: string; heroImage?: string }
>
>
| undefined,
): AlbumEntry[] {
type RawAlbumEntry = string | Record<string, string | AlbumEntryObject>;
/**
* Whether an entry carries anything beyond its ID.
*
* Both collapse rules below depend on this, and they are the reason every new
* per-album option has to be listed here: an entry that looks "empty" is
* serialized back to a bare UUID string, so a field missing from this check is
* silently dropped on the next save.
*/
function hasAlbumOptions(entry: AlbumEntry): boolean {
return Boolean(
entry.description ||
entry.password ||
entry.heroImage ||
(entry.sort && entry.sort !== DEFAULT_ALBUM_SORT) ||
entry.assetOrder?.length,
);
}
function parseAlbumEntries(raw: RawAlbumEntry[] | undefined): AlbumEntry[] {
if (!raw) return [];
return raw.map((entry) => {
if (typeof entry === 'string') return { id: entry };
@@ -188,27 +147,29 @@ function parseAlbumEntries(
description: value.description,
password: value.password,
heroImage: value.heroImage,
sort: isAlbumSortMode(value.sort) ? value.sort : undefined,
assetOrder: value.assetOrder,
};
});
}
function serializeAlbumEntries(
entries: AlbumEntry[],
): Array<
| string
| Record<string, string | { title: string; description?: string; password?: string; heroImage?: string }>
> {
function serializeAlbumEntries(entries: AlbumEntry[]): RawAlbumEntry[] {
return entries.map((entry) => {
if (!entry.title && !entry.description && !entry.password && !entry.heroImage) return entry.id;
if (entry.title && !entry.description && !entry.password && !entry.heroImage) {
return { [entry.id]: entry.title };
}
const val: { title: string; description?: string; password?: string; heroImage?: string } = {
title: entry.title || '',
};
const extras = hasAlbumOptions(entry);
if (!entry.title && !extras) return entry.id;
if (entry.title && !extras) return { [entry.id]: entry.title };
const val: AlbumEntryObject = {};
// Only when set: a sort-only entry would otherwise be written with an empty
// title, which deriveGallery ignores but which still lands in the YAML.
if (entry.title) val.title = entry.title;
if (entry.description) val.description = entry.description;
if (entry.password) val.password = entry.password;
if (entry.heroImage) val.heroImage = entry.heroImage;
if (entry.sort && entry.sort !== DEFAULT_ALBUM_SORT) val.sort = entry.sort;
// Persisted regardless of the mode, so manual → newest → manual does not
// throw away a hand-curated order.
if (entry.assetOrder?.length) val.assetOrder = entry.assetOrder;
return { [entry.id]: val };
});
}
@@ -238,11 +199,11 @@ function SortableHeroTile({
return (
<div ref={setNodeRef} style={style} className="hero-tile" {...attributes}>
<div className="hero-tile-drag" {...listeners} title="Drag to reorder">
<Icons.Drag />
<IconGripVertical size={18} className="svg-icon svg-drag" />
</div>
<img src={`/api/admin/thumbnail/${id}`} alt="" loading="lazy" />
<button className="hero-tile-remove" onClick={onRemove} title="Remove">
<Icons.Close />
<IconX size={14} />
</button>
<span className="hero-tile-index">{index + 1}</span>
</div>
@@ -334,7 +295,7 @@ function SortableSubpageTile({
{...attributes}
>
<div className="subpage-tile-drag" {...listeners} title="Drag to reorder">
<Icons.Drag />
<IconGripVertical size={18} className="svg-icon svg-drag" />
</div>
{sp.enabled === false && (
@@ -345,7 +306,7 @@ function SortableSubpageTile({
{sp.password && (
<span className="subpage-badge-protected" title="Password protected">
<Icons.Lock /> Password
<IconLock size={12} /> Password
</span>
)}
@@ -354,11 +315,11 @@ function SortableSubpageTile({
<img src={`/api/admin/thumbnail/${firstThumb}`} alt="" loading="lazy" />
) : (
<div className="subpage-tile-placeholder">
<Icons.Folder />
<IconFolder />
</div>
)}
<div className="subpage-hover-overlay">
<span className="hover-action-btn"><Icons.Edit /> Edit Page</span>
<span className="hover-action-btn"><IconPencil size={14} /> Edit Page</span>
</div>
</div>
<div className="subpage-tile-info">
@@ -367,7 +328,7 @@ function SortableSubpageTile({
<span className="subpage-tile-slug">/{slug}</span>
</div>
<span className="subpage-tile-meta">
<Icons.Folder /> {totalAlbums} album{totalAlbums !== 1 ? 's' : ''}
<IconFolder /> {totalAlbums} album{totalAlbums !== 1 ? 's' : ''}
</span>
</div>
</div>
@@ -396,6 +357,12 @@ export default function PageBuilder() {
currentAssetIds?: string[];
title?: string;
} | null>(null);
const [orderEditorTarget, setOrderEditorTarget] = useState<{
albumId: string;
albumName: string;
assetOrder: string[];
onSave: (assetOrder: string[]) => void;
} | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [editingAlbumAddress, setEditingAlbumAddress] = useState<ActiveEditAlbumAddress | null>(null);
@@ -929,7 +896,7 @@ export default function PageBuilder() {
{/* Search Bar */}
<div className="builder-search-container">
<div className="builder-search-wrapper">
<span className="builder-search-icon"><Icons.Search /></span>
<span className="builder-search-icon"><IconSearch size={14} /></span>
<input
type="text"
className="builder-search-input"
@@ -948,7 +915,7 @@ export default function PageBuilder() {
{/* Hero Section */}
<section className="builder-section">
<div className="builder-section-header">
<h2><Icons.Home /> Homepage Hero</h2>
<h2><IconHome /> Homepage Hero</h2>
<button
className="admin-btn admin-btn-sm"
onClick={() =>
@@ -959,7 +926,7 @@ export default function PageBuilder() {
})
}
>
<Icons.Plus /> Add Hero
<IconPlus size={14} /> Add Hero
</button>
</div>
<DndContext
@@ -993,7 +960,10 @@ export default function PageBuilder() {
{/* Standalone Albums */}
<section className="builder-section">
<div className="builder-section-header">
<h2>📷 Standalone Albums</h2>
<h2>
<IconCamera />
Standalone Albums
</h2>
<button
className="admin-btn admin-btn-sm"
onClick={() => setPickerTarget({ type: 'standalone' })}
@@ -1042,7 +1012,10 @@ export default function PageBuilder() {
{/* Subpages */}
<section className="builder-section">
<div className="builder-section-header">
<h2>📂 Subpages</h2>
<h2>
<IconFolder />
Subpages
</h2>
<button className="admin-btn admin-btn-sm" onClick={addSubpage}>
+ New Subpage
</button>
@@ -1107,7 +1080,7 @@ export default function PageBuilder() {
onClick={() => setDrawerMode('edit')}
style={{ padding: '0.2rem 0.6rem', fontSize: '0.75rem' }}
>
<Icons.Edit /> Edit
<IconPencil size={14} /> Edit
</button>
<button
type="button"
@@ -1115,7 +1088,7 @@ export default function PageBuilder() {
onClick={() => setDrawerMode('preview')}
style={{ padding: '0.2rem 0.6rem', fontSize: '0.75rem' }}
>
<Icons.Search /> Live Preview
<IconSearch size={14} /> Live Preview
</button>
</div>
@@ -1162,7 +1135,7 @@ export default function PageBuilder() {
{thumb ? (
<img src={`/api/admin/thumbnail/${thumb}`} alt="" />
) : (
<div className="subpage-tile-placeholder"><Icons.Folder /></div>
<div className="subpage-tile-placeholder"><IconFolder /></div>
)}
</div>
<span className="preview-album-title">
@@ -1186,7 +1159,7 @@ export default function PageBuilder() {
{thumb ? (
<img src={`/api/admin/thumbnail/${thumb}`} alt="" />
) : (
<div className="subpage-tile-placeholder"><Icons.Folder /></div>
<div className="subpage-tile-placeholder"><IconFolder /></div>
)}
</div>
<span className="preview-album-title">
@@ -1242,7 +1215,7 @@ export default function PageBuilder() {
<div className="admin-field">
<label>Password Protection (optional)</label>
<div className="password-input-wrapper">
<span className="password-icon"><Icons.Lock /></span>
<span className="password-icon"><IconLock size={12} /></span>
<input
type="password"
value={sp.password || ''}
@@ -1289,12 +1262,17 @@ export default function PageBuilder() {
}}
style={{ padding: '8px 12px', borderRadius: '6px', width: '100%', fontSize: '0.9rem' }}
>
<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>
{/*
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>
</div>
@@ -1395,7 +1373,7 @@ export default function PageBuilder() {
onClick={() => removeSection(spIndex, secIndex)}
title="Remove section"
>
<Icons.Close />
<IconX size={14} />
</button>
</div>
<div className="admin-field">
@@ -1465,7 +1443,7 @@ export default function PageBuilder() {
setExpandedSubpage(null);
}}
>
<Icons.Trash /> Delete Subpage
<IconTrash size={14} /> Delete Subpage
</button>
<button
className="admin-btn admin-btn-primary admin-btn-sm"
@@ -1505,14 +1483,14 @@ export default function PageBuilder() {
<div className="album-drawer" onClick={(e) => e.stopPropagation()}>
<div className="album-drawer-header">
<h3>
<Icons.Camera /> Edit Album Details
<IconCamera /> Edit Album Details
</h3>
<button
className="admin-btn-icon"
onClick={() => setEditingAlbumAddress(null)}
title="Close details"
>
<Icons.Close />
<IconX size={14} />
</button>
</div>
<div className="album-drawer-body">
@@ -1523,7 +1501,7 @@ export default function PageBuilder() {
<img src={`/api/admin/thumbnail/${heroThumb}`} alt="" loading="lazy" />
) : (
<div className="modal-cover-placeholder">
<Icons.Camera />
<IconCamera />
</div>
)}
</div>
@@ -1562,7 +1540,7 @@ export default function PageBuilder() {
}}
title="Copy UUID"
>
<Icons.Copy />
<IconCopy size={12} />
</button>
</div>
</div>
@@ -1613,7 +1591,7 @@ export default function PageBuilder() {
onClick={() => onUpdate({ heroImage: undefined })}
title="Remove custom hero image"
>
<Icons.Close />
<IconX size={14} />
</button>
</div>
) : (
@@ -1633,10 +1611,64 @@ export default function PageBuilder() {
})
}
>
<Icons.Image /> {album.heroImage ? 'Change Image' : 'Pick Hero Image'}
<IconImage size={12} /> {album.heroImage ? 'Change Image' : 'Pick Hero Image'}
</button>
</div>
</div>
{/* Photo order */}
<div className="admin-field">
<label>Photo order</label>
<select
value={album.sort || DEFAULT_ALBUM_SORT}
onChange={(e) =>
onUpdate({
sort: isAlbumSortMode(e.target.value) ? e.target.value : undefined,
})
}
style={{
padding: '8px 12px',
borderRadius: '6px',
width: '100%',
fontSize: '0.9rem',
}}
>
{ALBUM_SORT_MODES.map((mode) => (
<option key={mode} value={mode}>
{SORT_LABELS[mode]}
</option>
))}
</select>
<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={() =>
setOrderEditorTarget({
albumId: album.id,
albumName: album.title || name,
assetOrder: album.assetOrder || [],
onSave: (assetOrder) => {
onUpdate({ assetOrder });
setOrderEditorTarget(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 className="album-drawer-footer">
@@ -1645,7 +1677,7 @@ export default function PageBuilder() {
onClick={onRemove}
style={{ maxWidth: '160px' }}
>
<Icons.Trash /> Remove Album
<IconTrash size={14} /> Remove Album
</button>
<button
className="admin-btn admin-btn-primary"
@@ -1670,6 +1702,17 @@ export default function PageBuilder() {
title={heroPickerTarget.title}
/>
)}
{/* Manual Photo Order Editor */}
{orderEditorTarget && (
<AssetOrderEditor
albumId={orderEditorTarget.albumId}
albumName={orderEditorTarget.albumName}
assetOrder={orderEditorTarget.assetOrder}
onSave={orderEditorTarget.onSave}
onClose={() => setOrderEditorTarget(null)}
/>
)}
</div>
);
@@ -1797,14 +1840,14 @@ function AlbumCard({
<div className="album-tile-cover">
{dragListeners && (
<div className="album-tile-drag" {...dragListeners} title="Drag to reorder">
<Icons.Drag />
<IconGripVertical size={18} className="svg-icon svg-drag" />
</div>
)}
{heroThumb ? (
<img src={`/api/admin/thumbnail/${heroThumb}`} alt="" loading="lazy" />
) : (
<div className="album-tile-placeholder">
<Icons.Camera />
<IconCamera />
</div>
)}
<div className="album-tile-overlay">
@@ -1813,14 +1856,14 @@ function AlbumCard({
onClick={onEdit}
title="Edit details"
>
<Icons.Edit />
<IconPencil size={14} />
</button>
<button
className="album-tile-btn album-tile-btn-danger"
onClick={onRemove}
title="Remove album"
>
<Icons.Trash />
<IconTrash size={14} />
</button>
</div>
</div>
@@ -1832,12 +1875,17 @@ function AlbumCard({
<div className="album-tile-badges">
{hasPassword && (
<span className="badge badge-password" title="Password protected">
<Icons.Lock />
<IconLock size={12} />
</span>
)}
{album.heroImage && (
<span className="badge badge-hero" title="Custom Hero Image set">
<Icons.Image />
<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>