From e74bd1df5d046897d260bba66212ef83fe16756b Mon Sep 17 00:00:00 2001 From: nmemmert Date: Thu, 1 Oct 2026 07:45:50 -0400 Subject: [PATCH] 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 --- package.json | 2 +- server/data.js | 6 +- server/r2.js | 11 +- server/routes/admin-assets.js | 119 ++++++-- server/routes/contact.js | 98 +++++- server/routes/inbound-email.js | 24 +- src/AdminPage.tsx | 376 ++++------------------- src/App.css | 216 +++++++++---- src/AssetManager.tsx | 533 +++++++++++++++++++++++++++++++++ src/InboxPanel.tsx | 279 +++++++++++++++++ 10 files changed, 1247 insertions(+), 417 deletions(-) create mode 100644 src/AssetManager.tsx create mode 100644 src/InboxPanel.tsx diff --git a/package.json b/package.json index 95ef252..d37e0fb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.3.2", + "version": "2.5.0", "type": "module", "scripts": { "dev": "vite", diff --git a/server/data.js b/server/data.js index 1781162..38ad3a3 100644 --- a/server/data.js +++ b/server/data.js @@ -1009,6 +1009,8 @@ export async function listUploadedAssets() { : Array.isArray(entry?.tags) ? entry.tags.filter(t => typeof t === 'string') : [] return { filename, + key: obj.Key, + storage: 'r2', url: `${R2_PUBLIC_URL}/${obj.Key}`, folder, sizeBytes: obj.Size ?? 0, @@ -1036,8 +1038,10 @@ export async function listUploadedAssets() { : Array.isArray(entry?.tags) ? entry.tags.filter(t => typeof t === 'string') : [] return { filename, + key: filename, + storage: 'local', url: `/uploads/${filename}`, - folder: 'uploads', + folder: 'local', sizeBytes: info.size, updatedAt: info.mtime.toISOString(), tags, diff --git a/server/r2.js b/server/r2.js index 7e5e5bb..39acbc7 100644 --- a/server/r2.js +++ b/server/r2.js @@ -1,4 +1,4 @@ -import { S3Client, PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3' +import { S3Client, PutObjectCommand, DeleteObjectCommand, CopyObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3' import { R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET, R2_PUBLIC_URL } from './config.js' function makeClient() { @@ -27,6 +27,15 @@ export async function deleteFromR2(key) { await makeClient().send(new DeleteObjectCommand({ Bucket: R2_BUCKET, Key: key })) } +export async function copyInR2(fromKey, toKey) { + await makeClient().send(new CopyObjectCommand({ + Bucket: R2_BUCKET, + CopySource: `${R2_BUCKET}/${fromKey.split('/').map(encodeURIComponent).join('/')}`, + Key: toKey, + })) + return `${R2_PUBLIC_URL}/${toKey}` +} + export async function listR2Objects(prefix) { const objects = [] let continuationToken diff --git a/server/routes/admin-assets.js b/server/routes/admin-assets.js index 3950f69..2f35c23 100644 --- a/server/routes/admin-assets.js +++ b/server/routes/admin-assets.js @@ -1,9 +1,9 @@ -import { mkdir, stat, unlink, writeFile } from 'node:fs/promises' +import { mkdir, rename, stat, unlink, writeFile } from 'node:fs/promises' import path from 'node:path' import { inferImageExtensionFromDataUrl, normalizeAssetBaseName } from '../helpers.js' import { requireAdminAuth } from '../auth.js' import { UPLOADS_DIR } from '../config.js' -import { isR2Configured, uploadToR2, deleteFromR2 } from '../r2.js' +import { isR2Configured, uploadToR2, deleteFromR2, copyInR2 } from '../r2.js' import { state } from '../state.js' import { listUploadedAssets, @@ -23,11 +23,22 @@ import { } from '../study-helpers.js' import { sendStudyReengagementEmail, sendStudyPasswordChangedEmail } from '../email.js' +const ALLOWED_FOLDERS = ['uploads', 'images', 'guides', 'finished'] + +function isSafeFilename(filename) { + return typeof filename === 'string' && filename.length > 0 && !filename.includes('/') && !filename.includes('\\') && !filename.includes('..') +} + export function register(app) { app.get('/api/admin-assets', requireAdminAuth, async (_req, res) => { try { const assets = await listUploadedAssets() - res.json({ assets }) + const haystack = JSON.stringify(state.cachedDraftSiteContent ?? {}) + JSON.stringify(state.cachedSiteContent ?? {}) + const withUsage = assets.map(asset => ({ + ...asset, + usedIn: asset.url ? haystack.split(asset.url).length - 1 : 0, + })) + res.json({ assets: withUsage }) } catch { res.status(500).json({ message: 'Could not list uploaded assets.' }) } @@ -41,7 +52,6 @@ export function register(app) { const rawStorage = typeof req.body?.storage === 'string' ? req.body.storage : 'r2' const ext = inferImageExtensionFromDataUrl(dataUrl) - const ALLOWED_FOLDERS = ['uploads', 'images', 'guides', 'finished'] const folder = ALLOWED_FOLDERS.includes(rawFolder) ? rawFolder : 'uploads' const useLocal = rawStorage === 'local' || !isR2Configured() @@ -84,60 +94,105 @@ export function register(app) { app.patch('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => { try { const { filename } = req.params - if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..')) { + if (!isSafeFilename(filename)) { res.status(400).json({ message: 'Invalid filename.' }) return } - const tags = Array.isArray(req.body?.tags) - ? req.body.tags.filter(tag => typeof tag === 'string').map(tag => tag.trim()).filter(Boolean) - : [] - const metadata = await readUploadsMetadata() - if (!metadata[filename]) { - // legacy local file — verify it exists on disk - const filePath = path.join(UPLOADS_DIR, filename) - await stat(filePath) - metadata[filename] = tags - } else { - const entry = metadata[filename] - metadata[filename] = typeof entry === 'object' && !Array.isArray(entry) - ? { ...entry, tags } - : tags + const current = (await listUploadedAssets()).find(a => a.filename === filename) + if (!current) { + res.status(404).json({ message: 'Asset not found.' }) + return } + + const entry = metadata[filename] + let tags = Array.isArray(entry) + ? entry + : Array.isArray(entry?.tags) ? entry.tags : [] + if (Array.isArray(req.body?.tags)) { + tags = req.body.tags.filter(tag => typeof tag === 'string').map(tag => tag.trim()).filter(Boolean) + } + + let newFilename = filename + let newUrl = current.url + let newFolder = current.folder + const ext = path.extname(filename) + + const wantsRename = typeof req.body?.newName === 'string' && req.body.newName.trim() + const wantsMove = typeof req.body?.folder === 'string' && req.body.folder !== current.folder + + if (wantsRename) { + const base = normalizeAssetBaseName(req.body.newName.trim().replace(/\.[a-z0-9]+$/i, '')) + newFilename = `${base}${ext}` + } + if (wantsMove) { + if (current.storage !== 'r2' || !ALLOWED_FOLDERS.includes(req.body.folder)) { + res.status(400).json({ message: 'Only R2 assets can be moved, to uploads, images, guides, or finished.' }) + return + } + newFolder = req.body.folder + } + + if (newFilename !== filename || newFolder !== current.folder) { + if (newFilename !== filename) { + const taken = (await listUploadedAssets()).some(a => a.filename === newFilename && a.folder === newFolder) + if (taken) { + res.status(409).json({ message: `A file named ${newFilename} already exists there.` }) + return + } + } + if (current.storage === 'r2') { + const newKey = `${newFolder}/${newFilename}` + newUrl = await copyInR2(current.key, newKey) + await deleteFromR2(current.key) + } else { + await rename(path.join(UPLOADS_DIR, filename), path.join(UPLOADS_DIR, newFilename)) + newUrl = `/uploads/${newFilename}` + } + delete metadata[filename] + } + + metadata[newFilename] = { tags, url: newUrl, folder: current.storage === 'r2' ? newFolder : 'local' } await writeUploadsMetadata(metadata) - res.json({ ok: true, tags }) - } catch { - res.status(404).json({ message: 'Asset not found.' }) + res.json({ ok: true, tags, filename: newFilename, url: newUrl, folder: newFolder, oldUrl: current.url }) + } catch (err) { + console.error('[admin-assets] update failed:', err) + res.status(500).json({ message: 'Could not update asset.' }) } }) app.delete('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => { try { const { filename } = req.params - if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..')) { + if (!isSafeFilename(filename)) { res.status(400).json({ message: 'Invalid filename.' }) return } - const metadata = await readUploadsMetadata() - const entry = metadata[filename] + // Optional ?folder= disambiguates identical filenames in different R2 folders. + const folder = typeof req.query.folder === 'string' ? req.query.folder : null + const matches = (await listUploadedAssets()).filter(a => a.filename === filename && (!folder || a.folder === folder)) + const target = matches[0] + if (!target) { + res.status(404).json({ message: 'Asset not found.' }) + return + } - if (isR2Configured() && entry && typeof entry === 'object' && !Array.isArray(entry) && entry.url) { - // R2 file — derive key from stored folder or fall back to uploads/ - const folder = entry.folder ?? 'uploads' - await deleteFromR2(`${folder}/${filename}`).catch(() => {}) + if (target.storage === 'r2') { + await deleteFromR2(target.key) } else { - // local file await unlink(path.join(UPLOADS_DIR, filename)).catch(() => {}) } + const metadata = await readUploadsMetadata() delete metadata[filename] await writeUploadsMetadata(metadata) res.json({ ok: true }) - } catch { - res.status(404).json({ message: 'Asset not found.' }) + } catch (err) { + console.error('[admin-assets] delete failed:', err) + res.status(500).json({ message: 'Delete failed.' }) } }) diff --git a/server/routes/contact.js b/server/routes/contact.js index c98ced3..dc09ffd 100644 --- a/server/routes/contact.js +++ b/server/routes/contact.js @@ -353,24 +353,52 @@ export function register(app) { app.get('/api/admin-contact-submissions', requireAdminAuth, (_req, res) => { - res.json({ submissions: state.contactSubmissions.slice(0, 300) }) + res.json({ submissions: state.contactSubmissions }) }) app.patch('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => { const { id } = req.params - const { archived } = req.body ?? {} - if (typeof archived !== 'boolean') { - res.status(400).json({ message: 'archived must be a boolean.' }); return + const { archived, read } = req.body ?? {} + if (typeof archived !== 'boolean' && typeof read !== 'boolean') { + res.status(400).json({ message: 'archived or read must be a boolean.' }); return } const idx = state.contactSubmissions.findIndex(s => s.id === id) if (idx === -1) { res.status(404).json({ message: 'Submission not found.' }); return } - state.contactSubmissions[idx] = { ...state.contactSubmissions[idx], archived } + const patch = {} + if (typeof archived === 'boolean') patch.archived = archived + if (typeof read === 'boolean') patch.read = read + state.contactSubmissions[idx] = { ...state.contactSubmissions[idx], ...patch } queueContactSubmissionsWrite() res.json({ ok: true, submission: state.contactSubmissions[idx] }) }) + app.post('/api/admin-contact-submissions/bulk', requireAdminAuth, (req, res) => { + const { ids, action } = req.body ?? {} + const actions = ['archive', 'unarchive', 'read', 'unread', 'delete'] + if (!Array.isArray(ids) || ids.length === 0 || ids.length > 500 || !ids.every(id => typeof id === 'string') || !actions.includes(action)) { + res.status(400).json({ message: 'Provide ids (1-500) and a valid action.' }); return + } + const idSet = new Set(ids) + let count = 0 + if (action === 'delete') { + const before = state.contactSubmissions.length + state.contactSubmissions = state.contactSubmissions.filter(s => !idSet.has(s.id)) + count = before - state.contactSubmissions.length + } else { + state.contactSubmissions = state.contactSubmissions.map(s => { + if (!idSet.has(s.id)) return s + count += 1 + if (action === 'archive') return { ...s, archived: true } + if (action === 'unarchive') return { ...s, archived: false } + return { ...s, read: action === 'read' } + }) + } + queueContactSubmissionsWrite() + res.json({ ok: true, count }) + }) + app.get('/api/admin-subscribers', requireAdminAuth, (_req, res) => { const seen = new Set() const subscribers = state.contactSubmissions @@ -401,6 +429,64 @@ export function register(app) { res.send(csv) }) + app.get('/api/admin-broadcast/templates', requireAdminAuth, async (_req, res) => { + if (!process.env.RESEND_API_KEY) { + res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return + } + try { + const resend = new Resend(process.env.RESEND_API_KEY) + const templates = [] + let after + for (let page = 0; page < 5; page += 1) { + const { data, error } = await resend.templates.list({ limit: 100, ...(after ? { after } : {}) }) + if (error) throw new Error(error.message) + templates.push(...(data?.data ?? [])) + if (!data?.has_more || !data.data?.length) break + after = data.data[data.data.length - 1].id + } + res.json({ + templates: templates + .map(t => ({ id: t.id, name: t.name, status: t.status, updatedAt: t.updated_at, publishedAt: t.published_at })) + .sort((a, b) => String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? ''))), + }) + } catch (err) { + console.error('[admin-broadcast] template list failed:', err) + res.status(502).json({ message: 'Could not load templates from Resend.' }) + } + }) + + app.get('/api/admin-broadcast/templates/:id', requireAdminAuth, async (req, res) => { + if (!process.env.RESEND_API_KEY) { + res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return + } + try { + const resend = new Resend(process.env.RESEND_API_KEY) + const { data, error } = await resend.templates.get(req.params.id) + if (error || !data) { + res.status(404).json({ message: error?.message || 'Template not found.' }); return + } + // Resend variables look like {{{KEY}}}; fill in their saved fallback values, but keep name for per-subscriber personalization. + let html = data.html ?? '' + for (const v of data.variables ?? []) { + if (/^(first_?name|name)$/i.test(v.key)) continue + if (v.fallback_value === null || v.fallback_value === undefined) continue + html = html.replace(new RegExp(`\\{\\{\\{\\s*${v.key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\}\\}\\}`, 'g'), escapeHtml(String(v.fallback_value))) + } + res.json({ + id: data.id, + name: data.name, + subject: data.subject ?? '', + html, + hasUnsubscribeLink: /\{\{\{\s*RESEND_UNSUBSCRIBE_URL\s*\}\}\}/i.test(html), + unresolved: [...new Set([...html.matchAll(/\{\{\{\s*([A-Za-z0-9_]+)\s*\}\}\}/g)].map(m => m[1]))] + .filter(k => !/^(RESEND_UNSUBSCRIBE_URL|first_?name|name)$/i.test(k)), + }) + } catch (err) { + console.error('[admin-broadcast] template get failed:', err) + res.status(502).json({ message: 'Could not load that template from Resend.' }) + } + }) + app.post('/api/admin-broadcast', requireAdminAuth, async (req, res) => { const { subject, html } = req.body ?? {} if (!subject || typeof subject !== 'string' || !subject.trim()) { @@ -436,7 +522,7 @@ export function register(app) { to: r.email, reply_to: replyTo, subject: subject.trim(), - html: html.replace(/\{\{name\}\}/g, escapeHtml(r.name || 'Friend')), + html: html.replace(/\{\{\{?\s*(?:first_?name|name)\s*\}?\}\}/gi, escapeHtml(r.name || 'Friend')), })) try { const { error } = await resend.batch.send(emails) diff --git a/server/routes/inbound-email.js b/server/routes/inbound-email.js index 15eb157..a982b75 100644 --- a/server/routes/inbound-email.js +++ b/server/routes/inbound-email.js @@ -6,6 +6,26 @@ import { MAX_CONTACT_SUBMISSIONS } from '../config.js' const MESSAGE_ID_RE = /^<[\x21-\x7E]+>$/ const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024 // 5 MB total per email +const CP1252_EXTRA = { + '€': 0x80, '‚': 0x82, 'ƒ': 0x83, '„': 0x84, '…': 0x85, '†': 0x86, '‡': 0x87, 'ˆ': 0x88, '‰': 0x89, 'Š': 0x8a, + '‹': 0x8b, 'Œ': 0x8c, 'Ž': 0x8e, '‘': 0x91, '’': 0x92, '“': 0x93, '”': 0x94, '•': 0x95, '–': 0x96, '—': 0x97, + '˜': 0x98, '™': 0x99, 'š': 0x9a, '›': 0x9b, 'œ': 0x9c, 'ž': 0x9e, 'Ÿ': 0x9f, +} + +// Text that was UTF-8 but got decoded as Latin-1/Windows-1252 ("’", "é", "ð") — undo that. +function repairMojibake(text) { + if (typeof text !== 'string' || !/[ÃÂâð][\u0080-\u00ff€‚ƒ„…†‡ˆ‰Š‹ŒŽ‘’“”•–—˜™š›œžŸ]/.test(text)) return text + const bytes = [] + for (const ch of text) { + const code = ch.codePointAt(0) + if (code <= 0xff) bytes.push(code) + else if (CP1252_EXTRA[ch] !== undefined) bytes.push(CP1252_EXTRA[ch]) + else return text + } + const fixed = Buffer.from(bytes).toString('utf8') + return fixed.includes('\ufffd') ? text : fixed +} + function decodeHtmlBody(raw) { if (typeof raw !== 'string' || !raw.trim()) return null const s = raw.trim() @@ -96,7 +116,7 @@ export function register(app) { } } - const subjectStr = typeof subject === 'string' ? subject.trim() : '' + const subjectStr = typeof subject === 'string' ? repairMojibake(subject.trim()) : '' const threadId = resolveThreadId(fromEmail, normalizeSubject(subjectStr), normalizedInReplyTo) const submission = { @@ -105,7 +125,7 @@ export function register(app) { submittedAt: (typeof date === 'string' || typeof date === 'number') && Number.isFinite(Date.parse(date)) ? new Date(date).toISOString() : new Date().toISOString(), name: fromName || fromEmail, email: fromEmail, - message: [subjectStr ? `Subject: ${subjectStr}` : '', body ?? ''].filter(Boolean).join('\n\n'), + message: [subjectStr ? `Subject: ${subjectStr}` : '', repairMojibake(typeof body === 'string' ? body : '')].filter(Boolean).join('\n\n'), htmlBody: decodeHtmlBody(htmlBody), messageType: 'general', subscribe: false, diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 7c200d8..1b264fd 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -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({ draftUpdatedAt: null, publishedAt: null }) const [publishConfirmOpen, setPublishConfirmOpen] = useState(false) const [assets, setAssets] = useState([]) - const [assetTagEdits, setAssetTagEdits] = useState>({}) const [opsStatus, setOpsStatus] = useState(null) - const [assetUploadPending, setAssetUploadPending] = useState(false) - const [assetUploadDest, setAssetUploadDest] = useState('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([]) - const [inboxFilter, setInboxFilter] = useState<'unread' | 'all' | 'archived'>('unread') - const [inboxSearch, setInboxSearch] = useState('') - const [selectedSubmissionId, setSelectedSubmissionId] = useState(null) - const [archivingId, setArchivingId] = useState(null) const [subscriberSearch, setSubscriberSearch] = useState('') const [downloadStats, setDownloadStats] = useState>({}) // 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>([]) + 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>((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) { - const file = event.target.files?.[0] - if (!file) return - - setAssetUploadPending(true) - setOpsMsg('') - try { - const dataUrl = await new Promise((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) { ) } - 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) { 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) { })()} {/* INBOX */} - {adminView === 'inbox' && (() => { - const messageTypeLabel: Record = { - 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 ( -
-
-

