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
+1246 -416

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
"version": "2.3.2",
"version": "2.5.0",
"type": "module",
"scripts": {
"dev": "vite",
+5 -1
View File
@@ -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,
+10 -1
View File
@@ -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
+87 -32
View File
@@ -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.' })
}
})
+92 -6
View File
@@ -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)
+22 -2
View File
@@ -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,
+53 -321
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>
{adminView === 'inbox' && (
<InboxPanel submissions={contactSubmissions} setSubmissions={setContactSubmissions} />
)}
</div>
)}
</section>
)
})()}
{/* 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() && (
+164 -52
View File
@@ -8576,58 +8576,6 @@
letter-spacing: 0.04em;
}
.admin-assets-table {
table-layout: auto;
}
.admin-assets-table th,
.admin-assets-table td {
vertical-align: middle;
}
.admin-assets-table th:nth-child(1),
.admin-assets-table td:nth-child(1) {
width: 96px;
}
.admin-assets-table th:nth-child(2),
.admin-assets-table td:nth-child(2) {
min-width: 300px;
}
.admin-assets-table th:nth-child(5),
.admin-assets-table td:nth-child(5) {
min-width: 240px;
}
.admin-assets-table th:nth-child(6),
.admin-assets-table td:nth-child(6) {
min-width: 170px;
}
.admin-asset-thumb {
display: block;
border-radius: 8px;
border: 1px solid rgba(201, 168, 76, 0.25);
background: rgba(255, 255, 255, 0.03);
}
.admin-asset-tags-input {
width: 100%;
min-width: 220px;
background: #111;
border: 1px solid rgba(201, 168, 76, 0.24);
border-radius: 6px;
color: var(--brand-warm-white);
padding: 0.5rem 0.65rem;
}
.admin-asset-actions-inline {
display: flex;
gap: 0.4rem;
flex-wrap: nowrap;
}
.admin-email-layout {
display: grid;
grid-template-columns: minmax(260px, 360px) minmax(0, 1fr);
@@ -12857,3 +12805,167 @@
.study-community-shortcut-icon { width: 16px; height: 16px; fill: currentColor; flex-shrink: 0; }
.study-community-shortcut-arrow { margin-left: auto; opacity: 0.6; transition: transform 0.15s; }
.study-community-shortcut:hover .study-community-shortcut-arrow { transform: translateX(3px); opacity: 1; }
/* ── Asset Manager ── */
.am-root { max-width: none; }
.am-toolbar,
.am-upload-bar,
.am-tagbar,
.am-summary,
.am-bulkbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.8rem;
}
.am-search {
flex: 1 1 260px;
min-width: 0;
}
.am-search,
.am-select,
.am-tag-input,
.am-rename-input {
background: #111;
border: 1px solid rgba(201, 168, 76, 0.24);
border-radius: 8px;
color: var(--brand-warm-white);
padding: 0.5rem 0.7rem;
font: inherit;
font-size: 0.88rem;
}
.am-select--compact { padding: 0.4rem 0.5rem; font-size: 0.78rem; }
.am-view-toggle { display: inline-flex; border: 1px solid rgba(201, 168, 76, 0.3); border-radius: 8px; overflow: hidden; }
.am-view-toggle button { background: #111; color: var(--brand-warm-white); border: 0; padding: 0.5rem 0.9rem; cursor: pointer; font: inherit; font-size: 0.85rem; }
.am-view-toggle button.is-active { background: var(--brand-gold); color: #111; font-weight: 600; }
.am-check-inline { display: inline-flex; align-items: center; gap: 0.4rem; font-size: 0.85rem; cursor: pointer; }
.am-tagbar-label { font-size: 0.8rem; opacity: 0.7; }
.am-chip { background: #151209; border: 1px solid rgba(201, 168, 76, 0.28); color: var(--brand-warm-white); border-radius: 999px; padding: 0.2rem 0.65rem; font: inherit; font-size: 0.78rem; cursor: pointer; }
.am-chip.is-active { background: var(--brand-gold); color: #111; }
.am-chip-count { opacity: 0.6; margin-left: 0.15rem; }
.am-summary { font-size: 0.82rem; opacity: 0.85; padding: 0.4rem 0; border-top: 1px solid rgba(201, 168, 76, 0.12); }
.am-link-btn { background: none; border: 0; color: var(--brand-gold); text-decoration: underline; cursor: pointer; font: inherit; padding: 0; }
.am-bulkbar { position: sticky; top: 0; z-index: 5; background: #1a1608; border: 1px solid rgba(201, 168, 76, 0.45); border-radius: 10px; padding: 0.6rem 0.8rem; }
.am-list, .am-grid { list-style: none; margin: 0; padding: 0; }
.am-list { display: flex; flex-direction: column; gap: 0.5rem; }
.am-row {
display: grid;
grid-template-columns: auto 88px minmax(0, 1.4fr) minmax(180px, 1fr) auto;
gap: 0.9rem;
align-items: center;
padding: 0.65rem 0.8rem;
background: #0f0d09;
border: 1px solid rgba(201, 168, 76, 0.16);
border-radius: 10px;
}
.am-row.is-selected, .am-card.is-selected { border-color: var(--brand-gold); background: #17130a; }
.am-thumb, .am-card-thumb {
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(201, 168, 76, 0.22);
border-radius: 8px;
overflow: hidden;
text-decoration: none;
}
.am-thumb { width: 88px; height: 64px; }
.am-thumb-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.am-thumb-file { font-weight: 700; font-size: 0.85rem; color: var(--brand-gold); letter-spacing: 0.05em; }
.am-main { min-width: 0; display: flex; flex-direction: column; gap: 0.35rem; }
.am-name { color: var(--brand-warm-white); font-weight: 600; font-size: 0.92rem; overflow-wrap: anywhere; word-break: break-word; text-decoration: none; }
.am-name:hover { color: var(--brand-gold); text-decoration: underline; }
.am-meta { display: flex; flex-wrap: wrap; gap: 0.3rem 0.8rem; align-items: center; font-size: 0.78rem; opacity: 0.85; }
.am-badge { background: rgba(201, 168, 76, 0.14); color: var(--brand-gold); border-radius: 6px; padding: 0.1rem 0.45rem; }
.am-used { color: #7fd48a; }
.am-unused { opacity: 0.6; }
.am-tags { display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center; }
.am-tag { display: inline-flex; align-items: center; background: rgba(201, 168, 76, 0.14); border-radius: 999px; font-size: 0.75rem; }
.am-tag-label { background: none; border: 0; color: var(--brand-warm-white); padding: 0.15rem 0.2rem 0.15rem 0.6rem; cursor: pointer; font: inherit; }
.am-tag-x { background: none; border: 0; color: var(--brand-warm-white); opacity: 0.6; padding: 0.15rem 0.5rem 0.15rem 0.2rem; cursor: pointer; font: inherit; }
.am-tag-x:hover { opacity: 1; }
.am-tag-input { width: 84px; padding: 0.2rem 0.5rem; font-size: 0.75rem; }
.am-actions { display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
.am-actions a.btn-admin-reset { text-decoration: none; display: inline-flex; align-items: center; }
.am-rename { display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
.am-rename-input { flex: 1 1 160px; min-width: 0; }
.am-rename-ext { opacity: 0.7; font-size: 0.85rem; }
.am-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.9rem; }
.am-card { position: relative; display: flex; flex-direction: column; background: #0f0d09; border: 1px solid rgba(201, 168, 76, 0.16); border-radius: 12px; overflow: hidden; }
.am-card-thumb { width: 100%; height: 170px; border: 0; border-radius: 0; border-bottom: 1px solid rgba(201, 168, 76, 0.16); }
.am-card-check { position: absolute; top: 0.6rem; left: 0.6rem; z-index: 2; width: 18px; height: 18px; }
.am-card-body { display: flex; flex-direction: column; gap: 0.6rem; padding: 0.8rem; }
@media (max-width: 1100px) {
.am-row { grid-template-columns: auto 88px minmax(0, 1fr); }
.am-tags-cell, .am-actions-cell { grid-column: 2 / -1; }
}
/* ── Inbox ── */
.ib-root { max-width: none; }
.ib-toolbar, .ib-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem; margin-bottom: 0.7rem; }
.ib-search, .ib-select { background: #111; border: 1px solid rgba(201, 168, 76, 0.24); border-radius: 8px; color: var(--brand-warm-white); padding: 0.5rem 0.7rem; font: inherit; font-size: 0.88rem; }
.ib-search { flex: 1 1 280px; min-width: 0; }
.ib-tabs { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.7rem; border-bottom: 1px solid rgba(201, 168, 76, 0.15); }
.ib-tab { background: none; border: 0; border-bottom: 2px solid transparent; color: var(--brand-warm-white); opacity: 0.7; padding: 0.5rem 0.9rem; cursor: pointer; font: inherit; font-size: 0.9rem; }
.ib-tab.is-active { opacity: 1; border-bottom-color: var(--brand-gold); color: var(--brand-gold); }
.ib-tab-count { margin-left: 0.4rem; font-size: 0.75rem; background: rgba(201, 168, 76, 0.16); border-radius: 999px; padding: 0.05rem 0.45rem; }
.ib-check { display: inline-flex; align-items: center; gap: 0.4rem; font-size: 0.85rem; cursor: pointer; }
.ib-count { font-size: 0.82rem; opacity: 0.7; }
.ib-bulk { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; margin-left: auto; }
.ib-layout { display: grid; grid-template-columns: minmax(0, 1fr); gap: 1rem; align-items: start; }
.ib-layout.has-open { grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr); }
.ib-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.4rem; min-width: 0; }
.ib-item { display: flex; gap: 0.6rem; align-items: flex-start; padding: 0.7rem 0.8rem; background: #0f0d09; border: 1px solid rgba(201, 168, 76, 0.14); border-radius: 10px; min-width: 0; }
.ib-item.is-unread { border-left: 3px solid var(--brand-gold); background: #15110a; }
.ib-item.is-open { border-color: var(--brand-gold); }
.ib-item input[type="checkbox"] { margin-top: 0.3rem; flex-shrink: 0; }
.ib-item-main { all: unset; box-sizing: border-box; display: flex; flex-direction: column; gap: 0.3rem; flex: 1; min-width: 0; cursor: pointer; color: var(--brand-warm-white); }
.ib-item-top { display: flex; justify-content: space-between; gap: 0.75rem; flex-wrap: wrap; font-size: 0.82rem; }
.ib-from { font-weight: 600; overflow-wrap: anywhere; }
.ib-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--brand-gold); margin-right: 0.45rem; }
.ib-date { opacity: 0.6; white-space: nowrap; }
.ib-subject { font-size: 0.92rem; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.ib-item.is-unread .ib-subject { font-weight: 600; }
.ib-preview { font-size: 0.8rem; opacity: 0.6; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.ib-tags { display: flex; flex-wrap: wrap; gap: 0.4rem 0.6rem; align-items: center; font-size: 0.75rem; }
.ib-badge { background: rgba(201, 168, 76, 0.14); color: var(--brand-gold); border-radius: 6px; padding: 0.1rem 0.45rem; }
.ib-badge--email { background: rgba(120, 160, 255, 0.15); color: #9db8ff; }
.ib-badge--newsletter { background: rgba(127, 212, 138, 0.15); color: #7fd48a; }
.ib-addr { opacity: 0.6; overflow-wrap: anywhere; }
.ib-detail { position: sticky; top: 0.5rem; background: #0f0d09; border: 1px solid rgba(201, 168, 76, 0.3); border-radius: 12px; padding: 1rem; min-width: 0; max-height: calc(100vh - 2rem); overflow-y: auto; }
.ib-detail-head { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; }
.ib-detail-title { min-width: 0; }
.ib-detail-title h3 { margin: 0 0 0.35rem; font-size: 1.05rem; overflow-wrap: anywhere; }
.ib-detail-title p { margin: 0.15rem 0; font-size: 0.85rem; overflow-wrap: anywhere; }
.ib-detail-sub { opacity: 0.6; font-size: 0.78rem !important; }
.ib-detail-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0.8rem 0; padding-bottom: 0.8rem; border-bottom: 1px solid rgba(201, 168, 76, 0.15); align-items: center; }
.ib-detail-actions a.btn-admin-save { text-decoration: none; display: inline-block; }
.ib-body { white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.6; font-size: 0.9rem; }
.ib-frame { width: 100%; height: 520px; border: 1px solid rgba(201, 168, 76, 0.2); border-radius: 8px; background: #fff; }
@media (max-width: 1100px) {
.ib-layout.has-open { grid-template-columns: minmax(0, 1fr); }
.ib-detail { position: static; max-height: none; }
}
+533
View File
@@ -0,0 +1,533 @@
import { useMemo, useState } from 'react'
import type { ChangeEvent } from 'react'
export interface AdminAsset {
filename: string
key?: string
storage?: 'r2' | 'local'
url: string
folder?: string
sizeBytes: number
updatedAt: string | null
tags?: string[]
usedIn?: number
}
interface AssetManagerProps {
assets: AdminAsset[]
onReload: () => Promise<void>
onMessage: (message: string) => void
message: string
}
type SortKey = 'newest' | 'oldest' | 'name' | 'size' | 'used'
type TypeFilter = 'all' | 'image' | 'pdf' | 'doc'
type ViewMode = 'list' | 'grid'
const MOVE_FOLDERS = ['uploads', 'images', 'guides', 'finished']
const MAX_UPLOAD_BYTES = 8 * 1024 * 1024
const assetId = (a: AdminAsset) => a.key ?? `${a.folder ?? ''}/${a.filename}`
const isImage = (name: string) => /\.(png|jpe?g|webp|gif)$/i.test(name)
const isPdf = (name: string) => /\.pdf$/i.test(name)
const fileKind = (name: string): TypeFilter => (isImage(name) ? 'image' : isPdf(name) ? 'pdf' : 'doc')
const extOf = (name: string) => (name.match(/\.[a-z0-9]+$/i)?.[0] ?? '').toLowerCase()
function formatSize(bytes: number) {
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
return `${Math.max(bytes / 1024, 0.1).toFixed(1)} KB`
}
function formatWhen(value: string | null) {
if (!value) return '—'
const d = new Date(value)
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
function locationLabel(a: AdminAsset) {
if (a.storage === 'local' || a.folder === 'local') return 'Local disk'
return a.folder ? `R2 / ${a.folder}` : 'R2'
}
async function readError(res: Response, fallback: string) {
const data = await res.json().catch(() => ({})) as { message?: string }
return data.message ?? fallback
}
export default function AssetManager({ assets, onReload, onMessage, message }: AssetManagerProps) {
const [search, setSearch] = useState('')
const [folderFilter, setFolderFilter] = useState('all')
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all')
const [tagFilter, setTagFilter] = useState<string | null>(null)
const [unusedOnly, setUnusedOnly] = useState(false)
const [sort, setSort] = useState<SortKey>('newest')
const [view, setView] = useState<ViewMode>('list')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [busy, setBusy] = useState(false)
const [uploadDest, setUploadDest] = useState('r2:uploads')
const [renaming, setRenaming] = useState<{ id: string; value: string } | null>(null)
const [tagDrafts, setTagDrafts] = useState<Record<string, string>>({})
const [copiedId, setCopiedId] = useState<string | null>(null)
const [bulkMove, setBulkMove] = useState('')
const folders = useMemo(() => {
const counts = new Map<string, number>()
for (const a of assets) {
const key = a.storage === 'local' || a.folder === 'local' ? 'local' : (a.folder || 'r2')
counts.set(key, (counts.get(key) ?? 0) + 1)
}
return [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0]))
}, [assets])
const allTags = useMemo(() => {
const counts = new Map<string, number>()
for (const a of assets) for (const t of a.tags ?? []) counts.set(t, (counts.get(t) ?? 0) + 1)
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
}, [assets])
const visible = useMemo(() => {
const q = search.trim().toLowerCase()
const list = assets.filter(a => {
const folderKey = a.storage === 'local' || a.folder === 'local' ? 'local' : (a.folder || 'r2')
if (folderFilter !== 'all' && folderKey !== folderFilter) return false
if (typeFilter !== 'all' && fileKind(a.filename) !== typeFilter) return false
if (tagFilter && !(a.tags ?? []).includes(tagFilter)) return false
if (unusedOnly && (a.usedIn ?? 0) > 0) return false
if (q) {
const haystack = `${a.filename} ${a.folder ?? ''} ${(a.tags ?? []).join(' ')}`.toLowerCase()
if (!q.split(/\s+/).every(word => haystack.includes(word))) return false
}
return true
})
const dateOf = (a: AdminAsset) => a.updatedAt ?? ''
list.sort((a, b) => {
switch (sort) {
case 'oldest': return dateOf(a).localeCompare(dateOf(b))
case 'name': return a.filename.localeCompare(b.filename, undefined, { numeric: true })
case 'size': return b.sizeBytes - a.sizeBytes
case 'used': return (b.usedIn ?? 0) - (a.usedIn ?? 0)
default: return dateOf(b).localeCompare(dateOf(a))
}
})
return list
}, [assets, search, folderFilter, typeFilter, tagFilter, unusedOnly, sort])
const visibleIds = visible.map(assetId)
const selectedVisible = visibleIds.filter(id => selected.has(id))
const allVisibleSelected = visible.length > 0 && selectedVisible.length === visible.length
const totalBytes = assets.reduce((sum, a) => sum + a.sizeBytes, 0)
const filtersActive = Boolean(search || folderFilter !== 'all' || typeFilter !== 'all' || tagFilter || unusedOnly)
const selectedAssets = assets.filter(a => selected.has(assetId(a)))
function clearFilters() {
setSearch('')
setFolderFilter('all')
setTypeFilter('all')
setTagFilter(null)
setUnusedOnly(false)
}
function toggleSelected(id: string) {
setSelected(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function toggleAllVisible() {
setSelected(prev => {
const next = new Set(prev)
if (allVisibleSelected) visibleIds.forEach(id => next.delete(id))
else visibleIds.forEach(id => next.add(id))
return next
})
}
async function handleUpload(event: ChangeEvent<HTMLInputElement>) {
const files = Array.from(event.target.files ?? [])
event.target.value = ''
if (files.length === 0) return
setBusy(true)
onMessage('')
const [storage, folder] = uploadDest === 'local' ? ['local', 'uploads'] : ['r2', uploadDest.replace('r2:', '')]
let ok = 0
const failures: string[] = []
for (const file of files) {
try {
if (file.size > MAX_UPLOAD_BYTES) throw new Error('over the 8 MB limit')
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result ?? ''))
reader.onerror = () => reject(new Error('could not read file'))
reader.readAsDataURL(file)
})
const res = await fetch('/api/admin-assets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: file.name, dataUrl, storage, folder }),
})
if (!res.ok) throw new Error(await readError(res, 'upload failed'))
ok += 1
} catch (err) {
failures.push(`${file.name} (${err instanceof Error ? err.message : 'failed'})`)
}
}
await onReload().catch(() => {})
setBusy(false)
onMessage(failures.length
? `Uploaded ${ok} of ${files.length}. Failed: ${failures.join('; ')}`
: `Uploaded ${ok} file${ok === 1 ? '' : 's'}.`)
}
async function saveTags(asset: AdminAsset, tags: string[]) {
try {
const res = await fetch(`/api/admin-assets/${encodeURIComponent(asset.filename)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tags }),
})
if (!res.ok) throw new Error(await readError(res, 'Save failed'))
await onReload()
} catch (err) {
onMessage(err instanceof Error ? err.message : 'Save failed.')
}
}
function addTags(asset: AdminAsset) {
const id = assetId(asset)
const incoming = (tagDrafts[id] ?? '').split(',').map(t => t.trim()).filter(Boolean)
setTagDrafts(d => ({ ...d, [id]: '' }))
if (incoming.length === 0) return
const merged = [...new Set([...(asset.tags ?? []), ...incoming])]
void saveTags(asset, merged)
}
function warnIfUsed(asset: AdminAsset, action: string) {
const used = asset.usedIn ?? 0
if (used === 0) return true
return confirm(`${asset.filename} is used ${used} time${used === 1 ? '' : 's'} in your site content. ${action} changes its URL, so those places will show a broken file until you re-select it. Continue?`)
}
async function patchAsset(asset: AdminAsset, body: Record<string, unknown>) {
const res = await fetch(`/api/admin-assets/${encodeURIComponent(asset.filename)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) throw new Error(await readError(res, 'Update failed'))
}
async function commitRename(asset: AdminAsset) {
const value = renaming?.value.trim() ?? ''
setRenaming(null)
if (!value || value === asset.filename || `${value}${extOf(asset.filename)}` === asset.filename) return
if (!warnIfUsed(asset, 'Renaming')) return
setBusy(true)
try {
await patchAsset(asset, { newName: value })
await onReload()
onMessage(`Renamed ${asset.filename}.`)
} catch (err) {
onMessage(err instanceof Error ? err.message : 'Rename failed.')
} finally {
setBusy(false)
}
}
async function moveAssets(targets: AdminAsset[], folder: string) {
const movable = targets.filter(a => a.storage !== 'local' && a.folder !== 'local' && a.folder !== folder)
if (movable.length === 0) {
onMessage('Nothing to move — local files can’t be moved, and these are already in that folder.')
return
}
const usedCount = movable.filter(a => (a.usedIn ?? 0) > 0).length
if (usedCount > 0 && !confirm(`${usedCount} of the ${movable.length} file${movable.length === 1 ? '' : 's'} you’re moving are used in site content. Moving changes their URLs, so those places will show a broken file until re-selected. Continue?`)) return
setBusy(true)
let ok = 0
const failures: string[] = []
for (const asset of movable) {
try {
await patchAsset(asset, { folder })
ok += 1
} catch (err) {
failures.push(`${asset.filename} (${err instanceof Error ? err.message : 'failed'})`)
}
}
await onReload().catch(() => {})
setBusy(false)
setSelected(new Set())
setBulkMove('')
onMessage(failures.length ? `Moved ${ok}. Failed: ${failures.join('; ')}` : `Moved ${ok} file${ok === 1 ? '' : 's'} to ${folder}/.`)
}
async function deleteAssets(targets: AdminAsset[]) {
if (targets.length === 0) return
const used = targets.filter(a => (a.usedIn ?? 0) > 0).length
const label = targets.length === 1 ? targets[0].filename : `${targets.length} files`
const warning = used > 0 ? `\n\n${used} of these ${used === 1 ? 'is' : 'are'} used in your site content and will show as broken.` : ''
if (!confirm(`Permanently delete ${label}?${warning}`)) return
setBusy(true)
let ok = 0
for (const asset of targets) {
try {
const qs = asset.folder ? `?folder=${encodeURIComponent(asset.folder)}` : ''
const res = await fetch(`/api/admin-assets/${encodeURIComponent(asset.filename)}${qs}`, { method: 'DELETE' })
if (res.ok) ok += 1
} catch { /* counted as failed below */ }
}
await onReload().catch(() => {})
setBusy(false)
setSelected(new Set())
onMessage(ok === targets.length ? `Deleted ${ok} file${ok === 1 ? '' : 's'}.` : `Deleted ${ok} of ${targets.length}.`)
}
function copyUrl(asset: AdminAsset) {
const absolute = asset.url.startsWith('http') ? asset.url : `${window.location.origin}${asset.url}`
navigator.clipboard.writeText(absolute).then(() => {
setCopiedId(assetId(asset))
setTimeout(() => setCopiedId(id => (id === assetId(asset) ? null : id)), 1500)
}).catch(() => onMessage('Could not copy to clipboard.'))
}
function renderThumb(asset: AdminAsset) {
if (isImage(asset.filename)) {
return <img src={asset.url} alt="" loading="lazy" className="am-thumb-img" />
}
return <span className="am-thumb-file">{extOf(asset.filename).replace('.', '').toUpperCase() || 'FILE'}</span>
}
function renderTags(asset: AdminAsset) {
const id = assetId(asset)
return (
<div className="am-tags">
{(asset.tags ?? []).map(tag => (
<span key={tag} className="am-tag">
<button type="button" className="am-tag-label" onClick={() => setTagFilter(tag)} title={`Filter by "${tag}"`}>{tag}</button>
<button type="button" className="am-tag-x" aria-label={`Remove tag ${tag}`} onClick={() => void saveTags(asset, (asset.tags ?? []).filter(t => t !== tag))}>×</button>
</span>
))}
<input
type="text"
className="am-tag-input"
placeholder="+ tag"
value={tagDrafts[id] ?? ''}
onChange={e => setTagDrafts(d => ({ ...d, [id]: e.target.value }))}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addTags(asset) } }}
onBlur={() => addTags(asset)}
/>
</div>
)
}
function renderActions(asset: AdminAsset) {
const id = assetId(asset)
const canMove = asset.storage !== 'local' && asset.folder !== 'local'
return (
<div className="am-actions">
<button type="button" className="btn-admin-reset btn-admin-reset--compact" onClick={() => copyUrl(asset)}>
{copiedId === id ? 'Copied ✓' : 'Copy URL'}
</button>
<a className="btn-admin-reset btn-admin-reset--compact" href={asset.url} target="_blank" rel="noreferrer">Open</a>
<button type="button" className="btn-admin-reset btn-admin-reset--compact" disabled={busy} onClick={() => setRenaming({ id, value: asset.filename.replace(/\.[a-z0-9]+$/i, '') })}>Rename</button>
{canMove && (
<select
className="am-select am-select--compact"
value=""
disabled={busy}
aria-label={`Move ${asset.filename}`}
onChange={e => {
const folder = e.target.value
if (folder && warnIfUsed(asset, 'Moving')) void moveAssets([asset], folder)
}}
>
<option value="">Move to…</option>
{MOVE_FOLDERS.filter(f => f !== asset.folder).map(f => <option key={f} value={f}>{f}/</option>)}
</select>
)}
<button type="button" className="btn-admin-remove" disabled={busy} onClick={() => void deleteAssets([asset])}>Delete</button>
</div>
)
}
function renderName(asset: AdminAsset) {
const id = assetId(asset)
if (renaming?.id === id) {
return (
<div className="am-rename">
<input
autoFocus
type="text"
className="am-rename-input"
value={renaming.value}
onChange={e => setRenaming({ id, value: e.target.value })}
onKeyDown={e => {
if (e.key === 'Enter') void commitRename(asset)
if (e.key === 'Escape') setRenaming(null)
}}
/>
<span className="am-rename-ext">{extOf(asset.filename)}</span>
<button type="button" className="btn-admin-reset btn-admin-reset--compact" onClick={() => void commitRename(asset)}>Save</button>
<button type="button" className="btn-admin-reset btn-admin-reset--compact" onClick={() => setRenaming(null)}>Cancel</button>
</div>
)
}
return <a className="am-name" href={asset.url} target="_blank" rel="noreferrer" title={asset.filename}>{asset.filename}</a>
}
function renderMeta(asset: AdminAsset) {
const used = asset.usedIn ?? 0
return (
<div className="am-meta">
<span className="am-badge">{locationLabel(asset)}</span>
<span>{formatSize(asset.sizeBytes)}</span>
<span>{formatWhen(asset.updatedAt)}</span>
<span className={used > 0 ? 'am-used' : 'am-unused'}>{used > 0 ? `Used ${used}×` : 'Unused'}</span>
</div>
)
}
return (
<section className="admin-panel-section am-root" aria-label="Asset manager">
<div className="admin-panel-head">
<h2>Asset Manager</h2>
<p>Upload, find, tag, rename, and organize the images and files hosted on this site.</p>
</div>
<div className="am-toolbar">
<input
type="search"
className="am-search"
placeholder="Search by name, folder, or tag…"
value={search}
onChange={e => setSearch(e.target.value)}
aria-label="Search assets"
/>
<select className="am-select" value={folderFilter} onChange={e => setFolderFilter(e.target.value)} aria-label="Filter by folder">
<option value="all">All folders</option>
{folders.map(([name, count]) => <option key={name} value={name}>{name === 'local' ? 'Local disk' : `${name}/`} ({count})</option>)}
</select>
<select className="am-select" value={typeFilter} onChange={e => setTypeFilter(e.target.value as TypeFilter)} aria-label="Filter by type">
<option value="all">All types</option>
<option value="image">Images</option>
<option value="pdf">PDFs</option>
<option value="doc">Word docs</option>
</select>
<select className="am-select" value={sort} onChange={e => setSort(e.target.value as SortKey)} aria-label="Sort assets">
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
<option value="name">Name A–Z</option>
<option value="size">Largest first</option>
<option value="used">Most used</option>
</select>
<div className="am-view-toggle" role="group" aria-label="View">
<button type="button" className={view === 'list' ? 'is-active' : ''} onClick={() => setView('list')}>List</button>
<button type="button" className={view === 'grid' ? 'is-active' : ''} onClick={() => setView('grid')}>Grid</button>
</div>
</div>
<div className="am-upload-bar">
<select className="am-select" value={uploadDest} onChange={e => setUploadDest(e.target.value)} disabled={busy} aria-label="Upload destination">
<option value="r2:uploads">Upload to R2 – uploads/</option>
<option value="r2:images">Upload to R2 – images/</option>
<option value="r2:guides">Upload to R2 – guides/</option>
<option value="r2:finished">Upload to R2 – finished/</option>
<option value="local">Upload to local server disk</option>
</select>
<label className="btn-admin-reset" style={{ cursor: busy ? 'wait' : 'pointer' }}>
{busy ? 'Working…' : 'Upload files'}
<input type="file" multiple accept="image/*,.pdf,.doc,.docx" style={{ display: 'none' }} onChange={handleUpload} disabled={busy} />
</label>
<label className="am-check-inline">
<input type="checkbox" checked={unusedOnly} onChange={e => setUnusedOnly(e.target.checked)} /> Unused only
</label>
</div>
{allTags.length > 0 && (
<div className="am-tagbar" aria-label="Filter by tag">
<span className="am-tagbar-label">Tags:</span>
{allTags.map(([tag, count]) => (
<button
key={tag}
type="button"
className={`am-chip${tagFilter === tag ? ' is-active' : ''}`}
onClick={() => setTagFilter(tagFilter === tag ? null : tag)}
>
{tag} <span className="am-chip-count">{count}</span>
</button>
))}
</div>
)}
<div className="am-summary">
<label className="am-check-inline">
<input type="checkbox" checked={allVisibleSelected} onChange={toggleAllVisible} disabled={visible.length === 0} /> Select all shown
</label>
<span>
Showing {visible.length} of {assets.length} · {formatSize(totalBytes)} total
</span>
{filtersActive && <button type="button" className="am-link-btn" onClick={clearFilters}>Clear filters</button>}
</div>
{selected.size > 0 && (
<div className="am-bulkbar" role="region" aria-label="Bulk actions">
<strong>{selected.size} selected</strong>
<select className="am-select am-select--compact" value={bulkMove} disabled={busy} onChange={e => { const f = e.target.value; setBulkMove(f); if (f) void moveAssets(selectedAssets, f) }} aria-label="Move selected to folder">
<option value="">Move selected to…</option>
{MOVE_FOLDERS.map(f => <option key={f} value={f}>{f}/</option>)}
</select>
<button type="button" className="btn-admin-remove" disabled={busy} onClick={() => void deleteAssets(selectedAssets)}>Delete selected</button>
<button type="button" className="btn-admin-reset btn-admin-reset--compact" onClick={() => setSelected(new Set())}>Clear selection</button>
</div>
)}
{assets.length === 0 && <p className="admin-stats-note">No assets uploaded yet.</p>}
{assets.length > 0 && visible.length === 0 && (
<p className="admin-stats-note">No assets match these filters. <button type="button" className="am-link-btn" onClick={clearFilters}>Clear filters</button></p>
)}
{visible.length > 0 && view === 'list' && (
<ul className="am-list">
{visible.map(asset => {
const id = assetId(asset)
return (
<li key={id} className={`am-row${selected.has(id) ? ' is-selected' : ''}`}>
<input type="checkbox" className="am-row-check" checked={selected.has(id)} onChange={() => toggleSelected(id)} aria-label={`Select ${asset.filename}`} />
<a className="am-thumb" href={asset.url} target="_blank" rel="noreferrer" tabIndex={-1}>{renderThumb(asset)}</a>
<div className="am-main">
{renderName(asset)}
{renderMeta(asset)}
</div>
<div className="am-tags-cell">{renderTags(asset)}</div>
<div className="am-actions-cell">{renderActions(asset)}</div>
</li>
)
})}
</ul>
)}
{visible.length > 0 && view === 'grid' && (
<ul className="am-grid">
{visible.map(asset => {
const id = assetId(asset)
return (
<li key={id} className={`am-card${selected.has(id) ? ' is-selected' : ''}`}>
<input type="checkbox" className="am-card-check" checked={selected.has(id)} onChange={() => toggleSelected(id)} aria-label={`Select ${asset.filename}`} />
<a className="am-card-thumb" href={asset.url} target="_blank" rel="noreferrer" tabIndex={-1}>{renderThumb(asset)}</a>
<div className="am-card-body">
{renderName(asset)}
{renderMeta(asset)}
{renderTags(asset)}
{renderActions(asset)}
</div>
</li>
)
})}
</ul>
)}
{message && <p className="admin-stats-note" role="status">{message}</p>}
</section>
)
}
+279
View File
@@ -0,0 +1,279 @@
import { useMemo, useState } from 'react'
export interface InboxSubmission {
id: string
submittedAt: string
name: string
email: string
message: string
messageType: string
subscribe: boolean
archived?: boolean
read?: boolean
source?: 'contact-form' | 'download' | 'inbound-email'
inboundTo?: string
htmlBody?: string | null
}
interface InboxPanelProps {
submissions: InboxSubmission[]
setSubmissions: React.Dispatch<React.SetStateAction<InboxSubmission[]>>
}
type Tab = 'inbox' | 'unread' | 'archived' | 'all'
type Kind = 'email' | 'newsletter' | 'download' | 'message'
const TYPE_LABEL: Record<string, string> = {
question: 'Bible Question',
community: 'Community Question',
testimony: 'Testimony',
topic: 'Topic Request',
general: 'General Message',
}
const KIND_LABEL: Record<Kind, string> = { email: 'Email', newsletter: 'Newsletter signup', download: 'Download request', message: 'Contact form' }
const CP1252: Record<string, number> = {
'€': 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,
}
// Earlier inbound emails were stored with UTF-8 text decoded as Latin-1 ("’", "é"); undo that for display.
function fixMojibake(text: string) {
if (!/[ÃÂâð][\u0080-ÿ€‚ƒ„…†‡ˆ‰Š‹ŒŽ‘’“”•–—˜™š›œžŸ]/.test(text)) return text
const bytes: number[] = []
for (const ch of text) {
const code = ch.codePointAt(0) ?? 0
if (code <= 0xff) bytes.push(code)
else if (CP1252[ch] !== undefined) bytes.push(CP1252[ch])
else return text
}
try {
return new TextDecoder('utf-8', { fatal: true }).decode(new Uint8Array(bytes))
} catch {
return text
}
}
function kindOf(s: InboxSubmission): Kind {
if (s.source === 'inbound-email') return 'email'
if (/^newsletter signup/i.test(s.message)) return 'newsletter'
if (s.source === 'download' || /^requested\b/i.test(s.message)) return 'download'
return 'message'
}
function parse(s: InboxSubmission) {
const text = fixMojibake(s.message ?? '')
const m = /^Subject:\s*(.*?)(?:\r?\n\s*\r?\n|\r?\n)([\s\S]*)$/.exec(text)
if (m) return { subject: m[1].trim() || '(no subject)', body: m[2].trim() }
const single = /^Subject:\s*(.*)$/.exec(text)
if (single) return { subject: single[1].trim() || '(no subject)', body: '' }
const [first, ...rest] = text.split(/\r?\n/)
return { subject: first.trim() || '(no subject)', body: rest.join('\n').trim() }
}
function fmt(value: string) {
const d = new Date(value)
return Number.isNaN(d.getTime()) ? '' : d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
}
export default function InboxPanel({ submissions, setSubmissions }: InboxPanelProps) {
const [tab, setTab] = useState<Tab>('inbox')
const [kind, setKind] = useState<'all' | Kind>('all')
const [search, setSearch] = useState('')
const [sort, setSort] = useState<'newest' | 'oldest' | 'sender'>('newest')
const [openId, setOpenId] = useState<string | null>(null)
const [checked, setChecked] = useState<Set<string>>(new Set())
const [busy, setBusy] = useState(false)
const [formatted, setFormatted] = useState(false)
const [note, setNote] = useState('')
const rows = useMemo(() => submissions.map(s => ({ s, kind: kindOf(s), ...parse(s) })), [submissions])
const unread = submissions.filter(s => !s.archived && !s.read).length
const archivedCount = submissions.filter(s => s.archived).length
const kindCounts = useMemo(() => {
const c: Record<string, number> = {}
for (const r of rows) c[r.kind] = (c[r.kind] ?? 0) + 1
return c
}, [rows])
const visible = useMemo(() => {
const q = search.trim().toLowerCase().split(/\s+/).filter(Boolean)
const list = rows.filter(r => {
const { s } = r
if (tab === 'inbox' && s.archived) return false
if (tab === 'unread' && (s.archived || s.read)) return false
if (tab === 'archived' && !s.archived) return false
if (kind !== 'all' && r.kind !== kind) return false
if (q.length) {
const hay = `${s.name} ${s.email} ${r.subject} ${r.body}`.toLowerCase()
if (!q.every(w => hay.includes(w))) return false
}
return true
})
list.sort((a, b) => {
if (sort === 'sender') return a.s.name.localeCompare(b.s.name)
const d = new Date(a.s.submittedAt).getTime() - new Date(b.s.submittedAt).getTime()
return sort === 'oldest' ? d : -d
})
return list
}, [rows, tab, kind, search, sort])
const open = openId ? rows.find(r => r.s.id === openId) ?? null : null
const visibleIds = visible.map(r => r.s.id)
const allChecked = visible.length > 0 && visibleIds.every(id => checked.has(id))
async function bulk(ids: string[], action: 'archive' | 'unarchive' | 'read' | 'unread' | 'delete') {
if (ids.length === 0) return
if (action === 'delete' && !confirm(`Permanently delete ${ids.length} message${ids.length === 1 ? '' : 's'}? This can’t be undone.`)) return
setBusy(true)
setNote('')
try {
const res = await fetch('/api/admin-contact-submissions/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids, action }),
})
if (!res.ok) throw new Error('failed')
const idSet = new Set(ids)
setSubmissions(prev => action === 'delete'
? prev.filter(s => !idSet.has(s.id))
: prev.map(s => !idSet.has(s.id) ? s : action === 'archive' ? { ...s, archived: true } : action === 'unarchive' ? { ...s, archived: false } : { ...s, read: action === 'read' }))
setChecked(prev => { const n = new Set(prev); ids.forEach(id => n.delete(id)); return n })
if (openId && idSet.has(openId) && (action === 'delete' || action === 'archive')) setOpenId(null)
} catch {
setNote('That action failed. Please try again.')
} finally {
setBusy(false)
}
}
function openMessage(id: string) {
setFormatted(false)
if (openId === id) { setOpenId(null); return }
setOpenId(id)
const sub = submissions.find(s => s.id === id)
if (sub && !sub.read) void bulk([id], 'read')
}
const tabs: Array<[Tab, string, number | null]> = [
['inbox', 'Inbox', submissions.length - archivedCount],
['unread', 'Unread', unread],
['archived', 'Archived', archivedCount],
['all', 'All', submissions.length],
]
const selectedIds = [...checked].filter(id => submissions.some(s => s.id === id))
return (
<section className="admin-panel-section ib-root" aria-label="Inbox">
<div className="admin-panel-head">
<h2>Inbox</h2>
<p>{unread} unread · {archivedCount} archived · {submissions.length} total</p>
</div>
<div className="ib-toolbar">
<input type="search" className="ib-search" placeholder="Search sender, subject, or message…" value={search} onChange={e => setSearch(e.target.value)} aria-label="Search inbox" />
<select className="ib-select" value={kind} onChange={e => setKind(e.target.value as 'all' | Kind)} aria-label="Filter by type">
<option value="all">All types</option>
{(Object.keys(KIND_LABEL) as Kind[]).map(k => <option key={k} value={k}>{KIND_LABEL[k]} ({kindCounts[k] ?? 0})</option>)}
</select>
<select className="ib-select" value={sort} onChange={e => setSort(e.target.value as typeof sort)} aria-label="Sort">
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
<option value="sender">Sender A–Z</option>
</select>
</div>
<div className="ib-tabs" role="tablist">
{tabs.map(([id, label, count]) => (
<button key={id} type="button" role="tab" aria-selected={tab === id} className={`ib-tab${tab === id ? ' is-active' : ''}`} onClick={() => setTab(id)}>
{label}{count !== null && <span className="ib-tab-count">{count}</span>}
</button>
))}
</div>
<div className="ib-bar">
<label className="ib-check">
<input type="checkbox" checked={allChecked} disabled={visible.length === 0} onChange={() => setChecked(prev => { const n = new Set(prev); visibleIds.forEach(id => allChecked ? n.delete(id) : n.add(id)); return n })} />
Select all shown
</label>
<span className="ib-count">{visible.length} shown</span>
{selectedIds.length > 0 && (
<div className="ib-bulk">
<strong>{selectedIds.length} selected</strong>
<button type="button" className="btn-admin-reset btn-admin-reset--compact" disabled={busy} onClick={() => void bulk(selectedIds, 'read')}>Mark read</button>
<button type="button" className="btn-admin-reset btn-admin-reset--compact" disabled={busy} onClick={() => void bulk(selectedIds, 'unread')}>Mark unread</button>
{tab === 'archived'
? <button type="button" className="btn-admin-reset btn-admin-reset--compact" disabled={busy} onClick={() => void bulk(selectedIds, 'unarchive')}>Unarchive</button>
: <button type="button" className="btn-admin-reset btn-admin-reset--compact" disabled={busy} onClick={() => void bulk(selectedIds, 'archive')}>Archive</button>}
<button type="button" className="btn-admin-remove" disabled={busy} onClick={() => void bulk(selectedIds, 'delete')}>Delete</button>
</div>
)}
{unread > 0 && tab !== 'archived' && selectedIds.length === 0 && (
<button type="button" className="am-link-btn" disabled={busy} onClick={() => void bulk(submissions.filter(s => !s.archived && !s.read).map(s => s.id), 'read')}>Mark all read</button>
)}
</div>
{note && <p className="admin-stats-note" role="status">{note}</p>}
{submissions.length === 0 && <p className="admin-stats-note">No messages yet.</p>}
{submissions.length > 0 && visible.length === 0 && <p className="admin-stats-note">No messages match these filters.</p>}
<div className={`ib-layout${open ? ' has-open' : ''}`}>
<ul className="ib-list">
{visible.map(r => {
const { s } = r
const isUnread = !s.read && !s.archived
return (
<li key={s.id} className={`ib-item${isUnread ? ' is-unread' : ''}${openId === s.id ? ' is-open' : ''}`}>
<input type="checkbox" checked={checked.has(s.id)} onChange={() => setChecked(prev => { const n = new Set(prev); if (n.has(s.id)) n.delete(s.id); else n.add(s.id); return n })} aria-label={`Select message from ${s.name}`} />
<button type="button" className="ib-item-main" onClick={() => openMessage(s.id)}>
<span className="ib-item-top">
<span className="ib-from">{isUnread && <i className="ib-dot" aria-label="Unread" />}{fixMojibake(s.name)}</span>
<span className="ib-date">{fmt(s.submittedAt)}</span>
</span>
<span className="ib-subject">{r.subject}</span>
{r.body && <span className="ib-preview">{r.body.replace(/\s+/g, ' ').slice(0, 220)}</span>}
<span className="ib-tags">
<span className={`ib-badge ib-badge--${r.kind}`}>{r.kind === 'message' ? (TYPE_LABEL[s.messageType] ?? KIND_LABEL.message) : KIND_LABEL[r.kind]}</span>
{s.archived && <span className="ib-badge">Archived</span>}
<span className="ib-addr">{s.email}</span>
</span>
</button>
</li>
)
})}
</ul>
{open && (
<article className="ib-detail">
<header className="ib-detail-head">
<div className="ib-detail-title">
<h3>{open.subject}</h3>
<p><strong>{fixMojibake(open.s.name)}</strong> &lt;<a href={`mailto:${open.s.email}`}>{open.s.email}</a>&gt;</p>
<p className="ib-detail-sub">
{fmt(open.s.submittedAt)} · {open.kind === 'message' ? (TYPE_LABEL[open.s.messageType] ?? KIND_LABEL.message) : KIND_LABEL[open.kind]}
{open.s.inboundTo ? ` · to ${open.s.inboundTo}` : ''}
{open.s.subscribe ? ' · subscribed' : ''}
</p>
</div>
<button type="button" className="btn-admin-reset btn-admin-reset--compact" onClick={() => setOpenId(null)} aria-label="Close message">✕</button>
</header>
<div className="ib-detail-actions">
<a className="btn-admin-save" href={`mailto:${open.s.email}?subject=${encodeURIComponent(/^re:/i.test(open.subject) ? open.subject : `Re: ${open.subject}`)}`}>Reply</a>
<button type="button" className="btn-admin-reset btn-admin-reset--compact" disabled={busy} onClick={() => void bulk([open.s.id], open.s.archived ? 'unarchive' : 'archive')}>{open.s.archived ? 'Unarchive' : 'Archive'}</button>
<button type="button" className="btn-admin-reset btn-admin-reset--compact" disabled={busy} onClick={() => void bulk([open.s.id], 'unread')}>Mark unread</button>
<button type="button" className="btn-admin-remove" disabled={busy} onClick={() => void bulk([open.s.id], 'delete')}>Delete</button>
{open.s.htmlBody && (
<button type="button" className="btn-admin-reset btn-admin-reset--compact" onClick={() => setFormatted(f => !f)}>{formatted ? 'Show plain text' : 'Show formatted'}</button>
)}
</div>
{formatted && open.s.htmlBody
? <iframe className="ib-frame" title="Formatted email" sandbox="" srcDoc={open.s.htmlBody} />
: <div className="ib-body">{open.body || open.subject}</div>}
</article>
)}
</div>
</section>
)
}