import { useMemo, useState } from 'react' import type { ChangeEvent } from 'react' export interface AdminAsset { filename: string key?: string storage?: 'r2' | 'local' url: string folder?: string sizeBytes: number updatedAt: string | null tags?: string[] usedIn?: number } interface AssetManagerProps { assets: AdminAsset[] onReload: () => Promise onMessage: (message: string) => void message: string } type SortKey = 'newest' | 'oldest' | 'name' | 'size' | 'used' type TypeFilter = 'all' | 'image' | 'pdf' | 'doc' type ViewMode = 'list' | 'grid' const MOVE_FOLDERS = ['uploads', 'images', 'guides', 'finished'] const MAX_UPLOAD_BYTES = 8 * 1024 * 1024 const assetId = (a: AdminAsset) => a.key ?? `${a.folder ?? ''}/${a.filename}` const isImage = (name: string) => /\.(png|jpe?g|webp|gif)$/i.test(name) const isPdf = (name: string) => /\.pdf$/i.test(name) const fileKind = (name: string): TypeFilter => (isImage(name) ? 'image' : isPdf(name) ? 'pdf' : 'doc') const extOf = (name: string) => (name.match(/\.[a-z0-9]+$/i)?.[0] ?? '').toLowerCase() function formatSize(bytes: number) { if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB` return `${Math.max(bytes / 1024, 0.1).toFixed(1)} KB` } function formatWhen(value: string | null) { if (!value) return '—' const d = new Date(value) return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) } function locationLabel(a: AdminAsset) { if (a.storage === 'local' || a.folder === 'local') return 'Local disk' return a.folder ? `R2 / ${a.folder}` : 'R2' } async function readError(res: Response, fallback: string) { const data = await res.json().catch(() => ({})) as { message?: string } return data.message ?? fallback } export default function AssetManager({ assets, onReload, onMessage, message }: AssetManagerProps) { const [search, setSearch] = useState('') const [folderFilter, setFolderFilter] = useState('all') const [typeFilter, setTypeFilter] = useState('all') const [tagFilter, setTagFilter] = useState(null) const [unusedOnly, setUnusedOnly] = useState(false) const [sort, setSort] = useState('newest') const [view, setView] = useState('list') const [selected, setSelected] = useState>(new Set()) const [busy, setBusy] = useState(false) const [uploadDest, setUploadDest] = useState('r2:uploads') const [renaming, setRenaming] = useState<{ id: string; value: string } | null>(null) const [tagDrafts, setTagDrafts] = useState>({}) const [copiedId, setCopiedId] = useState(null) const [bulkMove, setBulkMove] = useState('') const folders = useMemo(() => { const counts = new Map() for (const a of assets) { const key = a.storage === 'local' || a.folder === 'local' ? 'local' : (a.folder || 'r2') counts.set(key, (counts.get(key) ?? 0) + 1) } return [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0])) }, [assets]) const allTags = useMemo(() => { const counts = new Map() for (const a of assets) for (const t of a.tags ?? []) counts.set(t, (counts.get(t) ?? 0) + 1) return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) }, [assets]) const visible = useMemo(() => { const q = search.trim().toLowerCase() const list = assets.filter(a => { const folderKey = a.storage === 'local' || a.folder === 'local' ? 'local' : (a.folder || 'r2') if (folderFilter !== 'all' && folderKey !== folderFilter) return false if (typeFilter !== 'all' && fileKind(a.filename) !== typeFilter) return false if (tagFilter && !(a.tags ?? []).includes(tagFilter)) return false if (unusedOnly && (a.usedIn ?? 0) > 0) return false if (q) { const haystack = `${a.filename} ${a.folder ?? ''} ${(a.tags ?? []).join(' ')}`.toLowerCase() if (!q.split(/\s+/).every(word => haystack.includes(word))) return false } return true }) const dateOf = (a: AdminAsset) => a.updatedAt ?? '' list.sort((a, b) => { switch (sort) { case 'oldest': return dateOf(a).localeCompare(dateOf(b)) case 'name': return a.filename.localeCompare(b.filename, undefined, { numeric: true }) case 'size': return b.sizeBytes - a.sizeBytes case 'used': return (b.usedIn ?? 0) - (a.usedIn ?? 0) default: return dateOf(b).localeCompare(dateOf(a)) } }) return list }, [assets, search, folderFilter, typeFilter, tagFilter, unusedOnly, sort]) const visibleIds = visible.map(assetId) const selectedVisible = visibleIds.filter(id => selected.has(id)) const allVisibleSelected = visible.length > 0 && selectedVisible.length === visible.length const totalBytes = assets.reduce((sum, a) => sum + a.sizeBytes, 0) const filtersActive = Boolean(search || folderFilter !== 'all' || typeFilter !== 'all' || tagFilter || unusedOnly) const selectedAssets = assets.filter(a => selected.has(assetId(a))) function clearFilters() { setSearch('') setFolderFilter('all') setTypeFilter('all') setTagFilter(null) setUnusedOnly(false) } function toggleSelected(id: string) { setSelected(prev => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) } function toggleAllVisible() { setSelected(prev => { const next = new Set(prev) if (allVisibleSelected) visibleIds.forEach(id => next.delete(id)) else visibleIds.forEach(id => next.add(id)) return next }) } async function handleUpload(event: ChangeEvent) { const files = Array.from(event.target.files ?? []) event.target.value = '' if (files.length === 0) return setBusy(true) onMessage('') const [storage, folder] = uploadDest === 'local' ? ['local', 'uploads'] : ['r2', uploadDest.replace('r2:', '')] let ok = 0 const failures: string[] = [] for (const file of files) { try { if (file.size > MAX_UPLOAD_BYTES) throw new Error('over the 8 MB limit') const dataUrl = await new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => resolve(String(reader.result ?? '')) reader.onerror = () => reject(new Error('could not read file')) reader.readAsDataURL(file) }) const res = await fetch('/api/admin-assets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: file.name, dataUrl, storage, folder }), }) if (!res.ok) throw new Error(await readError(res, 'upload failed')) ok += 1 } catch (err) { failures.push(`${file.name} (${err instanceof Error ? err.message : 'failed'})`) } } await onReload().catch(() => {}) setBusy(false) onMessage(failures.length ? `Uploaded ${ok} of ${files.length}. Failed: ${failures.join('; ')}` : `Uploaded ${ok} file${ok === 1 ? '' : 's'}.`) } async function saveTags(asset: AdminAsset, tags: string[]) { try { const res = await fetch(`/api/admin-assets/${encodeURIComponent(asset.filename)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tags }), }) if (!res.ok) throw new Error(await readError(res, 'Save failed')) await onReload() } catch (err) { onMessage(err instanceof Error ? err.message : 'Save failed.') } } function addTags(asset: AdminAsset) { const id = assetId(asset) const incoming = (tagDrafts[id] ?? '').split(',').map(t => t.trim()).filter(Boolean) setTagDrafts(d => ({ ...d, [id]: '' })) if (incoming.length === 0) return const merged = [...new Set([...(asset.tags ?? []), ...incoming])] void saveTags(asset, merged) } function warnIfUsed(asset: AdminAsset, action: string) { const used = asset.usedIn ?? 0 if (used === 0) return true return confirm(`${asset.filename} is used ${used} time${used === 1 ? '' : 's'} in your site content. ${action} changes its URL, so those places will show a broken file until you re-select it. Continue?`) } async function patchAsset(asset: AdminAsset, body: Record) { const res = await fetch(`/api/admin-assets/${encodeURIComponent(asset.filename)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) if (!res.ok) throw new Error(await readError(res, 'Update failed')) } async function commitRename(asset: AdminAsset) { const value = renaming?.value.trim() ?? '' setRenaming(null) if (!value || value === asset.filename || `${value}${extOf(asset.filename)}` === asset.filename) return if (!warnIfUsed(asset, 'Renaming')) return setBusy(true) try { await patchAsset(asset, { newName: value }) await onReload() onMessage(`Renamed ${asset.filename}.`) } catch (err) { onMessage(err instanceof Error ? err.message : 'Rename failed.') } finally { setBusy(false) } } async function moveAssets(targets: AdminAsset[], folder: string) { const movable = targets.filter(a => a.storage !== 'local' && a.folder !== 'local' && a.folder !== folder) if (movable.length === 0) { onMessage('Nothing to move — local files can’t be moved, and these are already in that folder.') return } const usedCount = movable.filter(a => (a.usedIn ?? 0) > 0).length if (usedCount > 0 && !confirm(`${usedCount} of the ${movable.length} file${movable.length === 1 ? '' : 's'} you’re moving are used in site content. Moving changes their URLs, so those places will show a broken file until re-selected. Continue?`)) return setBusy(true) let ok = 0 const failures: string[] = [] for (const asset of movable) { try { await patchAsset(asset, { folder }) ok += 1 } catch (err) { failures.push(`${asset.filename} (${err instanceof Error ? err.message : 'failed'})`) } } await onReload().catch(() => {}) setBusy(false) setSelected(new Set()) setBulkMove('') onMessage(failures.length ? `Moved ${ok}. Failed: ${failures.join('; ')}` : `Moved ${ok} file${ok === 1 ? '' : 's'} to ${folder}/.`) } async function deleteAssets(targets: AdminAsset[]) { if (targets.length === 0) return const used = targets.filter(a => (a.usedIn ?? 0) > 0).length const label = targets.length === 1 ? targets[0].filename : `${targets.length} files` const warning = used > 0 ? `\n\n${used} of these ${used === 1 ? 'is' : 'are'} used in your site content and will show as broken.` : '' if (!confirm(`Permanently delete ${label}?${warning}`)) return setBusy(true) let ok = 0 for (const asset of targets) { try { const qs = asset.folder ? `?folder=${encodeURIComponent(asset.folder)}` : '' const res = await fetch(`/api/admin-assets/${encodeURIComponent(asset.filename)}${qs}`, { method: 'DELETE' }) if (res.ok) ok += 1 } catch { /* counted as failed below */ } } await onReload().catch(() => {}) setBusy(false) setSelected(new Set()) onMessage(ok === targets.length ? `Deleted ${ok} file${ok === 1 ? '' : 's'}.` : `Deleted ${ok} of ${targets.length}.`) } function copyUrl(asset: AdminAsset) { const absolute = asset.url.startsWith('http') ? asset.url : `${window.location.origin}${asset.url}` navigator.clipboard.writeText(absolute).then(() => { setCopiedId(assetId(asset)) setTimeout(() => setCopiedId(id => (id === assetId(asset) ? null : id)), 1500) }).catch(() => onMessage('Could not copy to clipboard.')) } function renderThumb(asset: AdminAsset) { if (isImage(asset.filename)) { return } return {extOf(asset.filename).replace('.', '').toUpperCase() || 'FILE'} } function renderTags(asset: AdminAsset) { const id = assetId(asset) return (
{(asset.tags ?? []).map(tag => ( ))} setTagDrafts(d => ({ ...d, [id]: e.target.value }))} onKeyDown={e => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addTags(asset) } }} onBlur={() => addTags(asset)} />
) } function renderActions(asset: AdminAsset) { const id = assetId(asset) const canMove = asset.storage !== 'local' && asset.folder !== 'local' return (
Open {canMove && ( )}
) } function renderName(asset: AdminAsset) { const id = assetId(asset) if (renaming?.id === id) { return (
setRenaming({ id, value: e.target.value })} onKeyDown={e => { if (e.key === 'Enter') void commitRename(asset) if (e.key === 'Escape') setRenaming(null) }} /> {extOf(asset.filename)}
) } return {asset.filename} } function renderMeta(asset: AdminAsset) { const used = asset.usedIn ?? 0 return (
{locationLabel(asset)} {formatSize(asset.sizeBytes)} {formatWhen(asset.updatedAt)} 0 ? 'am-used' : 'am-unused'}>{used > 0 ? `Used ${used}×` : 'Unused'}
) } return (

Asset Manager

Upload, find, tag, rename, and organize the images and files hosted on this site.

setSearch(e.target.value)} aria-label="Search assets" />
{allTags.length > 0 && (
Tags: {allTags.map(([tag, count]) => ( ))}
)}
Showing {visible.length} of {assets.length} · {formatSize(totalBytes)} total {filtersActive && }
{selected.size > 0 && (
{selected.size} selected
)} {assets.length === 0 &&

No assets uploaded yet.

} {assets.length > 0 && visible.length === 0 && (

No assets match these filters.

)} {visible.length > 0 && view === 'list' && (
    {visible.map(asset => { const id = assetId(asset) return (
  • toggleSelected(id)} aria-label={`Select ${asset.filename}`} /> {renderThumb(asset)}
    {renderName(asset)} {renderMeta(asset)}
    {renderTags(asset)}
    {renderActions(asset)}
  • ) })}
)} {visible.length > 0 && view === 'grid' && (
    {visible.map(asset => { const id = assetId(asset) return (
  • toggleSelected(id)} aria-label={`Select ${asset.filename}`} /> {renderThumb(asset)}
    {renderName(asset)} {renderMeta(asset)} {renderTags(asset)} {renderActions(asset)}
  • ) })}
)} {message &&

{message}

}
) }