Files
immich-folio/app/admin/components/AssetPicker.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

384 lines
13 KiB
TypeScript
Raw Permalink 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, useCallback, useRef, useMemo } from 'react';
import { useModalDialog } from '@/hooks/useModalDialog';
import { canSelectMore, matchesQuery, toggleSelection } from '@/lib/admin/assetSelection';
import { IconCheck, IconFolder, IconImage, IconSearch, IconStar } from './Icons';
import { useScrollLock } from './useScrollLock';
interface AssetInfo {
id: string;
originalFileName: string;
fileCreatedAt: string;
isFavorite: boolean;
}
interface Props {
/** Single-select: called with the one asset picked; the caller closes the picker. */
onSelect?: (assetId: string) => void;
/**
* Multi-select (#602): tiles toggle, and a confirm button hands over every
* picked asset in the order they were picked. Takes precedence over `onSelect`.
*/
onSelectMany?: (assetIds: string[]) => void;
/** Most assets `onSelectMany` accepts; unlimited when omitted. */
max?: number;
onClose: () => void;
currentAssetIds?: string[];
albumId?: string;
title?: string;
}
type Tab = 'album' | 'favorites' | 'all';
const SEARCH_DEBOUNCE_MS = 300;
export default function AssetPicker({
onSelect,
onSelectMany,
max,
onClose,
currentAssetIds = [],
albumId,
title,
}: Props) {
useScrollLock(true);
const cardRef = useModalDialog(onClose);
const multi = onSelectMany !== undefined;
const [tab, setTab] = useState<Tab>(albumId ? 'album' : 'favorites');
const [assets, setAssets] = useState<AssetInfo[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(false);
const [queryInput, setQueryInput] = useState('');
const [query, setQuery] = useState('');
const [selected, setSelected] = useState<string[]>([]);
const [uuidInput, setUuidInput] = useState('');
const [uuidError, setUuidError] = useState('');
const listRef = useRef<HTMLDivElement>(null);
/**
* Bumped each time the list starts over (tab, album or search changed). A
* request is only allowed to touch the list while its generation is current:
* without this, a slow Favorites answer landing after a switch to All Photos
* replaced that tab's photos, and ended its spinner early.
*/
const generation = useRef(0);
const currentIds = useMemo(() => new Set(currentAssetIds), [currentAssetIds]);
// Debounce typing into the query the fetch runs on.
useEffect(() => {
const t = setTimeout(() => setQuery(queryInput.trim()), SEARCH_DEBOUNCE_MS);
return () => clearTimeout(t);
}, [queryInput]);
// The album tab loads the album whole and filters locally; the library tabs
// search server-side, since they are paged.
const serverQuery = tab === 'album' ? '' : query;
const loadAssets = useCallback(
async (pageNum: number, append: boolean = false) => {
// A fresh list starts a generation; a further page belongs to the current one.
const gen = append ? generation.current : ++generation.current;
const current = () => gen === generation.current;
if (append) {
setLoadingMore(true);
} else {
setLoading(true);
// A "load more" of the previous list will not clear this itself.
setLoadingMore(false);
}
try {
let res: Response;
if (tab === 'album' && albumId) {
res = await fetch(`/api/admin/albums/${albumId}/assets`);
} else {
const params = new URLSearchParams({
page: pageNum.toString(),
favorites: tab === 'favorites' ? 'true' : 'false',
});
if (serverQuery) params.set('q', serverQuery);
res = await fetch(`/api/admin/assets?${params}`);
}
if (res.ok) {
const data = await res.json();
if (!current()) return;
setAssets((prev) => {
if (!append) return data.assets;
// Merged filename + description pages can overlap across pages.
const seen = new Set(prev.map((a) => a.id));
return [...prev, ...data.assets.filter((a: AssetInfo) => !seen.has(a.id))];
});
setHasMore(data.nextPage !== null);
setPage(pageNum);
}
} catch (err) {
if (current()) console.error('Failed to load assets:', err);
} finally {
if (current()) {
setLoading(false);
setLoadingMore(false);
}
}
},
[tab, albumId, serverQuery],
);
useEffect(() => {
setAssets([]);
setPage(1);
setHasMore(false);
loadAssets(1);
}, [loadAssets]);
const visible = tab === 'album' ? assets.filter((a) => matchesQuery(a, query)) : assets;
// Infinite scroll
function handleScroll() {
if (!listRef.current || loadingMore || !hasMore) return;
const { scrollTop, scrollHeight, clientHeight } = listRef.current;
if (scrollHeight - scrollTop - clientHeight < 200) {
loadAssets(page + 1, true);
}
}
function pick(id: string) {
if (currentIds.has(id)) return;
if (multi) setSelected((s) => toggleSelection(s, id, { disabled: currentIds, max }));
else onSelect?.(id);
}
function confirm() {
if (multi && selected.length > 0) onSelectMany(selected);
}
function handleUuidSubmit(e: React.FormEvent) {
e.preventDefault();
const uuid = uuidInput.trim();
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(uuid)) {
setUuidError('Invalid UUID format');
return;
}
setUuidError('');
if (multi) {
setSelected((s) => toggleSelection(s, uuid.toLowerCase(), { disabled: currentIds, max }));
setUuidInput('');
} else {
onSelect?.(uuid);
}
}
function formatDate(dateStr: string): string {
try {
return new Date(dateStr).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
} catch {
return '';
}
}
const full = multi && !canSelectMore(selected, max);
return (
<div className="picker-overlay" onClick={onClose}>
<div
className="asset-picker-modal"
ref={cardRef}
role="dialog"
aria-modal="true"
aria-labelledby="asset-picker-title"
onClick={(e) => e.stopPropagation()}
>
<div className="picker-header">
<h3 id="asset-picker-title">{title || 'Select Hero Image'}</h3>
<button className="admin-btn-icon" onClick={onClose} aria-label="Close">
×
</button>
</div>
{/* Tabs */}
<div className="asset-picker-tabs">
{albumId && (
<button
className={`asset-picker-tab ${tab === 'album' ? 'active' : ''}`}
aria-pressed={tab === 'album'}
onClick={() => setTab('album')}
>
<IconFolder size={14} />
This Album
</button>
)}
<button
className={`asset-picker-tab ${tab === 'favorites' ? 'active' : ''}`}
aria-pressed={tab === 'favorites'}
onClick={() => setTab('favorites')}
>
<IconStar size={14} />
Favorites
</button>
<button
className={`asset-picker-tab ${tab === 'all' ? 'active' : ''}`}
aria-pressed={tab === 'all'}
onClick={() => setTab('all')}
>
<IconImage size={14} />
All Photos
</button>
</div>
{/* Search */}
<div className="asset-picker-search">
<IconSearch size={14} />
<input
type="search"
value={queryInput}
onChange={(e) => setQueryInput(e.target.value)}
onKeyDown={(e) => {
// Enter in the field searches at once instead of submitting anything.
if (e.key === 'Enter') {
e.preventDefault();
setQuery(queryInput.trim());
}
}}
maxLength={100}
placeholder={
tab === 'album'
? 'Filter this album by filename…'
: 'Search filename, description, or a date (2024-05)…'
}
aria-label="Search photos"
className="asset-picker-search-input"
/>
</div>
{/* Asset Grid */}
<div
className="asset-picker-grid"
ref={listRef}
onScroll={handleScroll}
role="group"
aria-label="Photos"
>
{loading ? (
<div className="asset-picker-loading">
<div className="admin-spinner" />
</div>
) : visible.length === 0 ? (
<p className="empty-hint">
{query
? `No photos match “${query}”.`
: tab === 'favorites'
? 'No favorite photos found. Star photos in Immich first, or switch to "All Photos".'
: 'No photos found.'}
</p>
) : (
<>
{visible.map((asset) => {
const isUsed = currentIds.has(asset.id);
const order = selected.indexOf(asset.id);
const isSelected = order !== -1;
const blocked = isUsed || (full && !isSelected);
const label = `${asset.originalFileName}, ${formatDate(asset.fileCreatedAt)}${
isUsed ? ' (already used)' : ''
}`;
return (
<button
type="button"
key={asset.id}
className={`asset-picker-tile ${isUsed ? 'used' : ''} ${
isSelected ? 'selected' : ''
}`}
onClick={() => pick(asset.id)}
disabled={blocked}
aria-pressed={multi ? isSelected : undefined}
aria-label={label}
title={`${asset.originalFileName}\n${formatDate(asset.fileCreatedAt)}`}
>
<img src={`/api/admin/thumbnail/${asset.id}`} alt="" loading="lazy" />
{isUsed && (
<span className="asset-picker-used-badge" aria-hidden="true">
<IconCheck size={11} />
</span>
)}
{isSelected && (
<span className="asset-picker-order-badge" aria-hidden="true">
{max === 1 ? <IconCheck size={11} /> : order + 1}
</span>
)}
{asset.isFavorite && !isUsed && (
<span className="asset-picker-fav-badge" aria-hidden="true">
<IconStar size={14} />
</span>
)}
</button>
);
})}
{loadingMore && (
<div className="asset-picker-loading-more">
<div className="admin-spinner" />
</div>
)}
</>
)}
</div>
{/* Selection bar (multi-select) */}
{multi && (
<div className="asset-picker-selection">
<span aria-live="polite">
{selected.length === 0
? max && max > 1
? `Pick up to ${max} photos`
: 'Pick photos'
: `${selected.length}${max && max > 1 ? ` of ${max}` : ''} selected`}
</span>
{selected.length > 0 && (
<button
type="button"
className="admin-btn admin-btn-sm"
onClick={() => setSelected([])}
>
Clear
</button>
)}
<button
type="button"
className="admin-btn admin-btn-sm admin-btn-primary"
disabled={selected.length === 0}
onClick={confirm}
>
{selected.length > 1 ? `Add ${selected.length} photos` : 'Add photo'}
</button>
</div>
)}
{/* UUID Fallback */}
<div className="asset-picker-uuid-section">
<form onSubmit={handleUuidSubmit} className="asset-picker-uuid-form">
<input
type="text"
value={uuidInput}
onChange={(e) => {
setUuidInput(e.target.value);
setUuidError('');
}}
placeholder="Or paste asset UUID directly..."
aria-label="Asset UUID"
className="asset-picker-uuid-input"
/>
<button type="submit" className="admin-btn admin-btn-sm" disabled={!uuidInput.trim()}>
Add
</button>
</form>
{uuidError && <span className="asset-picker-uuid-error">{uuidError}</span>}
</div>
</div>
</div>
);
}