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.
This commit is contained in:
ralksta
2026-05-29 17:59:25 +02:00
parent 2e4cde4b6e
commit e8e65e7172
8 changed files with 1075 additions and 174 deletions

View File

@@ -0,0 +1,192 @@
'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>
);
}