Files
immich-folio/app/admin/components/AssetPicker.tsx
ralksta e8e65e7172 feat(admin): add asset picker with favorites, drag-and-drop reordering, preview links, and keyboard shortcuts
- Asset Picker: Visual browser for hero images with Favorites tab (shown first)
  and All Photos tab. Includes infinite scroll and UUID fallback input for
  assets not easily found in the grid.
- Drag & Drop: All lists (hero images, standalone albums, subpage albums,
  subpage tiles) are now reorderable via drag handles using @dnd-kit.
- Preview Links: 'Preview Site' button in save bar, '↗ Site' in header, and
  per-subpage preview links with URL slug preview shown inline.
- Keyboard Shortcuts: ⌘+S / Ctrl+S to save in both Pages and Settings tabs.
- Unsaved Changes Guard: beforeunload warning prevents accidental data loss.
- New API route /api/admin/assets for browsing Immich assets with pagination
  and favorites filter via search/metadata endpoint.
2026-05-29 17:59:25 +02:00

193 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 } from 'react';
interface AssetInfo {
id: string;
originalFileName: string;
fileCreatedAt: string;
isFavorite: boolean;
}
interface Props {
onSelect: (assetId: string) => void;
onClose: () => void;
currentAssetIds?: string[];
}
type Tab = 'favorites' | 'all';
export default function AssetPicker({ onSelect, onClose, currentAssetIds = [] }: Props) {
const [tab, setTab] = useState<Tab>('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 [uuidInput, setUuidInput] = useState('');
const [uuidError, setUuidError] = useState('');
const listRef = useRef<HTMLDivElement>(null);
const currentIds = new Set(currentAssetIds);
const loadAssets = useCallback(
async (pageNum: number, append: boolean = false) => {
if (pageNum === 1) setLoading(true);
else setLoadingMore(true);
try {
const params = new URLSearchParams({
page: pageNum.toString(),
favorites: tab === 'favorites' ? 'true' : 'false',
});
const res = await fetch(`/api/admin/assets?${params}`);
if (res.ok) {
const data = await res.json();
setAssets((prev) => (append ? [...prev, ...data.assets] : data.assets));
setHasMore(data.nextPage !== null);
setPage(pageNum);
}
} catch (err) {
console.error('Failed to load assets:', err);
} finally {
setLoading(false);
setLoadingMore(false);
}
},
[tab],
);
useEffect(() => {
setAssets([]);
setPage(1);
setHasMore(false);
loadAssets(1);
}, [tab, loadAssets]);
// 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 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('');
onSelect(uuid);
}
function formatDate(dateStr: string): string {
try {
return new Date(dateStr).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
} catch {
return '';
}
}
return (
<div className="picker-overlay" onClick={onClose}>
<div className="asset-picker-modal" onClick={(e) => e.stopPropagation()}>
<div className="picker-header">
<h3>Select Hero Image</h3>
<button className="admin-btn-icon" onClick={onClose}>
×
</button>
</div>
{/* Tabs */}
<div className="asset-picker-tabs">
<button
className={`asset-picker-tab ${tab === 'favorites' ? 'active' : ''}`}
onClick={() => setTab('favorites')}
>
⭐ Favorites
</button>
<button
className={`asset-picker-tab ${tab === 'all' ? 'active' : ''}`}
onClick={() => setTab('all')}
>
🖼️ All Photos
</button>
</div>
{/* Asset Grid */}
<div className="asset-picker-grid" ref={listRef} onScroll={handleScroll}>
{loading ? (
<div className="asset-picker-loading">
<div className="admin-spinner" />
</div>
) : assets.length === 0 ? (
<p className="empty-hint">
{tab === 'favorites'
? 'No favorite photos found. Star photos in Immich first, or switch to "All Photos".'
: 'No photos found.'}
</p>
) : (
<>
{assets.map((asset) => {
const isUsed = currentIds.has(asset.id);
return (
<div
key={asset.id}
className={`asset-picker-tile ${isUsed ? 'used' : ''}`}
onClick={() => !isUsed && onSelect(asset.id)}
title={`${asset.originalFileName}\n${formatDate(asset.fileCreatedAt)}`}
>
<img
src={`/api/admin/thumbnail/${asset.id}`}
alt={asset.originalFileName}
loading="lazy"
/>
{isUsed && <div className="asset-picker-used-badge">✓</div>}
{asset.isFavorite && !isUsed && (
<div className="asset-picker-fav-badge">⭐</div>
)}
</div>
);
})}
{loadingMore && (
<div className="asset-picker-loading-more">
<div className="admin-spinner" />
</div>
)}
</>
)}
</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..."
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>
);
}