'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 (
{asset.originalFileName} {index + 1}
); } /** * 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([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [pinnedIds, setPinnedIds] = useState([]); 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(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 (
e.stopPropagation()} >

Photo order — {albumName}

{loading ? (
) : error ? (

{error}

) : (
{dropped > 0 && (

{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.

)}

Pinned ({pinned.length})

{pinned.length > 0 && ( )}
{pinned.length === 0 ? (

Nothing pinned yet — the album shows in Immich order. Pin the photos that should open the album.

) : (
{pinned.map((asset, i) => ( setPinnedIds(pinnedIds.filter((id) => id !== asset.id))} /> ))}
)}

Follows automatically ({unpinned.length})

{unpinned.length > 0 && ( )}

These follow the pinned photos in the album’s Immich order. Click one to pin it.

{unpinned.map((asset) => ( ))}
)}
); }