Files
Siteforge/src/AssetManager.tsx
T
nmemmertandClaude Sonnet 5.5 e74bd1df5d
Publish Container / build (push) Successful in 16m39s
Upgrade Asset Manager and Inbox; load Resend templates in Broadcast; v2.5.0
- Asset Manager: search/filter/sort, list+grid views, rename, move between R2 folders, tag chips, bulk actions, used-in counts; fix R2 delete lookup
- Inbox: readable wrapped rows with subject/preview, real read state, type filters, bulk actions, mojibake repair
- Broadcast: pick a Resend template to fill subject and body

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 07:45:50 -04:00

534 lines
23 KiB
TypeScript
Raw 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.
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<void>
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<TypeFilter>('all')
const [tagFilter, setTagFilter] = useState<string | null>(null)
const [unusedOnly, setUnusedOnly] = useState(false)
const [sort, setSort] = useState<SortKey>('newest')
const [view, setView] = useState<ViewMode>('list')
const [selected, setSelected] = useState<Set<string>>(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<Record<string, string>>({})
const [copiedId, setCopiedId] = useState<string | null>(null)
const [bulkMove, setBulkMove] = useState('')
const folders = useMemo(() => {
const counts = new Map<string, number>()
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<string, number>()
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<HTMLInputElement>) {
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<string>((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<string, unknown>) {
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 <img src={asset.url} alt="" loading="lazy" className="am-thumb-img" />
}
return <span className="am-thumb-file">{extOf(asset.filename).replace('.', '').toUpperCase() || 'FILE'}</span>
}
function renderTags(asset: AdminAsset) {
const id = assetId(asset)
return (
<div className="am-tags">
{(asset.tags ?? []).map(tag => (
<span key={tag} className="am-tag">
<button type="button" className="am-tag-label" onClick={() => setTagFilter(tag)} title={`Filter by "${tag}"`}>{tag}</button>
<button type="button" className="am-tag-x" aria-label={`Remove tag ${tag}`} onClick={() => void saveTags(asset, (asset.tags ?? []).filter(t => t !== tag))}>×</button>
</span>
))}
<input
type="text"
className="am-tag-input"
placeholder="+ tag"
value={tagDrafts[id] ?? ''}
onChange={e => setTagDrafts(d => ({ ...d, [id]: e.target.value }))}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addTags(asset) } }}
onBlur={() => addTags(asset)}
/>
</div>
)
}
function renderActions(asset: AdminAsset) {
const id = assetId(asset)
const canMove = asset.storage !== 'local' && asset.folder !== 'local'
return (
<div className="am-actions">
<button type="button" className="btn-admin-reset btn-admin-reset--compact" onClick={() => copyUrl(asset)}>
{copiedId === id ? 'Copied ✓' : 'Copy URL'}
</button>
<a className="btn-admin-reset btn-admin-reset--compact" href={asset.url} target="_blank" rel="noreferrer">Open</a>
<button type="button" className="btn-admin-reset btn-admin-reset--compact" disabled={busy} onClick={() => setRenaming({ id, value: asset.filename.replace(/\.[a-z0-9]+$/i, '') })}>Rename</button>
{canMove && (
<select
className="am-select am-select--compact"
value=""
disabled={busy}
aria-label={`Move ${asset.filename}`}
onChange={e => {
const folder = e.target.value
if (folder && warnIfUsed(asset, 'Moving')) void moveAssets([asset], folder)
}}
>
<option value="">Move to…</option>
{MOVE_FOLDERS.filter(f => f !== asset.folder).map(f => <option key={f} value={f}>{f}/</option>)}
</select>
)}
<button type="button" className="btn-admin-remove" disabled={busy} onClick={() => void deleteAssets([asset])}>Delete</button>
</div>
)
}
function renderName(asset: AdminAsset) {
const id = assetId(asset)
if (renaming?.id === id) {
return (
<div className="am-rename">
<input
autoFocus
type="text"
className="am-rename-input"
value={renaming.value}
onChange={e => setRenaming({ id, value: e.target.value })}
onKeyDown={e => {
if (e.key === 'Enter') void commitRename(asset)
if (e.key === 'Escape') setRenaming(null)
}}
/>
<span className="am-rename-ext">{extOf(asset.filename)}</span>
<button type="button" className="btn-admin-reset btn-admin-reset--compact" onClick={() => void commitRename(asset)}>Save</button>
<button type="button" className="btn-admin-reset btn-admin-reset--compact" onClick={() => setRenaming(null)}>Cancel</button>
</div>
)
}
return <a className="am-name" href={asset.url} target="_blank" rel="noreferrer" title={asset.filename}>{asset.filename}</a>
}
function renderMeta(asset: AdminAsset) {
const used = asset.usedIn ?? 0
return (
<div className="am-meta">
<span className="am-badge">{locationLabel(asset)}</span>
<span>{formatSize(asset.sizeBytes)}</span>
<span>{formatWhen(asset.updatedAt)}</span>
<span className={used > 0 ? 'am-used' : 'am-unused'}>{used > 0 ? `Used ${used}×` : 'Unused'}</span>
</div>
)
}
return (
<section className="admin-panel-section am-root" aria-label="Asset manager">
<div className="admin-panel-head">
<h2>Asset Manager</h2>
<p>Upload, find, tag, rename, and organize the images and files hosted on this site.</p>
</div>
<div className="am-toolbar">
<input
type="search"
className="am-search"
placeholder="Search by name, folder, or tag…"
value={search}
onChange={e => setSearch(e.target.value)}
aria-label="Search assets"
/>
<select className="am-select" value={folderFilter} onChange={e => setFolderFilter(e.target.value)} aria-label="Filter by folder">
<option value="all">All folders</option>
{folders.map(([name, count]) => <option key={name} value={name}>{name === 'local' ? 'Local disk' : `${name}/`} ({count})</option>)}
</select>
<select className="am-select" value={typeFilter} onChange={e => setTypeFilter(e.target.value as TypeFilter)} aria-label="Filter by type">
<option value="all">All types</option>
<option value="image">Images</option>
<option value="pdf">PDFs</option>
<option value="doc">Word docs</option>
</select>
<select className="am-select" value={sort} onChange={e => setSort(e.target.value as SortKey)} aria-label="Sort assets">
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
<option value="name">Name A–Z</option>
<option value="size">Largest first</option>
<option value="used">Most used</option>
</select>
<div className="am-view-toggle" role="group" aria-label="View">
<button type="button" className={view === 'list' ? 'is-active' : ''} onClick={() => setView('list')}>List</button>
<button type="button" className={view === 'grid' ? 'is-active' : ''} onClick={() => setView('grid')}>Grid</button>
</div>
</div>
<div className="am-upload-bar">
<select className="am-select" value={uploadDest} onChange={e => setUploadDest(e.target.value)} disabled={busy} aria-label="Upload destination">
<option value="r2:uploads">Upload to R2 – uploads/</option>
<option value="r2:images">Upload to R2 – images/</option>
<option value="r2:guides">Upload to R2 – guides/</option>
<option value="r2:finished">Upload to R2 – finished/</option>
<option value="local">Upload to local server disk</option>
</select>
<label className="btn-admin-reset" style={{ cursor: busy ? 'wait' : 'pointer' }}>
{busy ? 'Working…' : 'Upload files'}
<input type="file" multiple accept="image/*,.pdf,.doc,.docx" style={{ display: 'none' }} onChange={handleUpload} disabled={busy} />
</label>
<label className="am-check-inline">
<input type="checkbox" checked={unusedOnly} onChange={e => setUnusedOnly(e.target.checked)} /> Unused only
</label>
</div>
{allTags.length > 0 && (
<div className="am-tagbar" aria-label="Filter by tag">
<span className="am-tagbar-label">Tags:</span>
{allTags.map(([tag, count]) => (
<button
key={tag}
type="button"
className={`am-chip${tagFilter === tag ? ' is-active' : ''}`}
onClick={() => setTagFilter(tagFilter === tag ? null : tag)}
>
{tag} <span className="am-chip-count">{count}</span>
</button>
))}
</div>
)}
<div className="am-summary">
<label className="am-check-inline">
<input type="checkbox" checked={allVisibleSelected} onChange={toggleAllVisible} disabled={visible.length === 0} /> Select all shown
</label>
<span>
Showing {visible.length} of {assets.length} · {formatSize(totalBytes)} total
</span>
{filtersActive && <button type="button" className="am-link-btn" onClick={clearFilters}>Clear filters</button>}
</div>
{selected.size > 0 && (
<div className="am-bulkbar" role="region" aria-label="Bulk actions">
<strong>{selected.size} selected</strong>
<select className="am-select am-select--compact" value={bulkMove} disabled={busy} onChange={e => { const f = e.target.value; setBulkMove(f); if (f) void moveAssets(selectedAssets, f) }} aria-label="Move selected to folder">
<option value="">Move selected to…</option>
{MOVE_FOLDERS.map(f => <option key={f} value={f}>{f}/</option>)}
</select>
<button type="button" className="btn-admin-remove" disabled={busy} onClick={() => void deleteAssets(selectedAssets)}>Delete selected</button>
<button type="button" className="btn-admin-reset btn-admin-reset--compact" onClick={() => setSelected(new Set())}>Clear selection</button>
</div>
)}
{assets.length === 0 && <p className="admin-stats-note">No assets uploaded yet.</p>}
{assets.length > 0 && visible.length === 0 && (
<p className="admin-stats-note">No assets match these filters. <button type="button" className="am-link-btn" onClick={clearFilters}>Clear filters</button></p>
)}
{visible.length > 0 && view === 'list' && (
<ul className="am-list">
{visible.map(asset => {
const id = assetId(asset)
return (
<li key={id} className={`am-row${selected.has(id) ? ' is-selected' : ''}`}>
<input type="checkbox" className="am-row-check" checked={selected.has(id)} onChange={() => toggleSelected(id)} aria-label={`Select ${asset.filename}`} />
<a className="am-thumb" href={asset.url} target="_blank" rel="noreferrer" tabIndex={-1}>{renderThumb(asset)}</a>
<div className="am-main">
{renderName(asset)}
{renderMeta(asset)}
</div>
<div className="am-tags-cell">{renderTags(asset)}</div>
<div className="am-actions-cell">{renderActions(asset)}</div>
</li>
)
})}
</ul>
)}
{visible.length > 0 && view === 'grid' && (
<ul className="am-grid">
{visible.map(asset => {
const id = assetId(asset)
return (
<li key={id} className={`am-card${selected.has(id) ? ' is-selected' : ''}`}>
<input type="checkbox" className="am-card-check" checked={selected.has(id)} onChange={() => toggleSelected(id)} aria-label={`Select ${asset.filename}`} />
<a className="am-card-thumb" href={asset.url} target="_blank" rel="noreferrer" tabIndex={-1}>{renderThumb(asset)}</a>
<div className="am-card-body">
{renderName(asset)}
{renderMeta(asset)}
{renderTags(asset)}
{renderActions(asset)}
</div>
</li>
)
})}
</ul>
)}
{message && <p className="admin-stats-note" role="status">{message}</p>}
</section>
)
}