Inbox

-

{unreadEmailCount} unread · {contactSubmissions.filter(s => s.archived).length} archived · {contactSubmissions.length} total

-
- -
- setInboxSearch(e.target.value)} - placeholder="Search by name, email, or message…" - style={{ minWidth: '240px', maxWidth: '420px', width: '100%' }} - /> - - - -
- - {contactSubmissions.length === 0 ? ( -

No messages yet.

- ) : filtered.length === 0 ? ( -

No messages match this filter.

- ) : ( -
-
- {filtered.map(sub => ( -
setSelectedSubmissionId(prev => prev === sub.id ? null : sub.id)} - > -
-
-

- {sub.name} - {' · '} - e.stopPropagation()}>{sub.email} - {' · '} - {formatDate(sub.submittedAt)} -

-

- {sub.message} -

-
-
- - {messageTypeLabel[sub.messageType] ?? sub.messageType} - - {sub.archived && Archived} -
-
-
- ))} -
- - {selected && ( -
-
-
-

{selected.name}

-

- {selected.email} - {' · '} - {formatDate(selected.submittedAt)} -

-

- {messageTypeLabel[selected.messageType] ?? selected.messageType} - {selected.subscribe ? ' · subscribed' : ''} -

-
-
- - -
-
-
- {selected.message} -
- -
- )} -
- )} -
- ) - })()} + {adminView === 'inbox' && ( + + )} {/* SUBSCRIBERS */} {adminView === 'subscribers' && (() => { @@ -5950,87 +5722,7 @@ async function handleAssetUpload(event: ChangeEvent) { {/* ASSETS */} {adminView === 'assets' && ( -
-
-

Asset Manager

-

Upload, tag, and reuse hosted images from this domain.

-
-
- - -
- {assets.length === 0 &&

No assets uploaded yet.

} - {assets.length > 0 && ( -
- - - - - - - - - - - - - - {assets.map(asset => ( - - - - - - - - - - ))} - -
PreviewFilenameLocationSizeUpdatedTagsActions
- {isImageAsset(asset.filename) - ? {asset.filename} - : PDF/File} - {asset.filename} - {(asset as { folder?: string }).folder === 'local' - ? 'Local' - : (asset as { folder?: string }).folder - ? `R2: ${(asset as { folder?: string }).folder}/` - : 'R2'} - {(asset.sizeBytes / 1024).toFixed(1)} KB{formatDate(asset.updatedAt)} - setAssetTagEdits(t => ({ ...t, [asset.filename]: e.target.value }))} - onBlur={() => handleSaveAssetTags(asset.filename)} - /> - -
- - -
-
-
- )} - {opsMsg &&

{opsMsg}

} -
+ )} {/* QR CODES */} @@ -6341,6 +6033,35 @@ async function handleAssetUpload(event: ChangeEvent) { const seen = new Set() 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) {

Broadcast Email

Compose and send a one-off email to all {subscribers.length} subscribers.

+
+ +
+ + +
+ {bcTplNote &&

{bcTplNote}

} +
setBroadcastSubject(e.target.value)} placeholder="This week's episode + new study lesson" />
- +