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>
534 lines
23 KiB
TypeScript
534 lines
23 KiB
TypeScript
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>
|
||
)
|
||
}
|