diff --git a/package.json b/package.json
index 5fe0743..b942119 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
- "version": "1.1.11",
+ "version": "1.1.12",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/server.js b/server.js
index 178ab29..0ebf78e 100644
--- a/server.js
+++ b/server.js
@@ -23,6 +23,7 @@ import {
loadEpisodePlaysFromDisk,
loadPodcastChecklistFromDisk,
loadAnalyticsEventsFromDisk,
+ loadEmailSettingsFromDisk,
createBackupSnapshot,
refreshContentCaches,
queueHitStatsWrite,
@@ -143,6 +144,7 @@ Promise.all([
loadEpisodePlaysFromDisk(),
loadPodcastChecklistFromDisk(),
loadAnalyticsEventsFromDisk(),
+ loadEmailSettingsFromDisk(),
refreshContentCaches(),
])
.catch(err => {
diff --git a/server/config.js b/server/config.js
index 4dae9e5..d2dd1d3 100644
--- a/server/config.js
+++ b/server/config.js
@@ -58,6 +58,7 @@ export const EPISODE_SCRIPTS_FILE = path.join(DATA_DIR, 'episode-scripts.json')
export const QR_CODES_FILE = path.join(DATA_DIR, 'qr-codes.json')
export const EPISODE_PLAYS_FILE = path.join(DATA_DIR, 'episode-plays.json')
export const ANALYTICS_EVENTS_FILE = path.join(DATA_DIR, 'analytics-events.json')
+export const EMAIL_SETTINGS_FILE = path.join(DATA_DIR, 'email-settings.json')
export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon
export const DIST_DIR = path.join(ROOT_DIR, 'dist')
diff --git a/server/data.js b/server/data.js
index 3ea39a6..8acd857 100644
--- a/server/data.js
+++ b/server/data.js
@@ -30,6 +30,7 @@ import {
DOWNLOAD_COUNTS_FILE,
EPISODE_PLAYS_FILE,
ANALYTICS_EVENTS_FILE,
+ EMAIL_SETTINGS_FILE,
EMPTY_HIT_STATS,
EMPTY_VISITOR_STATS,
DEFAULT_REPLY_TEMPLATES,
@@ -385,6 +386,36 @@ export function loadReplyHistoryFromDisk() {
})
}
+// ── Email settings ────────────────────────────────────────────────────────
+
+export function queueEmailSettingsWrite() {
+ state.emailSettingsWritePromise = state.emailSettingsWritePromise
+ .then(async () => {
+ await mkdir(DATA_DIR, { recursive: true })
+ await writeFile(
+ EMAIL_SETTINGS_FILE,
+ JSON.stringify({ settings: state.emailSettings, updatedAt: new Date().toISOString() }, null, 2),
+ 'utf8',
+ )
+ })
+ .catch(err => {
+ console.error('[email-settings] failed to write settings:', err)
+ })
+}
+
+export function loadEmailSettingsFromDisk() {
+ return readFile(EMAIL_SETTINGS_FILE, 'utf8')
+ .then(raw => {
+ const parsed = JSON.parse(raw)
+ if (parsed?.settings && typeof parsed.settings === 'object') {
+ state.emailSettings = { ...state.emailSettings, ...parsed.settings }
+ }
+ })
+ .catch(() => {
+ // Keep defaults from state initializer
+ })
+}
+
// ── Podcast checklist ──────────────────────────────────────────────────────
export function queuePodcastChecklistWrite() {
diff --git a/server/email.js b/server/email.js
index dd516db..3e8c9cb 100644
--- a/server/email.js
+++ b/server/email.js
@@ -350,9 +350,11 @@ export function buildContactAdminNotificationTemplate({
}
}
-export function buildAdminReplyTemplate({ recipientName, message }) {
+export function buildAdminReplyTemplate({ recipientName, message, signature }) {
const safeRecipientName = escapeHtml(recipientName || 'friend')
const safeMessage = escapeHtml(message).replace(/\n/g, '
')
+ const sig = typeof signature === 'string' && signature.trim() ? signature.trim() : 'Grace and peace,\nVerse by Verse with Nate'
+ const safeSig = escapeHtml(sig).replace(/\n/g, '
')
return `
@@ -369,7 +371,7 @@ export function buildAdminReplyTemplate({ recipientName, message }) {
|
Hi ${safeRecipientName},
${safeMessage}
- Grace and peace, Verse by Verse with Nate
+ ${safeSig}
|
diff --git a/server/routes/contact.js b/server/routes/contact.js
index abc98c2..94de20a 100644
--- a/server/routes/contact.js
+++ b/server/routes/contact.js
@@ -17,6 +17,7 @@ import {
queueDraftQuestionsWrite,
queueReplyTemplatesWrite,
queueReplyHistoryWrite,
+ queueEmailSettingsWrite,
normalizeContactEmailStatus,
normalizeMessageType,
sanitizeReplyTemplates,
@@ -398,18 +399,54 @@ export function register(app) {
res.json({ submissions: state.contactSubmissions.slice(0, 300) })
})
+ app.post('/api/admin-contact-submissions/add', requireAdminAuth, (req, res) => {
+ const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''
+ const email = typeof req.body?.email === 'string' ? req.body.email.trim() : ''
+ const notes = typeof req.body?.notes === 'string' ? req.body.notes.trim().slice(0, 2000) : ''
+
+ if (!name && !email) {
+ res.status(400).json({ message: 'Name or email is required.' }); return
+ }
+ if (email && !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(email)) {
+ res.status(400).json({ message: 'Invalid email address.' }); return
+ }
+
+ const submission = {
+ id: randomUUID(),
+ submittedAt: new Date().toISOString(),
+ name: name.slice(0, 200),
+ email,
+ message: '',
+ messageType: 'general',
+ subscribe: false,
+ archived: false,
+ source: 'manual',
+ notes,
+ emailStatus: normalizeContactEmailStatus(null, false),
+ }
+
+ state.contactSubmissions.unshift(submission)
+ state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
+ queueContactSubmissionsWrite()
+ res.json({ ok: true, submission })
+ })
+
app.patch('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => {
const { id } = req.params
if (typeof id !== 'string' || !id.trim()) {
res.status(400).json({ message: 'Invalid submission id.' }); return
}
- const archived = req.body?.archived === true
+ const patch = {}
+ if (typeof req.body?.archived === 'boolean') patch.archived = req.body.archived
+ if (typeof req.body?.name === 'string') patch.name = req.body.name.trim().slice(0, 200)
+ if (typeof req.body?.notes === 'string') patch.notes = req.body.notes.trim().slice(0, 2000)
+
let found = false
state.contactSubmissions = state.contactSubmissions.map(item => {
if (item.id !== id) return item
found = true
- return { ...item, archived }
+ return { ...item, ...patch }
})
if (!found) {
@@ -417,7 +454,7 @@ export function register(app) {
}
queueContactSubmissionsWrite()
- res.json({ ok: true, archived })
+ res.json({ ok: true })
})
app.delete('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => {
@@ -493,11 +530,12 @@ export function register(app) {
}
const recipientName = splitName(submission.name).firstName || submission.name || 'friend'
- const html = buildAdminReplyTemplate({ recipientName, message })
+ const signature = state.emailSettings?.signature ?? 'Grace and peace,\nVerse by Verse with Nate'
+ const html = buildAdminReplyTemplate({ recipientName, message, signature })
const replyToAddress = getResendReplyToAddress()
const defaultFrom = getResendFromAddress() || ADMIN_REPLY_FROM
const fromAddress = ADMIN_REPLY_FROM_OPTIONS.includes(requestedFrom) ? requestedFrom : defaultFrom
- const text = `Hi ${recipientName},\n\n${message}\n\nGrace and peace,\nVerse by Verse with Nate\n${replyToAddress}`
+ const text = `Hi ${recipientName},\n\n${message}\n\n${signature}\n${replyToAddress}`
const resend = new Resend(process.env.RESEND_API_KEY)
const sendResult = await sendResendEmailWithRetry({
@@ -576,11 +614,12 @@ export function register(app) {
}
const recipientName = toName ? splitName(toName).firstName || toName : 'friend'
- const html = buildAdminReplyTemplate({ recipientName, message })
+ const signature = state.emailSettings?.signature ?? 'Grace and peace,\nVerse by Verse with Nate'
+ const html = buildAdminReplyTemplate({ recipientName, message, signature })
const defaultFrom = getResendFromAddress() || ADMIN_REPLY_FROM
const fromAddress = ADMIN_REPLY_FROM_OPTIONS.includes(requestedFrom) ? requestedFrom : defaultFrom
const replyToAddress = getResendReplyToAddress()
- const text = `Hi ${recipientName},\n\n${message}\n\nGrace and peace,\nVerse by Verse with Nate`
+ const text = `Hi ${recipientName},\n\n${message}\n\n${signature}`
const resend = new Resend(process.env.RESEND_API_KEY)
await sendResendEmailWithRetry({
@@ -617,6 +656,19 @@ export function register(app) {
}
})
+ app.get('/api/admin-email-settings', requireAdminAuth, (_req, res) => {
+ res.json(state.emailSettings ?? { signature: 'Grace and peace,\nVerse by Verse with Nate' })
+ })
+
+ app.put('/api/admin-email-settings', requireAdminAuth, (req, res) => {
+ const signature = typeof req.body?.signature === 'string'
+ ? req.body.signature.slice(0, 1000)
+ : (state.emailSettings?.signature ?? 'Grace and peace,\nVerse by Verse with Nate')
+ state.emailSettings = { ...state.emailSettings, signature }
+ queueEmailSettingsWrite()
+ res.json({ ok: true, settings: state.emailSettings })
+ })
+
app.get('/api/admin-subscribers', requireAdminAuth, (_req, res) => {
const seen = new Set()
const subscribers = state.contactSubmissions
diff --git a/server/state.js b/server/state.js
index 2c43adf..22ad8d2 100644
--- a/server/state.js
+++ b/server/state.js
@@ -90,6 +90,9 @@ export const state = {
qrScans: [],
qrCodesWritePromise: Promise.resolve(),
+ emailSettings: { signature: 'Grace and peace,\nVerse by Verse with Nate' },
+ emailSettingsWritePromise: Promise.resolve(),
+
lastBackupStatus: { ok: true, at: null, error: null, file: null },
lastCachePurgeStatus: { ok: true, at: null, error: null },
lastDeployHookStatus: { ok: true, at: null, error: null },
diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx
index f36882d..9f2b6d6 100644
--- a/src/AdminPage.tsx
+++ b/src/AdminPage.tsx
@@ -688,7 +688,7 @@ type AdminView =
| 'podcast' | 'current-series' | 'episode-highlights' | 'podcast-checklist'
| 'downloads' | 'custom-links' | 'content-blocks'
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
- | 'subscribers' | 'contacts' | 'study-users' | 'email-templates'
+ | 'subscribers' | 'study-users' | 'email-templates'
| 'seo' | 'legal' | 'security' | 'brand' | 'global'
interface AdminSectionLink {
@@ -725,7 +725,6 @@ const ADMIN_VIEW_OPTIONS: Array<{ group: string; options: Array<{ value: AdminVi
options: [
{ value: 'questions', label: 'Questions' },
{ value: 'study-comments', label: 'Study Comments' },
- { value: 'contacts', label: 'Contacts' },
{ value: 'subscribers', label: 'Subscribers' },
{ value: 'study-users', label: 'Study Users' },
],
@@ -1123,7 +1122,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [navSearch, setNavSearch] = useState('')
const [subscribers, setSubscribers] = useState([])
const [subscriberSearch, setSubscriberSearch] = useState('')
- const [contactSearch, setContactSearch] = useState('')
const [downloadStats, setDownloadStats] = useState>({})
// Study comments moderation state
interface AdminComment { id: string; studySlug: string; sectionId: string; displayName: string; text: string; createdAt: string; isApproved: boolean; approvedAt: string | null }
@@ -1420,12 +1418,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
setStatsStatus('ready')
}
- async function reloadContactSubmissions() {
- const r = await fetch('/api/admin-contact-submissions')
- if (!r.ok) throw new Error('Could not refresh contact submissions')
- const data = await r.json() as { submissions?: ContactSubmission[] }
- setContactSubmissions(Array.isArray(data.submissions) ? data.submissions : [])
- }
useEffect(() => {
setQuestionPage(0)
@@ -2209,9 +2201,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
if (view === 'questions' && unansweredCount > 0) {
return { count: unansweredCount, neutral: false }
}
- if (view === 'contacts' && contactSubmissions.length > 0) {
- return { count: contactSubmissions.length, neutral: true }
- }
if (view === 'subscribers' && subscribers.length > 0) {
return { count: subscribers.length, neutral: true }
}
@@ -3050,7 +3039,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
{c.message && {c.message.slice(0, 100)}{c.message.length > 100 ? '…' : ''}
}
))}
-
+ View All Contacts →
)
}
@@ -3107,125 +3096,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
)
})()}
- {/* CONTACTS */}
- {adminView === 'contacts' && (() => {
- const searchTerm = contactSearch.trim().toLowerCase()
- const grouped = new Map()
-
- for (const submission of contactSubmissions) {
- const emailKey = submission.email.trim().toLowerCase()
- const nameKey = submission.name.trim().toLowerCase()
- const key = emailKey || nameKey || submission.id
- const entries = grouped.get(key)
- if (entries) entries.push(submission)
- else grouped.set(key, [submission])
- }
-
- const rolledUp = Array.from(grouped.values())
- .map(entries => {
- const sortedEntries = [...entries].sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
- const latest = sortedEntries[0]
- return {
- ...latest,
- archived: sortedEntries.every(entry => entry.archived === true),
- subscribe: sortedEntries.some(entry => entry.subscribe),
- message: latest.message || sortedEntries.find(entry => entry.message)?.message || '',
- submissionCount: sortedEntries.length,
- allIds: sortedEntries.map(e => e.id),
- }
- })
- .filter(contact => {
- if (!searchTerm) return true
- return contact.name.toLowerCase().includes(searchTerm)
- || contact.email.toLowerCase().includes(searchTerm)
- || contact.message.toLowerCase().includes(searchTerm)
- })
- .sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
-
- return (
-
-
-
Contacts
-
{rolledUp.length} contacts from {contactSubmissions.length} total submissions — repeat senders are grouped together.
-
-
- setContactSearch(e.target.value)}
- style={{ minWidth: '260px', maxWidth: '440px', width: '100%' }}
- />
- {rolledUp.length} result{rolledUp.length !== 1 ? 's' : ''}
-
- {rolledUp.length === 0
- ? No contacts{contactSearch ? ' match your search' : ' yet'}.
- : (
-
-
-
-
- | Name |
- Email |
- Type |
- Subscriber |
- Date |
- Message |
- |
-
-
-
- {rolledUp.map(c => (
-
- |
- {c.name}
- {c.submissionCount > 1 && {c.submissionCount} submissions }
- |
- {c.email} |
-
- {c.messageType ?? 'contact'}
- {c.source === 'inbound-email' && (
-
- {c.inboundTo?.includes('nate@') ? 'nate@' : 'hello@'}
-
- )}
- |
- {c.subscribe ? '✓' : ''} |
- {formatDate(c.submittedAt)} |
- {c.message ?? '—'} |
-
-
- |
-
- ))}
-
-
-
- )
- }
-
- )
- })()}
-
{/* SUBSCRIBERS */}
{adminView === 'subscribers' && (() => {
const filteredSubs = subscribers.filter(s =>
diff --git a/src/App.css b/src/App.css
index 4902da1..431575d 100644
--- a/src/App.css
+++ b/src/App.css
@@ -10062,3 +10062,751 @@
.em-footer-hint { display: none; }
}
+
+/* ── Contacts Page (/contacts) ────────────────────────────────────────────── */
+
+.ct-app {
+ display: flex;
+ flex-direction: column;
+ height: 100dvh;
+ background: #0f0f0f;
+ color: #e8e2d5;
+ font-family: system-ui, -apple-system, sans-serif;
+ overflow: hidden;
+}
+
+.ct-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 1.25rem;
+ background: #1a1a1a;
+ border-bottom: 1px solid #2a2a2a;
+ flex-shrink: 0;
+}
+
+.ct-header-brand {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 1rem;
+ font-weight: 600;
+ color: #e8e2d5;
+}
+
+.ct-header-count {
+ background: #2e2e2e;
+ color: #9a9080;
+ font-size: 0.72rem;
+ font-weight: 500;
+ padding: 0.15rem 0.5rem;
+ border-radius: 999px;
+ margin-left: 0.25rem;
+}
+
+.ct-header-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.ct-add-banner {
+ background: #141414;
+ border-bottom: 1px solid #2a2a2a;
+ padding: 1rem 1.25rem;
+ flex-shrink: 0;
+}
+
+.ct-add-form { max-width: 720px; }
+
+.ct-add-title {
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: #b0a898;
+ margin: 0 0 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.ct-add-row {
+ display: flex;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+ margin-bottom: 0.6rem;
+}
+
+.ct-add-label {
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+ font-size: 0.78rem;
+ color: #9a9080;
+ flex: 1;
+ min-width: 180px;
+}
+
+.ct-add-label--full { width: 100%; flex: none; }
+
+.ct-input {
+ background: #1e1e1e;
+ border: 1px solid #333;
+ border-radius: 6px;
+ color: #e8e2d5;
+ padding: 0.45rem 0.65rem;
+ font-size: 0.88rem;
+ width: 100%;
+ box-sizing: border-box;
+ font-family: inherit;
+}
+
+.ct-input:focus {
+ outline: none;
+ border-color: #c8860a;
+ box-shadow: 0 0 0 2px rgba(200, 134, 10, 0.18);
+}
+
+.ct-notes-input { resize: vertical; }
+
+.ct-add-actions { margin-top: 0.6rem; }
+
+.ct-error {
+ color: #f87171;
+ font-size: 0.83rem;
+ margin: 0.4rem 0 0;
+}
+
+.ct-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.65rem 1.25rem;
+ background: #161616;
+ border-bottom: 1px solid #242424;
+ flex-shrink: 0;
+ flex-wrap: wrap;
+}
+
+.ct-search {
+ flex: 1;
+ min-width: 200px;
+ max-width: 480px;
+ background: #1e1e1e;
+ border: 1px solid #2e2e2e;
+ border-radius: 6px;
+ color: #e8e2d5;
+ padding: 0.42rem 0.75rem;
+ font-size: 0.87rem;
+}
+
+.ct-search:focus { outline: none; border-color: #c8860a; }
+
+.ct-archived-toggle {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ font-size: 0.82rem;
+ color: #7a7060;
+ cursor: pointer;
+}
+
+.ct-count-label {
+ font-size: 0.82rem;
+ color: #7a7060;
+ margin-left: auto;
+}
+
+.ct-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0.75rem 1.25rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.ct-empty {
+ color: #6a6050;
+ font-size: 0.9rem;
+ text-align: center;
+ margin-top: 2rem;
+}
+
+.ct-card {
+ display: flex;
+ gap: 0.9rem;
+ align-items: flex-start;
+ background: #1a1a1a;
+ border: 1px solid #2a2a2a;
+ border-radius: 10px;
+ padding: 0.85rem 1rem;
+ transition: border-color 0.15s;
+}
+
+.ct-card:hover { border-color: #3a3a3a; }
+.ct-card--archived { opacity: 0.5; }
+
+.ct-avatar {
+ width: 38px;
+ height: 38px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, #c8860a, #9a6408);
+ color: #fff;
+ font-size: 1rem;
+ font-weight: 700;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ user-select: none;
+}
+
+.ct-card-body { flex: 1; min-width: 0; }
+
+.ct-card-top {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ margin-bottom: 0.4rem;
+}
+
+.ct-card-name {
+ font-size: 0.92rem;
+ font-weight: 600;
+ color: #e8e2d5;
+}
+
+.ct-card-email {
+ font-size: 0.82rem;
+ color: #c8860a;
+ text-decoration: none;
+}
+.ct-card-email:hover { text-decoration: underline; }
+
+.ct-badge {
+ font-size: 0.7rem;
+ font-weight: 600;
+ padding: 0.15rem 0.45rem;
+ border-radius: 4px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+.ct-badge--form { background: #1e3a2e; color: #4ade80; }
+.ct-badge--email { background: #1e2a4a; color: #60a5fa; }
+.ct-badge--download { background: #2a2015; color: #f59e0b; }
+.ct-badge--manual { background: #2a1a2e; color: #c084fc; }
+.ct-badge--sub { background: #0f2a1e; color: #34d399; }
+
+.ct-count-badge {
+ font-size: 0.7rem;
+ background: #2e2e2e;
+ color: #9a9080;
+ padding: 0.12rem 0.45rem;
+ border-radius: 999px;
+}
+
+.ct-card-notes {
+ font-size: 0.83rem;
+ color: #a09080;
+ margin: 0.25rem 0;
+ line-height: 1.4;
+}
+.ct-card-notes--empty { color: #554f45; font-style: italic; }
+
+.ct-card-bottom {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ margin-top: 0.3rem;
+ flex-wrap: wrap;
+}
+
+.ct-card-date {
+ font-size: 0.78rem;
+ color: #6a6050;
+ flex-shrink: 0;
+}
+
+.ct-card-preview {
+ font-size: 0.8rem;
+ color: #706050;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ flex: 1;
+ min-width: 0;
+}
+
+.ct-card-actions {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ flex-shrink: 0;
+ align-self: center;
+}
+
+.ct-edit-form { display: flex; flex-direction: column; gap: 0.55rem; }
+.ct-edit-row { display: flex; gap: 0.6rem; }
+.ct-edit-label {
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+ font-size: 0.78rem;
+ color: #9a9080;
+ flex: 1;
+}
+.ct-edit-actions {
+ display: flex;
+ gap: 0.5rem;
+ margin-top: 0.2rem;
+}
+
+/* ── Calendar Page (/calendar) ─────────────────────────────────────────────── */
+
+.cal-app {
+ display: flex;
+ flex-direction: column;
+ height: 100dvh;
+ background: #0f0f0f;
+ color: #e8e2d5;
+ font-family: system-ui, -apple-system, sans-serif;
+ overflow: hidden;
+}
+
+.cal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 1.25rem;
+ background: #1a1a1a;
+ border-bottom: 1px solid #2a2a2a;
+ flex-shrink: 0;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+}
+
+.cal-header-left {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.cal-header-right {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ flex-wrap: wrap;
+}
+
+.cal-month-title {
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: #e8e2d5;
+ margin: 0;
+ min-width: 180px;
+ text-align: center;
+}
+
+.cal-nav-btn {
+ background: #2a2a2a;
+ border: 1px solid #363636;
+ color: #c0b8a8;
+ border-radius: 6px;
+ width: 32px;
+ height: 32px;
+ font-size: 1.1rem;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background 0.12s;
+}
+.cal-nav-btn:hover { background: #333; }
+
+.cal-saving {
+ font-size: 0.8rem;
+ color: #c8860a;
+ margin-left: 0.25rem;
+}
+
+.cal-scheduling-hint {
+ font-size: 0.82rem;
+ color: #c8860a;
+}
+
+.cal-cancel-link {
+ background: none;
+ border: none;
+ color: #c8860a;
+ cursor: pointer;
+ text-decoration: underline;
+ font-size: inherit;
+ padding: 0;
+}
+
+.cal-new-ep-banner {
+ background: #141414;
+ border-bottom: 1px solid #2a2a2a;
+ padding: 0.9rem 1.25rem;
+ flex-shrink: 0;
+}
+
+.cal-new-ep-form { max-width: 900px; }
+
+.cal-new-ep-title {
+ font-size: 0.82rem;
+ font-weight: 600;
+ color: #8a8070;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin: 0 0 0.6rem;
+}
+
+.cal-new-ep-row {
+ display: flex;
+ gap: 0.6rem;
+ flex-wrap: wrap;
+ margin-bottom: 0.6rem;
+}
+
+.cal-new-ep-label {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ font-size: 0.77rem;
+ color: #8a8070;
+ flex: 1;
+ min-width: 140px;
+}
+
+.cal-new-ep-actions { display: flex; gap: 0.5rem; }
+
+.cal-body {
+ flex: 1;
+ display: flex;
+ overflow: hidden;
+}
+
+.cal-main {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0.75rem;
+ min-width: 0;
+}
+
+.cal-loading {
+ color: #6a6050;
+ text-align: center;
+ margin-top: 3rem;
+}
+
+.cal-dow-row {
+ display: grid;
+ grid-template-columns: repeat(7, 1fr);
+ gap: 2px;
+ margin-bottom: 2px;
+}
+
+.cal-dow {
+ font-size: 0.72rem;
+ font-weight: 600;
+ color: #6a6050;
+ text-align: center;
+ padding: 0.35rem 0;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.cal-grid {
+ display: grid;
+ grid-template-columns: repeat(7, 1fr);
+ gap: 2px;
+}
+
+.cal-day {
+ background: #161616;
+ border: 1px solid #232323;
+ border-radius: 6px;
+ min-height: 90px;
+ padding: 0.4rem;
+ cursor: default;
+ display: flex;
+ flex-direction: column;
+ transition: border-color 0.12s;
+}
+
+.cal-day--out { opacity: 0.35; }
+.cal-day--today { border-color: #c8860a; }
+.cal-day--selectable { cursor: pointer; }
+.cal-day--selectable:hover { background: #1c1c12; border-color: #c8860a66; }
+
+.cal-day-num {
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: #7a7060;
+ display: block;
+ margin-bottom: 0.3rem;
+}
+.cal-day--today .cal-day-num { color: #c8860a; }
+
+.cal-day-events {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ flex: 1;
+}
+
+.cal-ep-chip {
+ background: linear-gradient(135deg, #1e2e1a, #172015);
+ border: 1px solid #2a3a24;
+ color: #6adb88;
+ font-size: 0.68rem;
+ padding: 0.2rem 0.4rem;
+ border-radius: 4px;
+ cursor: pointer;
+ text-align: left;
+ transition: background 0.12s;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ width: 100%;
+}
+.cal-ep-chip:hover { background: #2a3e24; }
+
+.cal-ep-chip-title { color: #4a8a58; }
+
+.cal-sidebar {
+ width: 220px;
+ flex-shrink: 0;
+ background: #141414;
+ border-left: 1px solid #222;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.cal-sidebar-title {
+ font-size: 0.78rem;
+ font-weight: 600;
+ color: #8a8070;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ padding: 0.75rem 0.9rem 0.5rem;
+ border-bottom: 1px solid #232323;
+ margin: 0;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-shrink: 0;
+}
+
+.cal-sidebar-count {
+ background: #2e2e2e;
+ color: #9a9080;
+ font-size: 0.68rem;
+ padding: 0.1rem 0.4rem;
+ border-radius: 999px;
+}
+
+.cal-sidebar-hint {
+ font-size: 0.78rem;
+ color: #c8860a;
+ padding: 0.4rem 0.9rem 0;
+ margin: 0;
+}
+
+.cal-sidebar-empty {
+ font-size: 0.82rem;
+ color: #6a6050;
+ padding: 0.75rem 0.9rem;
+ margin: 0;
+}
+
+.cal-sidebar-list {
+ overflow-y: auto;
+ flex: 1;
+ padding: 0.4rem 0.6rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+.cal-sidebar-ep {
+ background: #1a1a1a;
+ border: 1px solid #2a2a2a;
+ border-radius: 7px;
+ padding: 0.5rem 0.65rem;
+ cursor: pointer;
+ transition: border-color 0.12s, background 0.12s;
+}
+.cal-sidebar-ep:hover { border-color: #3a3a3a; }
+.cal-sidebar-ep--selected { border-color: #c8860a; background: #1e1a12; }
+
+.cal-sidebar-ep-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 0.2rem;
+}
+
+.cal-sidebar-ep-num {
+ font-size: 0.72rem;
+ font-weight: 600;
+ color: #c8860a;
+}
+
+.cal-sidebar-ep-edit {
+ background: none;
+ border: none;
+ color: #5a5040;
+ cursor: pointer;
+ font-size: 0.8rem;
+ padding: 0;
+ line-height: 1;
+}
+.cal-sidebar-ep-edit:hover { color: #9a9080; }
+
+.cal-sidebar-ep-title {
+ font-size: 0.8rem;
+ color: #c8b898;
+ line-height: 1.3;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.cal-sidebar-ep-series {
+ font-size: 0.7rem;
+ color: #6a6050;
+ margin-top: 0.1rem;
+}
+
+/* Calendar popover */
+.cal-popover-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,0.55);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 100;
+}
+
+.cal-popover {
+ background: #1e1e1e;
+ border: 1px solid #333;
+ border-radius: 12px;
+ width: 340px;
+ max-width: 90vw;
+ box-shadow: 0 16px 40px rgba(0,0,0,0.7);
+ overflow: hidden;
+}
+
+.cal-popover-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.85rem 1rem;
+ border-bottom: 1px solid #2a2a2a;
+}
+
+.cal-popover-head h3 {
+ margin: 0;
+ font-size: 0.95rem;
+ color: #e8e2d5;
+}
+
+.cal-popover-close {
+ background: none;
+ border: none;
+ color: #6a6050;
+ font-size: 1.2rem;
+ cursor: pointer;
+ line-height: 1;
+ padding: 0;
+}
+.cal-popover-close:hover { color: #c0b8a8; }
+
+.cal-popover-body {
+ padding: 0.9rem 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.65rem;
+}
+
+.cal-popover-label {
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+ font-size: 0.78rem;
+ color: #8a8070;
+}
+
+.cal-popover-actions {
+ display: flex;
+ gap: 0.5rem;
+ padding: 0.75rem 1rem;
+ border-top: 1px solid #2a2a2a;
+}
+
+/* Email settings panel additions */
+.em-settings-section {
+ margin-bottom: 1.25rem;
+}
+
+.em-settings-label {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: #b0a898;
+ margin-bottom: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.em-settings-note {
+ font-size: 0.8rem;
+ color: #6a6050;
+ margin: 0.2rem 0 0.5rem;
+ line-height: 1.4;
+}
+
+.em-settings-sig-textarea {
+ width: 100%;
+ box-sizing: border-box;
+ margin-top: 0.4rem;
+}
+
+.em-settings-status {
+ font-size: 0.72rem;
+ font-weight: 600;
+ padding: 0.15rem 0.45rem;
+ border-radius: 4px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+.em-settings-status--ok { background: #1e3a2e; color: #4ade80; }
+.em-settings-status--warn { background: #3a1a1a; color: #f87171; }
+
+.em-compose-signature-text {
+ white-space: pre-line;
+}
+
+.em-sig-edit-link {
+ background: none;
+ border: none;
+ color: #c8860a;
+ cursor: pointer;
+ font-size: 0.75rem;
+ padding: 0;
+ text-decoration: underline;
+}
+.em-sig-edit-link:hover { color: #e0a020; }
+
+@media (max-width: 600px) {
+ .ct-card { flex-direction: column; gap: 0.6rem; }
+ .ct-card-actions { flex-direction: row; }
+ .cal-sidebar { display: none; }
+ .cal-month-title { min-width: 120px; font-size: 0.95rem; }
+}
diff --git a/src/App.tsx b/src/App.tsx
index 2166e82..b98c286 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -3,6 +3,8 @@ import type { ReactElement } from 'react'
import { Link, NavLink, Routes, Route, useLocation, useNavigate, useParams } from 'react-router-dom'
import AdminPage from './AdminPage'
import EmailShell from './EmailPage'
+import ContactsShell from './ContactsPage'
+import CalendarShell from './CalendarPage'
import QASection from './components/QASection'
import ContactForm from './components/ContactForm'
import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySectionPage, StudyLandingPage, StudySignupPage, StudyAccountPage, StudyCommunityPage, StudyQuizPage } from './colossiansStudy'
@@ -2496,6 +2498,8 @@ export default function App() {
} />
} />
} />
+ } />
+ } />
} />
+}
+
+interface PodcastChecklistData {
+ tasks: PodcastChecklistTask[]
+ episodes: PodcastChecklistEpisode[]
+}
+
+// ── Auth Shell ───────────────────────────────────────────────────────────────
+
+export default function CalendarShell() {
+ const [authState, setAuthState] = useState<'checking' | 'needs-password' | 'needs-totp' | 'ok'>('checking')
+ const [password, setPassword] = useState('')
+ const [totp, setTotp] = useState('')
+ const [authError, setAuthError] = useState('')
+ const [authBusy, setAuthBusy] = useState(false)
+
+ useEffect(() => {
+ fetch('/api/admin-auth/status', { credentials: 'include' })
+ .then(r => r.json())
+ .then((data: { authenticated?: boolean }) => {
+ setAuthState(data.authenticated ? 'ok' : 'needs-password')
+ })
+ .catch(() => setAuthState('needs-password'))
+ }, [])
+
+ async function handleLogin(e: React.FormEvent) {
+ e.preventDefault()
+ setAuthBusy(true)
+ setAuthError('')
+ try {
+ const res = await fetch('/api/admin-auth/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ password }),
+ credentials: 'include',
+ })
+ const data = await res.json() as { ok?: boolean; requiresTOTP?: boolean; message?: string }
+ if (!res.ok) { setAuthError(data.message ?? 'Invalid password.'); setAuthBusy(false); return }
+ if (data.requiresTOTP) { setAuthState('needs-totp'); setAuthBusy(false); return }
+ setAuthState('ok')
+ } catch { setAuthError('Login failed.') }
+ setAuthBusy(false)
+ }
+
+ async function handleTotp(e: React.FormEvent) {
+ e.preventDefault()
+ setAuthBusy(true)
+ setAuthError('')
+ try {
+ const res = await fetch('/api/admin-auth/totp', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ token: totp }),
+ credentials: 'include',
+ })
+ const data = await res.json() as { ok?: boolean; message?: string }
+ if (!res.ok) { setAuthError(data.message ?? 'Invalid code.'); setAuthBusy(false); return }
+ setAuthState('ok')
+ } catch { setAuthError('Verification failed.') }
+ setAuthBusy(false)
+ }
+
+ if (authState === 'checking') {
+ return Loading…
+ }
+
+ if (authState === 'needs-password') {
+ return (
+
+ )
+ }
+
+ if (authState === 'needs-totp') {
+ return (
+
+ )
+ }
+
+ return
+}
+
+// ── Calendar Client ──────────────────────────────────────────────────────────
+
+function toDateKey(date: Date) {
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
+}
+
+const MONTH_NAMES = [
+ 'January', 'February', 'March', 'April', 'May', 'June',
+ 'July', 'August', 'September', 'October', 'November', 'December',
+]
+
+function CalendarClient() {
+ const today = new Date()
+ const [checklist, setChecklist] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [saving, setSaving] = useState(false)
+ const [viewYear, setViewYear] = useState(today.getFullYear())
+ const [viewMonth, setViewMonth] = useState(today.getMonth())
+ const [selectedEpisodeId, setSelectedEpisodeId] = useState(null)
+ const [editEp, setEditEp] = useState(null)
+ const [editForm, setEditForm] = useState({ series: '', episodeNumber: '', title: '', datePublished: '' })
+ const [newEpOpen, setNewEpOpen] = useState(false)
+ const [newForm, setNewForm] = useState({ series: '', episodeNumber: '', title: '', datePublished: '' })
+ const [newBusy, setNewBusy] = useState(false)
+
+ const load = useCallback(async () => {
+ try {
+ const res = await fetch('/api/admin-podcast-checklist', { credentials: 'include' })
+ if (res.ok) {
+ const data = await res.json() as { checklist: PodcastChecklistData }
+ setChecklist(data.checklist)
+ }
+ } catch { /* silent */ }
+ setLoading(false)
+ }, [])
+
+ useEffect(() => { load() }, [load])
+
+ async function save(episodes: PodcastChecklistEpisode[]) {
+ if (!checklist) return
+ setSaving(true)
+ try {
+ const updated = { ...checklist, episodes }
+ const res = await fetch('/api/admin-podcast-checklist', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ checklist: updated }),
+ })
+ if (res.ok) setChecklist(updated)
+ } catch { /* silent */ }
+ setSaving(false)
+ }
+
+ const calDays = useMemo(() => {
+ const first = new Date(viewYear, viewMonth, 1)
+ const last = new Date(viewYear, viewMonth + 1, 0)
+ const days: Array<{ date: Date; inMonth: boolean }> = []
+ const startDow = (first.getDay() + 6) % 7 // Mon=0 Sun=6
+ for (let i = startDow - 1; i >= 0; i--) {
+ days.push({ date: new Date(viewYear, viewMonth, -i), inMonth: false })
+ }
+ for (let d = 1; d <= last.getDate(); d++) {
+ days.push({ date: new Date(viewYear, viewMonth, d), inMonth: true })
+ }
+ const rem = (7 - (days.length % 7)) % 7
+ for (let i = 1; i <= rem; i++) {
+ days.push({ date: new Date(viewYear, viewMonth + 1, i), inMonth: false })
+ }
+ return days
+ }, [viewYear, viewMonth])
+
+ const episodesByDate = useMemo(() => {
+ const map = new Map()
+ for (const ep of (checklist?.episodes ?? [])) {
+ if (!ep.datePublished?.trim()) continue
+ const key = ep.datePublished.trim().slice(0, 10)
+ const arr = map.get(key) ?? []
+ arr.push(ep)
+ map.set(key, arr)
+ }
+ return map
+ }, [checklist])
+
+ const unscheduled = useMemo(
+ () => (checklist?.episodes ?? []).filter(ep => !ep.datePublished?.trim()),
+ [checklist]
+ )
+
+ const todayKey = toDateKey(today)
+
+ function prevMonth() {
+ if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1) }
+ else setViewMonth(m => m - 1)
+ }
+
+ function nextMonth() {
+ if (viewMonth === 11) { setViewMonth(0); setViewYear(y => y + 1) }
+ else setViewMonth(m => m + 1)
+ }
+
+ function handleDayClick(date: Date, inMonth: boolean) {
+ if (!inMonth || !selectedEpisodeId || !checklist) return
+ const dk = toDateKey(date)
+ const updated = checklist.episodes.map(ep =>
+ ep.id === selectedEpisodeId ? { ...ep, datePublished: dk } : ep
+ )
+ save(updated)
+ setSelectedEpisodeId(null)
+ }
+
+ function openEdit(ep: PodcastChecklistEpisode) {
+ setEditEp(ep)
+ setEditForm({
+ series: ep.series,
+ episodeNumber: ep.episodeNumber != null ? String(ep.episodeNumber) : '',
+ title: ep.title,
+ datePublished: ep.datePublished?.trim() ?? '',
+ })
+ }
+
+ function saveEdit() {
+ if (!editEp || !checklist) return
+ const updated = checklist.episodes.map(ep =>
+ ep.id === editEp.id
+ ? {
+ ...ep,
+ series: editForm.series.trim(),
+ episodeNumber: editForm.episodeNumber.trim() ? Number(editForm.episodeNumber) : null,
+ title: editForm.title.trim(),
+ datePublished: editForm.datePublished.trim(),
+ }
+ : ep
+ )
+ save(updated)
+ setEditEp(null)
+ }
+
+ function unschedule(ep: PodcastChecklistEpisode) {
+ if (!checklist) return
+ const updated = checklist.episodes.map(e =>
+ e.id === ep.id ? { ...e, datePublished: '' } : e
+ )
+ save(updated)
+ setEditEp(null)
+ }
+
+ async function handleNewEpisode(e: React.FormEvent) {
+ e.preventDefault()
+ if (!checklist) return
+ setNewBusy(true)
+ const id = typeof crypto?.randomUUID === 'function'
+ ? crypto.randomUUID()
+ : `ep-${Date.now()}-${Math.random().toString(36).slice(2)}`
+ const ep: PodcastChecklistEpisode = {
+ id,
+ series: newForm.series.trim(),
+ episodeNumber: newForm.episodeNumber.trim() ? Number(newForm.episodeNumber) : null,
+ title: newForm.title.trim(),
+ datePublished: newForm.datePublished.trim(),
+ expanded: false,
+ tasks: {},
+ }
+ const updated = [...checklist.episodes, ep]
+ await save(updated)
+ setNewForm({ series: '', episodeNumber: '', title: '', datePublished: '' })
+ setNewEpOpen(false)
+ setNewBusy(false)
+ }
+
+ return (
+
+
+
+
+
+ {MONTH_NAMES[viewMonth]} {viewYear}
+
+
+ {saving && Saving…}
+
+
+ {selectedEpisodeId && (
+
+ Click a date to schedule ·{' '}
+
+
+ )}
+
+ ← Admin
+
+
+
+ {newEpOpen && (
+
+ )}
+
+
+
+ {loading ? (
+
Loading calendar…
+ ) : (
+ <>
+
+ {['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map(d => (
+
{d}
+ ))}
+
+
+ {calDays.map(({ date, inMonth }, i) => {
+ const dk = toDateKey(date)
+ const eps = episodesByDate.get(dk) ?? []
+ const isToday = dk === todayKey
+ const isSelecting = Boolean(selectedEpisodeId)
+ return (
+
handleDayClick(date, inMonth)}
+ >
+
{date.getDate()}
+
+ {eps.map(ep => (
+
+ ))}
+
+
+ )
+ })}
+
+ >
+ )}
+
+
+
+
+
+ {/* Edit popover */}
+ {editEp && (
+
+ )}
+
+ )
+}
diff --git a/src/ContactsPage.tsx b/src/ContactsPage.tsx
new file mode 100644
index 0000000..08ee17b
--- /dev/null
+++ b/src/ContactsPage.tsx
@@ -0,0 +1,487 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react'
+import { Link } from 'react-router-dom'
+
+// ── Types ────────────────────────────────────────────────────────────────────
+
+interface ContactSubmission {
+ id: string
+ submittedAt: string
+ name: string
+ email: string
+ message: string
+ messageType: string
+ subscribe: boolean
+ archived?: boolean
+ source?: string
+ inboundTo?: string
+ notes?: string
+}
+
+interface Contact {
+ key: string
+ email: string
+ name: string
+ source: string | undefined
+ inboundTo: string | undefined
+ subscribe: boolean
+ archived: boolean
+ submittedAt: string
+ message: string
+ notes: string
+ submissionCount: number
+ mainId: string
+ allIds: string[]
+}
+
+// ── Auth Shell ───────────────────────────────────────────────────────────────
+
+export default function ContactsShell() {
+ const [authState, setAuthState] = useState<'checking' | 'needs-password' | 'needs-totp' | 'ok'>('checking')
+ const [password, setPassword] = useState('')
+ const [totp, setTotp] = useState('')
+ const [authError, setAuthError] = useState('')
+ const [authBusy, setAuthBusy] = useState(false)
+
+ useEffect(() => {
+ fetch('/api/admin-auth/status', { credentials: 'include' })
+ .then(r => r.json())
+ .then((data: { authenticated?: boolean }) => {
+ setAuthState(data.authenticated ? 'ok' : 'needs-password')
+ })
+ .catch(() => setAuthState('needs-password'))
+ }, [])
+
+ async function handleLogin(e: React.FormEvent) {
+ e.preventDefault()
+ setAuthBusy(true)
+ setAuthError('')
+ try {
+ const res = await fetch('/api/admin-auth/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ password }),
+ credentials: 'include',
+ })
+ const data = await res.json() as { ok?: boolean; requiresTOTP?: boolean; message?: string }
+ if (!res.ok) { setAuthError(data.message ?? 'Invalid password.'); setAuthBusy(false); return }
+ if (data.requiresTOTP) { setAuthState('needs-totp'); setAuthBusy(false); return }
+ setAuthState('ok')
+ } catch { setAuthError('Login failed.') }
+ setAuthBusy(false)
+ }
+
+ async function handleTotp(e: React.FormEvent) {
+ e.preventDefault()
+ setAuthBusy(true)
+ setAuthError('')
+ try {
+ const res = await fetch('/api/admin-auth/totp', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ token: totp }),
+ credentials: 'include',
+ })
+ const data = await res.json() as { ok?: boolean; message?: string }
+ if (!res.ok) { setAuthError(data.message ?? 'Invalid code.'); setAuthBusy(false); return }
+ setAuthState('ok')
+ } catch { setAuthError('Verification failed.') }
+ setAuthBusy(false)
+ }
+
+ if (authState === 'checking') {
+ return Loading…
+ }
+
+ if (authState === 'needs-password') {
+ return (
+
+ )
+ }
+
+ if (authState === 'needs-totp') {
+ return (
+
+ )
+ }
+
+ return
+}
+
+// ── Contacts Client ──────────────────────────────────────────────────────────
+
+function ContactsClient() {
+ const [submissions, setSubmissions] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [search, setSearch] = useState('')
+ const [showArchived, setShowArchived] = useState(false)
+ const [editingId, setEditingId] = useState(null)
+ const [editName, setEditName] = useState('')
+ const [editNotes, setEditNotes] = useState('')
+ const [editSaving, setEditSaving] = useState(false)
+ const [addOpen, setAddOpen] = useState(false)
+ const [addName, setAddName] = useState('')
+ const [addEmail, setAddEmail] = useState('')
+ const [addNotes, setAddNotes] = useState('')
+ const [addBusy, setAddBusy] = useState(false)
+ const [addError, setAddError] = useState('')
+
+ const reload = useCallback(async () => {
+ try {
+ const res = await fetch('/api/admin-contact-submissions', { credentials: 'include' })
+ if (res.ok) {
+ const data = await res.json() as { submissions: ContactSubmission[] }
+ setSubmissions(data.submissions ?? [])
+ }
+ } catch { /* silent */ }
+ setLoading(false)
+ }, [])
+
+ useEffect(() => { reload() }, [reload])
+
+ const contacts: Contact[] = useMemo(() => {
+ const grouped = new Map()
+ for (const s of submissions) {
+ const key = s.email?.trim().toLowerCase() || s.id
+ const arr = grouped.get(key) ?? []
+ arr.push(s)
+ grouped.set(key, arr)
+ }
+ return Array.from(grouped.values())
+ .map(entries => {
+ const sorted = [...entries].sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
+ const latest = sorted[0]
+ return {
+ key: latest.email?.trim().toLowerCase() || latest.id,
+ email: latest.email,
+ name: latest.name,
+ source: latest.source,
+ inboundTo: latest.inboundTo,
+ subscribe: sorted.some(s => s.subscribe),
+ archived: sorted.every(s => s.archived === true),
+ submittedAt: latest.submittedAt,
+ message: latest.message || sorted.find(s => s.message)?.message || '',
+ notes: latest.notes ?? '',
+ submissionCount: sorted.length,
+ mainId: latest.id,
+ allIds: sorted.map(s => s.id),
+ }
+ })
+ .sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
+ }, [submissions])
+
+ const filtered = contacts.filter(c => {
+ if (!showArchived && c.archived) return false
+ if (!search.trim()) return true
+ const q = search.toLowerCase()
+ return (
+ c.name.toLowerCase().includes(q) ||
+ c.email.toLowerCase().includes(q) ||
+ c.message.toLowerCase().includes(q) ||
+ c.notes.toLowerCase().includes(q)
+ )
+ })
+
+ function startEdit(c: Contact) {
+ setEditingId(c.mainId)
+ setEditName(c.name)
+ setEditNotes(c.notes)
+ }
+
+ async function saveEdit() {
+ if (!editingId) return
+ setEditSaving(true)
+ await fetch(`/api/admin-contact-submissions/${encodeURIComponent(editingId)}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ name: editName, notes: editNotes }),
+ })
+ setEditSaving(false)
+ setEditingId(null)
+ await reload()
+ }
+
+ async function deleteContact(c: Contact) {
+ const label = c.name || c.email || 'this contact'
+ const plural = c.submissionCount > 1 ? `all ${c.submissionCount} submissions` : 'submission'
+ if (!confirm(`Delete ${plural} from ${label}?`)) return
+ await Promise.all(
+ c.allIds.map(id =>
+ fetch(`/api/admin-contact-submissions/${encodeURIComponent(id)}`, {
+ method: 'DELETE',
+ credentials: 'include',
+ })
+ )
+ )
+ await reload()
+ }
+
+ async function handleAdd(e: React.FormEvent) {
+ e.preventDefault()
+ setAddBusy(true)
+ setAddError('')
+ try {
+ const res = await fetch('/api/admin-contact-submissions/add', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ name: addName, email: addEmail, notes: addNotes }),
+ })
+ const data = await res.json() as { ok?: boolean; message?: string }
+ if (!res.ok) { setAddError(data.message ?? 'Failed to add contact.'); setAddBusy(false); return }
+ setAddOpen(false)
+ setAddName('')
+ setAddEmail('')
+ setAddNotes('')
+ await reload()
+ } catch { setAddError('Network error.') }
+ setAddBusy(false)
+ }
+
+ function sourceBadge(source: string | undefined) {
+ if (source === 'manual') return manual
+ if (source === 'inbound-email') return email
+ if (source === 'download') return download
+ return form
+ }
+
+ function fmtDate(iso: string) {
+ try {
+ return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
+ } catch { return '—' }
+ }
+
+ return (
+
+
+
+ {addOpen && (
+
+ )}
+
+
+ setSearch(e.target.value)}
+ />
+
+
+ {filtered.length} contact{filtered.length !== 1 ? 's' : ''}
+
+
+
+
+ {loading &&
Loading contacts…
}
+ {!loading && filtered.length === 0 && (
+
+ {search ? 'No contacts match your search.' : 'No contacts yet. Add one above.'}
+
+ )}
+
+ {filtered.map(c => (
+
+
+ {(c.name || c.email || '?').charAt(0).toUpperCase()}
+
+
+
+ {editingId === c.mainId ? (
+
+
+
+
+
+
+
+
+
+
+ ) : (
+ <>
+
+
{c.name || No name}
+ {c.email && (
+
{c.email}
+ )}
+ {sourceBadge(c.source)}
+ {c.subscribe &&
subscriber}
+ {c.submissionCount > 1 && (
+
{c.submissionCount}
+ )}
+
+
+ {c.notes || 'No notes — click Edit to add'}
+
+
+ {fmtDate(c.submittedAt)}
+ {c.message && (
+
+ {c.message.length > 90 ? c.message.slice(0, 90) + '…' : c.message}
+
+ )}
+
+ >
+ )}
+
+
+ {editingId !== c.mainId && (
+
+
+
+
+ )}
+
+ ))}
+
+
+ )
+}
diff --git a/src/EmailPage.tsx b/src/EmailPage.tsx
index 6d76c26..d6e9a48 100644
--- a/src/EmailPage.tsx
+++ b/src/EmailPage.tsx
@@ -60,6 +60,10 @@ interface ReplyConfig {
note: string
}
+interface EmailSettings {
+ signature: string
+}
+
// ── Helpers ──────────────────────────────────────────────────────────────────
function extractSubject(message: string): string {
@@ -120,6 +124,11 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
const [templateStatus, setTemplateStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [showTemplatesMgr, setShowTemplatesMgr] = useState(false)
const [showHistory, setShowHistory] = useState(false)
+ const [showSettings, setShowSettings] = useState(false)
+ const [emailSettings, setEmailSettings] = useState({ signature: 'Grace and peace,\nVerse by Verse with Nate' })
+ const [settingsSig, setSettingsSig] = useState('')
+ const [settingsSaving, setSettingsSaving] = useState(false)
+ const [settingsSaved, setSettingsSaved] = useState(false)
const [actionMsg, setActionMsg] = useState('')
const composeRef = useRef(null)
const listRef = useRef(null)
@@ -128,11 +137,12 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
const loadAll = useCallback(async () => {
try {
- const [subRes, tplRes, histRes, cfgRes] = await Promise.all([
+ const [subRes, tplRes, histRes, cfgRes, settingsRes] = await Promise.all([
fetch('/api/admin-contact-submissions'),
fetch('/api/admin-contact-reply-templates'),
fetch('/api/admin-contact-reply-history'),
fetch('/api/admin-reply-config'),
+ fetch('/api/admin-email-settings'),
])
if (!subRes.ok) throw new Error('submissions failed')
const subData = await subRes.json() as { submissions?: ContactSubmission[] }
@@ -147,6 +157,10 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
setHistory(d.items ?? [])
}
if (cfgRes.ok) setConfig(await cfgRes.json() as ReplyConfig)
+ if (settingsRes.ok) {
+ const s = await settingsRes.json() as EmailSettings
+ setEmailSettings(s)
+ }
} catch {
setLoadStatus('error')
}
@@ -543,8 +557,8 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
onChange={e => setReplyDraft({ ...replyDraft, message: e.target.value })}
/>
- Grace and peace, · Verse by Verse with Nate
- auto-appended
+ {emailSettings.signature}
+ auto-appended ·
)}
+ {/* Settings drawer */}
+ {showSettings && (
+ { if (e.target === e.currentTarget) setShowSettings(false) }}>
+
+
+
Email Settings
+
+
+
+
+
+
+ {config && (
+
+
+ Email Configuration
+
+ {config.canSendReplies ? 'Configured' : 'Not configured'}
+
+
+
{config.note}
+ {config.fromEmail &&
Reply-to: {config.fromEmail}
}
+
+ )}
+
+
+
+
+
+
+ )}
+
{/* Footer toolbar */}
{config && !config.canSendReplies && (
@@ -799,6 +877,9 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
+
↑ ↓ navigate · r reply · e archive · Esc close