🛡️ 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:
277
app/admin/components/AssetOrderEditor.tsx
Normal file
277
app/admin/components/AssetOrderEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user