Files
immich-folio/app/admin/components/AssetOrderEditor.tsx
Ralksta ad8b09700b release: v0.19.0
Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of
[CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md)
for the full list.

Highlights: client proofing links (#683), custom content pages (#738),
asset picker search (#740), concurrent-edit detection (#742), env-locked
settings (#739), an accessibility baseline (#741), three security fixes
with advisories (#737, #755, #759), and a pre-release quality pass
(#780–#797).

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
2026-09-29 22:50:30 +02:00

291 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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';
import { useScrollLock } from './useScrollLock';
import { useModalDialog } from '@/hooks/useModalDialog';
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
type="button"
className="order-tile-action"
title="Unpin — let this photo follow automatically"
aria-label={`Unpin ${asset.originalFileName}`}
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) {
useScrollLock(true);
const cardRef = useModalDialog(onClose);
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"
ref={cardRef}
role="dialog"
aria-modal="true"
aria-labelledby="order-editor-title"
onClick={(e) => e.stopPropagation()}
>
<div className="picker-header">
<h3 id="order-editor-title">Photo order — {albumName}</h3>
<button type="button" className="admin-btn-icon" onClick={onClose} aria-label="Close">
×
</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) => (
<button
type="button"
key={asset.id}
className="order-tile order-tile-unpinned"
title={asset.originalFileName}
aria-label={`Pin ${asset.originalFileName}`}
onClick={() => setPinnedIds([...pinnedIds, asset.id])}
>
<img src={`/api/admin/thumbnail/${asset.id}`} alt="" loading="lazy" />
<span className="order-tile-action" aria-hidden="true">
+
</span>
</button>
))}
</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>
);
}