🛡️ 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:
@@ -1510,6 +1510,10 @@
|
||||
|
||||
.asset-picker-tab {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.7rem 1rem;
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -1607,8 +1611,11 @@
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
font-size: 0.7rem;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
font-size: 0.85rem;
|
||||
color: var(--admin-accent);
|
||||
/* drop-shadow, not text-shadow: the badge is an inline SVG, not a glyph. */
|
||||
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
|
||||
.asset-picker-uuid-section {
|
||||
@@ -1782,8 +1789,10 @@
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
/* Drag handles use IconGripVertical's square 24x24 viewBox, so keep the box
|
||||
square too — a 12x18 box would letterbox the glyph down to 12x12. */
|
||||
.svg-drag {
|
||||
width: 12px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
@@ -1924,6 +1933,17 @@ h3 .svg-icon {
|
||||
background: rgba(74, 222, 128, 0.05);
|
||||
}
|
||||
|
||||
/* Carries a mode name ("filename", "oldest"), so it cannot use the fixed
|
||||
icon-sized box the other badges share. */
|
||||
.album-tile-badges .badge-sort {
|
||||
width: auto;
|
||||
min-width: 18px;
|
||||
padding: 0 5px;
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ── Breitwand-Kiste Centered Modal styling ── */
|
||||
.album-drawer-overlay {
|
||||
position: fixed;
|
||||
@@ -3821,3 +3841,140 @@ h3 .svg-icon {
|
||||
background: var(--admin-btn-primary-bg, #ffffff);
|
||||
color: var(--admin-btn-primary-text, #000000);
|
||||
}
|
||||
|
||||
/* ── Per-album photo order (sort: manual) ────────────────────── */
|
||||
|
||||
.admin-field-hint {
|
||||
display: block;
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
color: var(--admin-text-muted);
|
||||
}
|
||||
|
||||
.order-editor-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.order-editor-notice {
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.4;
|
||||
color: var(--admin-accent);
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
background: rgba(245, 158, 11, 0.05);
|
||||
}
|
||||
|
||||
.order-editor-section + .order-editor-section {
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--admin-border);
|
||||
}
|
||||
|
||||
.order-editor-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.order-editor-section-head h4 {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--admin-text);
|
||||
}
|
||||
|
||||
.order-editor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.order-tile {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--admin-bg-elevated);
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
opacity 0.15s;
|
||||
}
|
||||
|
||||
.order-tile img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
/* The whole tile is a drag handle; without this the browser's native image
|
||||
drag wins and dnd-kit never sees the gesture. */
|
||||
-webkit-user-drag: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.order-tile-pinned {
|
||||
border-color: var(--admin-accent);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.order-tile-pinned:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.order-tile-unpinned {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.order-tile-unpinned:hover {
|
||||
opacity: 1;
|
||||
border-color: var(--admin-border);
|
||||
}
|
||||
|
||||
.order-tile-index {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 4px;
|
||||
min-width: 18px;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
color: #000;
|
||||
background: var(--admin-accent);
|
||||
}
|
||||
|
||||
.order-tile-action {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: var(--admin-text);
|
||||
background: var(--admin-overlay, rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
|
||||
.order-editor-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid var(--admin-border);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
vi.mock('@/lib/admin/auth', () => ({
|
||||
isAdminEnabled: () => true,
|
||||
isAdminAuthenticated: async () => true,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getConfig: () => ({ needsSetup: false }),
|
||||
}));
|
||||
|
||||
const getAlbumAssetsRaw = vi.fn();
|
||||
vi.mock('@/lib/immich', () => ({
|
||||
immich: {
|
||||
get getAlbumAssetsRaw() {
|
||||
return getAlbumAssetsRaw;
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { GET } from '../route';
|
||||
|
||||
const ALBUM = '11111111-1111-1111-1111-111111111111';
|
||||
|
||||
const asset = (id: string, type: 'IMAGE' | 'VIDEO') => ({
|
||||
id,
|
||||
type,
|
||||
originalFileName: `${id}.jpg`,
|
||||
fileCreatedAt: '2024-01-01T00:00:00.000Z',
|
||||
isFavorite: false,
|
||||
});
|
||||
|
||||
const call = (albumId: string, query = '') =>
|
||||
GET(new NextRequest(`http://localhost/api/admin/albums/${albumId}/assets${query}`), {
|
||||
params: Promise.resolve({ albumId }),
|
||||
});
|
||||
|
||||
describe('GET /api/admin/albums/[albumId]/assets', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getAlbumAssetsRaw.mockResolvedValue([
|
||||
asset('img-1', 'IMAGE'),
|
||||
asset('vid-1', 'VIDEO'),
|
||||
asset('img-2', 'IMAGE'),
|
||||
]);
|
||||
});
|
||||
|
||||
// Without this, `..%2f..%2fusers` resolves inside fetch() to an arbitrary
|
||||
// Immich endpoint, called with the server's API key.
|
||||
it('rejects an album ID that is not a UUID', async () => {
|
||||
const res = await call('..%2f..%2fusers');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(getAlbumAssetsRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns images only by default, so the hero picker is unaffected', async () => {
|
||||
const res = await call(ALBUM);
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.assets.map((a: { id: string }) => a.id)).toEqual(['img-1', 'img-2']);
|
||||
});
|
||||
|
||||
// The public grid renders videos, so the sort editor has to be able to pin
|
||||
// one — otherwise a video could only ever sit in the unpinned tail.
|
||||
it('includes videos when types=all', async () => {
|
||||
const res = await call(ALBUM, '?types=all');
|
||||
const body = await res.json();
|
||||
|
||||
expect(body.assets.map((a: { id: string }) => a.id)).toEqual(['img-1', 'vid-1', 'img-2']);
|
||||
});
|
||||
|
||||
/**
|
||||
* Immich 3.x stopped embedding assets in `GET /albums/:id`, which is what
|
||||
* this route used to read — so it returned nothing, and threw outright when
|
||||
* the key was absent. It has to go through the client's paginated metadata
|
||||
* search instead.
|
||||
*/
|
||||
it('reads assets through the Immich client rather than the album endpoint', async () => {
|
||||
await call(ALBUM);
|
||||
|
||||
expect(getAlbumAssetsRaw).toHaveBeenCalledWith(ALBUM);
|
||||
});
|
||||
|
||||
it('reports an upstream failure as a 500 rather than an empty album', async () => {
|
||||
getAlbumAssetsRaw.mockRejectedValue(new Error('immich down'));
|
||||
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const res = await call(ALBUM);
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
err.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,16 @@
|
||||
/**
|
||||
* Admin API: Get assets of a specific album for the hero image picker.
|
||||
* Admin API: assets of a specific album, for the hero image picker and the
|
||||
* manual sort editor.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { immich } from '@/lib/immich';
|
||||
import { isUuid } from '@/lib/uuid';
|
||||
|
||||
interface ImmichAlbumDetail {
|
||||
assets: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
originalFileName: string;
|
||||
thumbhash: string | null;
|
||||
fileCreatedAt: string;
|
||||
isFavorite: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ albumId: string }> },
|
||||
) {
|
||||
if (!isAdminEnabled()) {
|
||||
@@ -42,27 +33,27 @@ export async function GET(
|
||||
return NextResponse.json({ error: 'Invalid album ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Videos are opt-in. The hero picker feeds imageUrl()/assetPlaceholder and
|
||||
// wants images only; the sort editor must show everything the public grid
|
||||
// renders, or a video could never be pinned and would always fall to the tail.
|
||||
const includeVideos = request.nextUrl.searchParams.get('types') === 'all';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${config.immich.apiUrl}/albums/${encodeURIComponent(albumId)}`, {
|
||||
headers: {
|
||||
'x-api-key': config.immich.apiKey,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
// Via the client, not a bare fetch of `GET /albums/:id`: Immich 3.x no
|
||||
// longer embeds assets in that response, so reading `album.assets` returned
|
||||
// nothing (and threw outright when the key was absent). getAlbumAssetsRaw()
|
||||
// pages through the metadata search and applies the album's own order, so
|
||||
// this list matches what the site renders under `sort: immich`.
|
||||
const all = await immich.getAlbumAssetsRaw(albumId);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ error: `Immich API returned ${res.status}` }, { status: 502 });
|
||||
}
|
||||
|
||||
const album = (await res.json()) as ImmichAlbumDetail;
|
||||
|
||||
const assets = album.assets
|
||||
.filter((a) => a.type === 'IMAGE')
|
||||
const assets = all
|
||||
.filter((a) => a.type === 'IMAGE' || (includeVideos && a.type === 'VIDEO'))
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
type: a.type,
|
||||
originalFileName: a.originalFileName,
|
||||
fileCreatedAt: a.fileCreatedAt,
|
||||
isFavorite: a.isFavorite,
|
||||
isFavorite: a.isFavorite ?? false,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ assets, nextPage: null });
|
||||
|
||||
Reference in New Issue
Block a user