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

+54 -322
View File
@@ -2,7 +2,6 @@ import React, { useEffect, useRef, useState } from 'react'
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
import { arrayMove, SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import type { ChangeEvent } from 'react'
import { Link } from 'react-router-dom'
import type { SiteContent, CustomLink, CustomBlock, ColossiansStudySection, StudyProgram, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings, PodcastHost } from './content'
import { DEFAULTS } from './content'
@@ -10,6 +9,8 @@ import { AnalyticsPanel } from './components/AnalyticsPanel'
import { AdminCollapsibleCard } from './components/AdminCollapsibleCard'
import { useAutosave } from './hooks/useAutosave'
import QRCode from 'qrcode'
import InboxPanel, { type InboxSubmission } from './InboxPanel'
import AssetManager, { type AdminAsset } from './AssetManager'
interface SortableLessonRowProps {
@@ -624,14 +625,6 @@ export interface AdminStats {
}
}
interface AdminAsset {
filename: string
url: string
sizeBytes: number
updatedAt: string
tags?: string[]
}
interface PublishState {
draftUpdatedAt: string | null
publishedAt: string | null
@@ -659,19 +652,7 @@ interface Question {
pinned?: boolean
}
interface ContactSubmission {
id: string
submittedAt: string
name: string
email: string
message: string
messageType: 'question' | 'testimony' | 'topic' | 'general'
subscribe: boolean
archived?: boolean
source?: 'contact-form' | 'download' | 'inbound-email'
inboundTo?: string
htmlBody?: string | null
}
type ContactSubmission = InboxSubmission
interface Subscriber {
name: string
@@ -1584,10 +1565,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [publishState, setPublishState] = useState<PublishState>({ draftUpdatedAt: null, publishedAt: null })
const [publishConfirmOpen, setPublishConfirmOpen] = useState(false)
const [assets, setAssets] = useState<AdminAsset[]>([])
const [assetTagEdits, setAssetTagEdits] = useState<Record<string, string>>({})
const [opsStatus, setOpsStatus] = useState<OpsStatus | null>(null)
const [assetUploadPending, setAssetUploadPending] = useState(false)
const [assetUploadDest, setAssetUploadDest] = useState<string>('r2:uploads')
const [endSeriesOpen, setEndSeriesOpen] = useState(false)
const [endSeriesNewTitle, setEndSeriesNewTitle] = useState('')
@@ -1605,10 +1583,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [mobileNavOpen, setMobileNavOpen] = useState(false)
const [navSearch, setNavSearch] = useState('')
const [subscribers, setSubscribers] = useState<Subscriber[]>([])
const [inboxFilter, setInboxFilter] = useState<'unread' | 'all' | 'archived'>('unread')
const [inboxSearch, setInboxSearch] = useState('')
const [selectedSubmissionId, setSelectedSubmissionId] = useState<string | null>(null)
const [archivingId, setArchivingId] = useState<string | null>(null)
const [subscriberSearch, setSubscriberSearch] = useState('')
const [downloadStats, setDownloadStats] = useState<Record<string, number>>({})
// Study comments moderation state
@@ -1667,6 +1641,11 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [broadcastResult, setBroadcastResult] = useState<{ sent: number; failed: number; total: number } | null>(null)
const [broadcastMsg, setBroadcastMsg] = useState('')
const [broadcastConfirm, setBroadcastConfirm] = useState(false)
const [bcTemplates, setBcTemplates] = useState<Array<{ id: string; name: string; status: string }>>([])
const [bcTplLoaded, setBcTplLoaded] = useState(false)
const [bcTplLoading, setBcTplLoading] = useState(false)
const [bcTplId, setBcTplId] = useState('')
const [bcTplNote, setBcTplNote] = useState('')
// Newsletter archive state
interface ResendBroadcastRow { id: string; name: string; subject: string; sentAt: string | null; topicId: string | null; matchesNewEpisodeTopic: boolean; alreadyPublished: boolean }
@@ -1696,7 +1675,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
const autosaveRestoreCheckedRef = useRef(false)
const isDirty = JSON.stringify(form) !== lastSavedSnapshot
const unreadEmailCount = contactSubmissions.filter(s => !s.archived).length
const unreadEmailCount = contactSubmissions.filter(s => !s.archived && !s.read).length
const unansweredCount = questions.filter(q => !q.answer?.trim()).length
const navSearchTerm = navSearch.trim().toLowerCase()
const filteredNav: NavGroup[] = ADMIN_NAV
@@ -1985,10 +1964,6 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
const data = await r.json() as { assets?: AdminAsset[] }
const incomingAssets = Array.isArray(data.assets) ? data.assets : []
setAssets(incomingAssets)
setAssetTagEdits(incomingAssets.reduce<Record<string, string>>((memo, asset) => {
memo[asset.filename] = (asset.tags ?? []).join(', ')
return memo
}, {}))
}
async function reloadOpsStatus() {
@@ -2231,74 +2206,6 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
setForm(f => ({ ...f, podcastFeaturedLinks: (f.podcastFeaturedLinks ?? []).filter(link => link.id !== id) }))
}
async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
if (!file) return
setAssetUploadPending(true)
setOpsMsg('')
try {
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result ?? ''))
reader.onerror = () => reject(new Error('Failed to read file'))
reader.readAsDataURL(file)
})
const [storage, folder] = assetUploadDest === 'local'
? ['local', 'uploads']
: ['r2', assetUploadDest.replace('r2:', '')]
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) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { message?: string }).message ?? 'Upload failed')
}
await reloadAssets()
setOpsMsg('Asset uploaded successfully.')
} catch (err) {
setOpsMsg(err instanceof Error ? err.message : 'Upload failed.')
} finally {
setAssetUploadPending(false)
event.target.value = ''
}
}
async function handleDeleteAsset(filename: string) {
if (!confirm(`Delete asset ${filename}?`)) return
try {
const res = await fetch(`/api/admin-assets/${encodeURIComponent(filename)}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Delete failed')
await reloadAssets()
setOpsMsg('Asset deleted.')
} catch {
setOpsMsg('Asset delete failed.')
}
}
async function handleSaveAssetTags(filename: string) {
const tagsText = assetTagEdits[filename] ?? ''
const tags = tagsText.split(',').map(tag => tag.trim()).filter(Boolean)
try {
const res = await fetch(`/api/admin-assets/${encodeURIComponent(filename)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tags }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { message?: string }).message ?? 'Save failed')
}
await reloadAssets()
setOpsMsg('Asset tags saved.')
} catch (err) {
setOpsMsg(err instanceof Error ? err.message : 'Save failed.')
}
}
async function handlePurgeCache() {
try {
const res = await fetch('/api/admin-ops/purge-cache', { method: 'POST' })
@@ -2581,10 +2488,6 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
)
}
function isImageAsset(filename: string) {
return /\.(png|jpe?g|webp|gif)$/i.test(filename)
}
function confirmLeaveUnsavedChanges() {
if (!isDirty) return true
return confirm('You have unsaved changes. Leave this section without saving?')
@@ -2612,22 +2515,6 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
return null
}
async function handleArchiveSubmission(id: string, archived: boolean) {
setArchivingId(id)
try {
const res = await fetch(`/api/admin-contact-submissions/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ archived }),
})
if (!res.ok) return
setContactSubmissions(prev => prev.map(s => s.id === id ? { ...s, archived } : s))
if (archived && selectedSubmissionId === id) setSelectedSubmissionId(null)
} finally {
setArchivingId(null)
}
}
function renderAdminNavItem(view: AdminView, label: string) {
const badge = getAdminViewBadge(view)
@@ -3597,124 +3484,9 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
})()}
{/* INBOX */}
{adminView === 'inbox' && (() => {
const messageTypeLabel: Record<string, string> = {
question: 'Bible Question',
community: 'Community Question',
testimony: 'Testimony',
topic: 'Topic Request',
general: 'General Message',
}
const filtered = contactSubmissions.filter(s => {
if (inboxFilter === 'unread') return !s.archived
if (inboxFilter === 'archived') return s.archived === true
return true
}).filter(s => {
if (!inboxSearch.trim()) return true
const q = inboxSearch.toLowerCase()
return s.name.toLowerCase().includes(q) || s.email.toLowerCase().includes(q) || s.message.toLowerCase().includes(q)
})
const selected = selectedSubmissionId ? contactSubmissions.find(s => s.id === selectedSubmissionId) ?? null : null
return (
<section className="admin-panel-section" aria-label="Contact Form Inbox">
<div className="admin-panel-head">
<h2>Inbox</h2>
<p>{unreadEmailCount} unread · {contactSubmissions.filter(s => s.archived).length} archived · {contactSubmissions.length} total</p>
</div>
<div className="admin-actions admin-actions--maintenance" style={{ alignItems: 'center' }}>
<input
type="search"
value={inboxSearch}
onChange={e => setInboxSearch(e.target.value)}
placeholder="Search by name, email, or message…"
style={{ minWidth: '240px', maxWidth: '420px', width: '100%' }}
/>
<button type="button" className={`btn-admin-reset${inboxFilter === 'unread' ? ' btn-admin-reset--active' : ''}`} onClick={() => setInboxFilter('unread')}>Unread</button>
<button type="button" className={`btn-admin-reset${inboxFilter === 'all' ? ' btn-admin-reset--active' : ''}`} onClick={() => setInboxFilter('all')}>All</button>
<button type="button" className={`btn-admin-reset${inboxFilter === 'archived' ? ' btn-admin-reset--active' : ''}`} onClick={() => setInboxFilter('archived')}>Archived</button>
</div>
{contactSubmissions.length === 0 ? (
<p className="admin-stats-note">No messages yet.</p>
) : filtered.length === 0 ? (
<p className="admin-stats-note">No messages match this filter.</p>
) : (
<div style={{ display: 'grid', gridTemplateColumns: selected ? '1fr 1fr' : '1fr', gap: '1rem', alignItems: 'start' }}>
<div className="admin-questions-list" style={{ gap: '0.5rem' }}>
{filtered.map(sub => (
<div
key={sub.id}
className={`admin-question-card${selectedSubmissionId === sub.id ? ' admin-question-card--selected' : ''}`}
style={{ cursor: 'pointer' }}
onClick={() => setSelectedSubmissionId(prev => prev === sub.id ? null : sub.id)}
>
<div className="admin-question-header">
<div style={{ flex: 1, minWidth: 0 }}>
<p className="admin-question-meta">
<strong>{sub.name}</strong>
{' · '}
<a href={`mailto:${sub.email}`} onClick={e => e.stopPropagation()}>{sub.email}</a>
{' · '}
{formatDate(sub.submittedAt)}
</p>
<p className="admin-question-text" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{sub.message}
</p>
</div>
<div className="admin-question-status" style={{ flexShrink: 0 }}>
<span className="admin-badge admin-badge--pending" style={{ textTransform: 'capitalize' }}>
{messageTypeLabel[sub.messageType] ?? sub.messageType}
</span>
{sub.archived && <span className="admin-badge" style={{ background: 'var(--clr-surface-2)' }}>Archived</span>}
</div>
</div>
</div>
))}
</div>
{selected && (
<div className="admin-visits-table-wrap" style={{ position: 'sticky', top: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '1rem', marginBottom: '0.75rem' }}>
<div>
<p style={{ fontWeight: 600, margin: 0 }}>{selected.name}</p>
<p style={{ margin: '0.2rem 0 0', fontSize: '0.875rem', opacity: 0.7 }}>
<a href={`mailto:${selected.email}`}>{selected.email}</a>
{' · '}
{formatDate(selected.submittedAt)}
</p>
<p style={{ margin: '0.25rem 0 0', fontSize: '0.8rem', opacity: 0.6 }}>
{messageTypeLabel[selected.messageType] ?? selected.messageType}
{selected.subscribe ? ' · subscribed' : ''}
</p>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
<button
type="button"
className="btn-admin-reset"
disabled={archivingId === selected.id}
onClick={() => handleArchiveSubmission(selected.id, !selected.archived)}
>
{archivingId === selected.id ? '…' : selected.archived ? 'Unarchive' : 'Archive'}
</button>
<button type="button" className="btn-admin-reset" onClick={() => setSelectedSubmissionId(null)}>✕</button>
</div>
</div>
<div style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6, borderTop: '1px solid var(--clr-border)', paddingTop: '0.75rem' }}>
{selected.message}
</div>
<div style={{ marginTop: '1rem' }}>
<a href={`mailto:${selected.email}?subject=Re: Your message`} className="btn-admin-save" style={{ display: 'inline-block', textDecoration: 'none' }}>
Reply via Email
</a>
</div>
</div>
)}
</div>
)}
</section>
)
})()}
{adminView === 'inbox' && (
<InboxPanel submissions={contactSubmissions} setSubmissions={setContactSubmissions} />
)}
{/* SUBSCRIBERS */}
{adminView === 'subscribers' && (() => {
@@ -5950,87 +5722,7 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
{/* ASSETS */}
{adminView === 'assets' && (
<section className="admin-panel-section" aria-label="Asset manager">
<div className="admin-panel-head">
<h2>Asset Manager</h2>
<p>Upload, tag, and reuse hosted images from this domain.</p>
</div>
<div className="admin-actions admin-actions--maintenance">
<select
value={assetUploadDest}
onChange={e => setAssetUploadDest(e.target.value)}
disabled={assetUploadPending}
className="admin-select"
style={{ marginRight: '0.5rem' }}
>
<option value="r2:uploads">R2 – uploads/</option>
<option value="r2:images">R2 – images/</option>
<option value="r2:guides">R2 – guides/</option>
<option value="r2:finished">R2 – finished/</option>
<option value="local">Local (server disk)</option>
</select>
<label className="btn-admin-reset" style={{ cursor: 'pointer' }}>
{assetUploadPending ? 'Uploading…' : 'Upload Image or PDF'}
<input type="file" accept="image/*,.pdf" style={{ display: 'none' }} onChange={handleAssetUpload} disabled={assetUploadPending} />
</label>
</div>
{assets.length === 0 && <p className="admin-stats-note">No assets uploaded yet.</p>}
{assets.length > 0 && (
<div className="admin-visits-table-scroll">
<table className="admin-visits-table admin-assets-table">
<thead>
<tr>
<th>Preview</th>
<th>Filename</th>
<th>Location</th>
<th>Size</th>
<th>Updated</th>
<th>Tags</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{assets.map(asset => (
<tr key={asset.filename}>
<td>
{isImageAsset(asset.filename)
? <img src={asset.url} alt={asset.filename} className="admin-asset-thumb" style={{ width: '72px', height: '52px', objectFit: 'cover' }} />
: <span>PDF/File</span>}
</td>
<td>{asset.filename}</td>
<td style={{ whiteSpace: 'nowrap', fontSize: '0.8em', opacity: 0.75 }}>
{(asset as { folder?: string }).folder === 'local'
? 'Local'
: (asset as { folder?: string }).folder
? `R2: ${(asset as { folder?: string }).folder}/`
: 'R2'}
</td>
<td>{(asset.sizeBytes / 1024).toFixed(1)} KB</td>
<td>{formatDate(asset.updatedAt)}</td>
<td>
<input
type="text"
className="admin-asset-tags-input"
placeholder="Add tags…"
value={assetTagEdits[asset.filename] ?? (asset.tags ?? []).join(', ')}
onChange={e => setAssetTagEdits(t => ({ ...t, [asset.filename]: e.target.value }))}
onBlur={() => handleSaveAssetTags(asset.filename)}
/>
</td>
<td>
<div className="admin-asset-actions-inline">
<button type="button" className="btn-admin-reset btn-admin-reset--compact" onClick={() => { navigator.clipboard.writeText(asset.url).catch(() => {}) }}>Copy URL</button>
<button type="button" className="btn-admin-remove" onClick={() => handleDeleteAsset(asset.filename)}>Delete</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{opsMsg && <p className="admin-stats-note">{opsMsg}</p>}
</section>
<AssetManager assets={assets} onReload={reloadAssets} onMessage={setOpsMsg} message={opsMsg} />
)}
{/* QR CODES */}
@@ -6341,6 +6033,35 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
const seen = new Set<string>()
return subscribers.filter(s => s.email && !seen.has(s.email) && !!seen.add(s.email)).length
})()
async function loadBcTemplates() {
setBcTplLoading(true); setBcTplNote('')
try {
const r = await fetch('/api/admin-broadcast/templates')
const d = await r.json()
if (!r.ok) setBcTplNote(d.message || 'Could not load Resend templates.')
else setBcTemplates(Array.isArray(d.templates) ? d.templates : [])
} catch { setBcTplNote('Network error loading templates.') }
finally { setBcTplLoaded(true); setBcTplLoading(false) }
}
if (!bcTplLoaded && !bcTplLoading) void loadBcTemplates()
async function applyBcTemplate(id: string) {
setBcTplId(id)
if (!id) return
if ((broadcastSubject.trim() || broadcastBody.trim()) && !confirm('Replace the current subject and body with this template?')) { setBcTplId(''); return }
setBcTplNote('Loading template…')
try {
const r = await fetch(`/api/admin-broadcast/templates/${encodeURIComponent(id)}`)
const d = await r.json()
if (!r.ok) { setBcTplNote(d.message || 'Could not load that template.'); return }
setBroadcastSubject(d.subject || '')
setBroadcastBody(d.html || '')
setBroadcastConfirm(false)
const warnings: string[] = []
if (d.hasUnsubscribeLink) warnings.push('This template has Resend’s unsubscribe link, which only works in Resend-sent broadcasts — replace it before sending.')
if (Array.isArray(d.unresolved) && d.unresolved.length) warnings.push(`Unfilled variables: ${d.unresolved.join(', ')}.`)
setBcTplNote(`Loaded “${d.name}”. ${warnings.join(' ')}`.trim())
} catch { setBcTplNote('Network error loading template.') }
}
async function sendBroadcast() {
if (!broadcastSubject.trim() || !broadcastBody.trim()) {
setBroadcastMsg('Subject and body are required.'); return
@@ -6366,12 +6087,23 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
<h2>Broadcast Email</h2>
<p>Compose and send a one-off email to all {subscribers.length} subscribers.</p>
</div>
<div className="admin-field">
<label htmlFor="bc-template">Start from a Resend template</label>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
<select id="bc-template" value={bcTplId} onChange={e => void applyBcTemplate(e.target.value)} disabled={bcTplLoading} style={{ flex: '1 1 260px', minWidth: 0 }}>
<option value="">{bcTplLoading ? 'Loading templates…' : bcTemplates.length ? 'Choose a template…' : 'No templates found'}</option>
{bcTemplates.map(t => <option key={t.id} value={t.id}>{t.name}{t.status === 'draft' ? ' (draft)' : ''}</option>)}
</select>
<button type="button" className="btn-admin-reset btn-admin-reset--compact" disabled={bcTplLoading} onClick={() => void loadBcTemplates()}>Refresh</button>
</div>
{bcTplNote && <p className="admin-stats-note" style={{ marginTop: '0.4rem' }}>{bcTplNote}</p>}
</div>
<div className="admin-field">
<label htmlFor="bc-subject">Subject</label>
<input id="bc-subject" type="text" value={broadcastSubject} onChange={e => setBroadcastSubject(e.target.value)} placeholder="This week's episode + new study lesson" />
</div>
<div className="admin-field">
<label htmlFor="bc-body">Body <span style={{ fontWeight: 400, opacity: 0.6, fontSize: '0.8em' }}>(HTML — use {'{{name}}'} for personalization)</span></label>
<label htmlFor="bc-body">Body <span style={{ fontWeight: 400, opacity: 0.6, fontSize: '0.8em' }}>(HTML — {'{{name}}'} is replaced with each subscriber’s name)</span></label>
<textarea id="bc-body" rows={14} value={broadcastBody} onChange={e => setBroadcastBody(e.target.value)} placeholder={'<p>Hi {{name}},</p>\n<p>This week we covered...</p>'} style={{ fontFamily: 'monospace', fontSize: '0.85rem' }} />
</div>
{broadcastBody.trim() && (