Upgrade Asset Manager and Inbox; load Resend templates in Broadcast; v2.5.0
Publish Container / build (push) Successful in 16m39s
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:
1 parent
4931bfab4d
commit
e74bd1df5d
10 files changed
+1247
-417
No files matched your search
+54
-322
@@ -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() && (
|
||||
|
||||
Reference in new issue
Block a user