Upgrade Asset Manager and Inbox; load Resend templates in Broadcast; v2.5.0
Publish Container / build (push) Successful in 16m39s

- 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>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-01 07:45:50 -04:00
1 parent 4931bfab4d
commit e74bd1df5d
10 files changed
+1247 -417

No files matched your search

+533
View File
@@ -0,0 +1,533 @@
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>
)
}