Add /contacts and /calendar pages; email signature settings; v1.1.12
- /contacts: standalone page with hybrid contact list (submissions + manual entry), inline edit, search, archive - /calendar: monthly release scheduling calendar reading/writing podcast checklist episode dates - /email settings: editable signature panel; signature persisted server-side and injected into outgoing emails - Move contacts out of /admin panel (now links to /contacts route) - Partial PATCH for contact submissions (name, notes, archived independently) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.11",
|
"version": "1.1.12",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
loadEpisodePlaysFromDisk,
|
loadEpisodePlaysFromDisk,
|
||||||
loadPodcastChecklistFromDisk,
|
loadPodcastChecklistFromDisk,
|
||||||
loadAnalyticsEventsFromDisk,
|
loadAnalyticsEventsFromDisk,
|
||||||
|
loadEmailSettingsFromDisk,
|
||||||
createBackupSnapshot,
|
createBackupSnapshot,
|
||||||
refreshContentCaches,
|
refreshContentCaches,
|
||||||
queueHitStatsWrite,
|
queueHitStatsWrite,
|
||||||
@@ -143,6 +144,7 @@ Promise.all([
|
|||||||
loadEpisodePlaysFromDisk(),
|
loadEpisodePlaysFromDisk(),
|
||||||
loadPodcastChecklistFromDisk(),
|
loadPodcastChecklistFromDisk(),
|
||||||
loadAnalyticsEventsFromDisk(),
|
loadAnalyticsEventsFromDisk(),
|
||||||
|
loadEmailSettingsFromDisk(),
|
||||||
refreshContentCaches(),
|
refreshContentCaches(),
|
||||||
])
|
])
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
|
|||||||
@@ -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 QR_CODES_FILE = path.join(DATA_DIR, 'qr-codes.json')
|
||||||
export const EPISODE_PLAYS_FILE = path.join(DATA_DIR, 'episode-plays.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 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 MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon
|
||||||
|
|
||||||
export const DIST_DIR = path.join(ROOT_DIR, 'dist')
|
export const DIST_DIR = path.join(ROOT_DIR, 'dist')
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
DOWNLOAD_COUNTS_FILE,
|
DOWNLOAD_COUNTS_FILE,
|
||||||
EPISODE_PLAYS_FILE,
|
EPISODE_PLAYS_FILE,
|
||||||
ANALYTICS_EVENTS_FILE,
|
ANALYTICS_EVENTS_FILE,
|
||||||
|
EMAIL_SETTINGS_FILE,
|
||||||
EMPTY_HIT_STATS,
|
EMPTY_HIT_STATS,
|
||||||
EMPTY_VISITOR_STATS,
|
EMPTY_VISITOR_STATS,
|
||||||
DEFAULT_REPLY_TEMPLATES,
|
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 ──────────────────────────────────────────────────────
|
// ── Podcast checklist ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function queuePodcastChecklistWrite() {
|
export function queuePodcastChecklistWrite() {
|
||||||
|
|||||||
+4
-2
@@ -350,9 +350,11 @@ export function buildContactAdminNotificationTemplate({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildAdminReplyTemplate({ recipientName, message }) {
|
export function buildAdminReplyTemplate({ recipientName, message, signature }) {
|
||||||
const safeRecipientName = escapeHtml(recipientName || 'friend')
|
const safeRecipientName = escapeHtml(recipientName || 'friend')
|
||||||
const safeMessage = escapeHtml(message).replace(/\n/g, '<br/>')
|
const safeMessage = escapeHtml(message).replace(/\n/g, '<br/>')
|
||||||
|
const sig = typeof signature === 'string' && signature.trim() ? signature.trim() : 'Grace and peace,\nVerse by Verse with Nate'
|
||||||
|
const safeSig = escapeHtml(sig).replace(/\n/g, '<br/>')
|
||||||
return `
|
return `
|
||||||
<div style="margin:0;padding:0;background-color:#f5f1e8;font-family:Georgia,serif;color:#201a10;">
|
<div style="margin:0;padding:0;background-color:#f5f1e8;font-family:Georgia,serif;color:#201a10;">
|
||||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:#f5f1e8;">
|
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:#f5f1e8;">
|
||||||
@@ -369,7 +371,7 @@ export function buildAdminReplyTemplate({ recipientName, message }) {
|
|||||||
<td style="padding:26px 24px 18px;">
|
<td style="padding:26px 24px 18px;">
|
||||||
<p style="margin:0 0 16px;font-family:Arial,sans-serif;font-size:16px;line-height:1.6;color:#201a10;">Hi ${safeRecipientName},</p>
|
<p style="margin:0 0 16px;font-family:Arial,sans-serif;font-size:16px;line-height:1.6;color:#201a10;">Hi ${safeRecipientName},</p>
|
||||||
<div style="margin:0 0 18px;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">${safeMessage}</div>
|
<div style="margin:0 0 18px;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">${safeMessage}</div>
|
||||||
<p style="margin:0;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">Grace and peace,<br/>Verse by Verse with Nate</p>
|
<p style="margin:0;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">${safeSig}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
queueDraftQuestionsWrite,
|
queueDraftQuestionsWrite,
|
||||||
queueReplyTemplatesWrite,
|
queueReplyTemplatesWrite,
|
||||||
queueReplyHistoryWrite,
|
queueReplyHistoryWrite,
|
||||||
|
queueEmailSettingsWrite,
|
||||||
normalizeContactEmailStatus,
|
normalizeContactEmailStatus,
|
||||||
normalizeMessageType,
|
normalizeMessageType,
|
||||||
sanitizeReplyTemplates,
|
sanitizeReplyTemplates,
|
||||||
@@ -398,18 +399,54 @@ export function register(app) {
|
|||||||
res.json({ submissions: state.contactSubmissions.slice(0, 300) })
|
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) => {
|
app.patch('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => {
|
||||||
const { id } = req.params
|
const { id } = req.params
|
||||||
if (typeof id !== 'string' || !id.trim()) {
|
if (typeof id !== 'string' || !id.trim()) {
|
||||||
res.status(400).json({ message: 'Invalid submission id.' }); return
|
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
|
let found = false
|
||||||
state.contactSubmissions = state.contactSubmissions.map(item => {
|
state.contactSubmissions = state.contactSubmissions.map(item => {
|
||||||
if (item.id !== id) return item
|
if (item.id !== id) return item
|
||||||
found = true
|
found = true
|
||||||
return { ...item, archived }
|
return { ...item, ...patch }
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!found) {
|
if (!found) {
|
||||||
@@ -417,7 +454,7 @@ export function register(app) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
queueContactSubmissionsWrite()
|
queueContactSubmissionsWrite()
|
||||||
res.json({ ok: true, archived })
|
res.json({ ok: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
app.delete('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => {
|
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 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 replyToAddress = getResendReplyToAddress()
|
||||||
const defaultFrom = getResendFromAddress() || ADMIN_REPLY_FROM
|
const defaultFrom = getResendFromAddress() || ADMIN_REPLY_FROM
|
||||||
const fromAddress = ADMIN_REPLY_FROM_OPTIONS.includes(requestedFrom) ? requestedFrom : defaultFrom
|
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 resend = new Resend(process.env.RESEND_API_KEY)
|
||||||
|
|
||||||
const sendResult = await sendResendEmailWithRetry({
|
const sendResult = await sendResendEmailWithRetry({
|
||||||
@@ -576,11 +614,12 @@ export function register(app) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const recipientName = toName ? splitName(toName).firstName || toName : 'friend'
|
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 defaultFrom = getResendFromAddress() || ADMIN_REPLY_FROM
|
||||||
const fromAddress = ADMIN_REPLY_FROM_OPTIONS.includes(requestedFrom) ? requestedFrom : defaultFrom
|
const fromAddress = ADMIN_REPLY_FROM_OPTIONS.includes(requestedFrom) ? requestedFrom : defaultFrom
|
||||||
const replyToAddress = getResendReplyToAddress()
|
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)
|
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||||
|
|
||||||
await sendResendEmailWithRetry({
|
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) => {
|
app.get('/api/admin-subscribers', requireAdminAuth, (_req, res) => {
|
||||||
const seen = new Set()
|
const seen = new Set()
|
||||||
const subscribers = state.contactSubmissions
|
const subscribers = state.contactSubmissions
|
||||||
|
|||||||
@@ -90,6 +90,9 @@ export const state = {
|
|||||||
qrScans: [],
|
qrScans: [],
|
||||||
qrCodesWritePromise: Promise.resolve(),
|
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 },
|
lastBackupStatus: { ok: true, at: null, error: null, file: null },
|
||||||
lastCachePurgeStatus: { ok: true, at: null, error: null },
|
lastCachePurgeStatus: { ok: true, at: null, error: null },
|
||||||
lastDeployHookStatus: { ok: true, at: null, error: null },
|
lastDeployHookStatus: { ok: true, at: null, error: null },
|
||||||
|
|||||||
+2
-132
@@ -688,7 +688,7 @@ type AdminView =
|
|||||||
| 'podcast' | 'current-series' | 'episode-highlights' | 'podcast-checklist'
|
| 'podcast' | 'current-series' | 'episode-highlights' | 'podcast-checklist'
|
||||||
| 'downloads' | 'custom-links' | 'content-blocks'
|
| 'downloads' | 'custom-links' | 'content-blocks'
|
||||||
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
|
| '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'
|
| 'seo' | 'legal' | 'security' | 'brand' | 'global'
|
||||||
|
|
||||||
interface AdminSectionLink {
|
interface AdminSectionLink {
|
||||||
@@ -725,7 +725,6 @@ const ADMIN_VIEW_OPTIONS: Array<{ group: string; options: Array<{ value: AdminVi
|
|||||||
options: [
|
options: [
|
||||||
{ value: 'questions', label: 'Questions' },
|
{ value: 'questions', label: 'Questions' },
|
||||||
{ value: 'study-comments', label: 'Study Comments' },
|
{ value: 'study-comments', label: 'Study Comments' },
|
||||||
{ value: 'contacts', label: 'Contacts' },
|
|
||||||
{ value: 'subscribers', label: 'Subscribers' },
|
{ value: 'subscribers', label: 'Subscribers' },
|
||||||
{ value: 'study-users', label: 'Study Users' },
|
{ value: 'study-users', label: 'Study Users' },
|
||||||
],
|
],
|
||||||
@@ -1123,7 +1122,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
const [navSearch, setNavSearch] = useState('')
|
const [navSearch, setNavSearch] = useState('')
|
||||||
const [subscribers, setSubscribers] = useState<Subscriber[]>([])
|
const [subscribers, setSubscribers] = useState<Subscriber[]>([])
|
||||||
const [subscriberSearch, setSubscriberSearch] = useState('')
|
const [subscriberSearch, setSubscriberSearch] = useState('')
|
||||||
const [contactSearch, setContactSearch] = useState('')
|
|
||||||
const [downloadStats, setDownloadStats] = useState<Record<string, number>>({})
|
const [downloadStats, setDownloadStats] = useState<Record<string, number>>({})
|
||||||
// Study comments moderation state
|
// Study comments moderation state
|
||||||
interface AdminComment { id: string; studySlug: string; sectionId: string; displayName: string; text: string; createdAt: string; isApproved: boolean; approvedAt: string | null }
|
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')
|
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(() => {
|
useEffect(() => {
|
||||||
setQuestionPage(0)
|
setQuestionPage(0)
|
||||||
@@ -2209,9 +2201,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
if (view === 'questions' && unansweredCount > 0) {
|
if (view === 'questions' && unansweredCount > 0) {
|
||||||
return { count: unansweredCount, neutral: false }
|
return { count: unansweredCount, neutral: false }
|
||||||
}
|
}
|
||||||
if (view === 'contacts' && contactSubmissions.length > 0) {
|
|
||||||
return { count: contactSubmissions.length, neutral: true }
|
|
||||||
}
|
|
||||||
if (view === 'subscribers' && subscribers.length > 0) {
|
if (view === 'subscribers' && subscribers.length > 0) {
|
||||||
return { count: subscribers.length, neutral: true }
|
return { count: subscribers.length, neutral: true }
|
||||||
}
|
}
|
||||||
@@ -3050,7 +3039,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
{c.message && <div className="admin-dashboard-feed-preview">{c.message.slice(0, 100)}{c.message.length > 100 ? '…' : ''}</div>}
|
{c.message && <div className="admin-dashboard-feed-preview">{c.message.slice(0, 100)}{c.message.length > 100 ? '…' : ''}</div>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button type="button" className="admin-dashboard-card-action" style={{ marginTop: '0.5rem' }} onClick={() => navigateTo('contacts')}>View All Contacts →</button>
|
<Link to="/contacts" className="admin-dashboard-card-action" style={{ marginTop: '0.5rem', display: 'inline-block' }}>View All Contacts →</Link>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -3107,125 +3096,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* CONTACTS */}
|
|
||||||
{adminView === 'contacts' && (() => {
|
|
||||||
const searchTerm = contactSearch.trim().toLowerCase()
|
|
||||||
const grouped = new Map<string, ContactSubmission[]>()
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<section className="admin-panel-section">
|
|
||||||
<div className="admin-panel-head">
|
|
||||||
<h2>Contacts</h2>
|
|
||||||
<p>{rolledUp.length} contacts from {contactSubmissions.length} total submissions — repeat senders are grouped together.</p>
|
|
||||||
</div>
|
|
||||||
<div className="admin-toolbar" style={{ marginBottom: '1rem', display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
placeholder="Search name, email, or message…"
|
|
||||||
value={contactSearch}
|
|
||||||
onChange={e => setContactSearch(e.target.value)}
|
|
||||||
style={{ minWidth: '260px', maxWidth: '440px', width: '100%' }}
|
|
||||||
/>
|
|
||||||
<span className="admin-stats-note" style={{ margin: 0 }}>{rolledUp.length} result{rolledUp.length !== 1 ? 's' : ''}</span>
|
|
||||||
</div>
|
|
||||||
{rolledUp.length === 0
|
|
||||||
? <p className="admin-stats-note">No contacts{contactSearch ? ' match your search' : ' yet'}.</p>
|
|
||||||
: (
|
|
||||||
<div className="admin-visits-table-wrap">
|
|
||||||
<table className="admin-visits-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Email</th>
|
|
||||||
<th>Type</th>
|
|
||||||
<th>Subscriber</th>
|
|
||||||
<th>Date</th>
|
|
||||||
<th>Message</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rolledUp.map(c => (
|
|
||||||
<tr key={c.id} className={c.archived ? 'admin-contacts-row--archived' : ''}>
|
|
||||||
<td>
|
|
||||||
<div>{c.name}</div>
|
|
||||||
{c.submissionCount > 1 && <div className="admin-stats-note" style={{ margin: '0.2rem 0 0' }}>{c.submissionCount} submissions</div>}
|
|
||||||
</td>
|
|
||||||
<td><a href={`mailto:${c.email}`}>{c.email}</a></td>
|
|
||||||
<td>
|
|
||||||
<span className="admin-badge admin-badge--pending" style={{ fontSize: '0.7rem' }}>{c.messageType ?? 'contact'}</span>
|
|
||||||
{c.source === 'inbound-email' && (
|
|
||||||
<span
|
|
||||||
className="admin-badge"
|
|
||||||
style={{
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
marginLeft: '0.3rem',
|
|
||||||
background: c.inboundTo?.includes('nate@') ? '#7c3aed' : '#0369a1',
|
|
||||||
color: '#fff',
|
|
||||||
}}
|
|
||||||
title={c.inboundTo ?? 'Direct email'}
|
|
||||||
>
|
|
||||||
{c.inboundTo?.includes('nate@') ? 'nate@' : 'hello@'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td style={{ textAlign: 'center' }}>{c.subscribe ? '✓' : ''}</td>
|
|
||||||
<td style={{ whiteSpace: 'nowrap' }}>{formatDate(c.submittedAt)}</td>
|
|
||||||
<td style={{ maxWidth: '280px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={c.message}>{c.message ?? '—'}</td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-admin-remove"
|
|
||||||
style={{ fontSize: '0.75rem', padding: '0.2rem 0.55rem', whiteSpace: 'nowrap' }}
|
|
||||||
onClick={async () => {
|
|
||||||
const label = c.name || c.email || 'this contact'
|
|
||||||
if (!confirm(`Delete ${c.submissionCount > 1 ? `all ${c.submissionCount} submissions` : 'submission'} from ${label}?`)) return
|
|
||||||
await Promise.all(c.allIds.map(id => fetch(`/api/admin-contact-submissions/${encodeURIComponent(id)}`, { method: 'DELETE' })))
|
|
||||||
await reloadContactSubmissions()
|
|
||||||
}}
|
|
||||||
>Remove</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* SUBSCRIBERS */}
|
{/* SUBSCRIBERS */}
|
||||||
{adminView === 'subscribers' && (() => {
|
{adminView === 'subscribers' && (() => {
|
||||||
const filteredSubs = subscribers.filter(s =>
|
const filteredSubs = subscribers.filter(s =>
|
||||||
|
|||||||
+748
@@ -10062,3 +10062,751 @@
|
|||||||
|
|
||||||
.em-footer-hint { display: none; }
|
.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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { ReactElement } from 'react'
|
|||||||
import { Link, NavLink, Routes, Route, useLocation, useNavigate, useParams } from 'react-router-dom'
|
import { Link, NavLink, Routes, Route, useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||||
import AdminPage from './AdminPage'
|
import AdminPage from './AdminPage'
|
||||||
import EmailShell from './EmailPage'
|
import EmailShell from './EmailPage'
|
||||||
|
import ContactsShell from './ContactsPage'
|
||||||
|
import CalendarShell from './CalendarPage'
|
||||||
import QASection from './components/QASection'
|
import QASection from './components/QASection'
|
||||||
import ContactForm from './components/ContactForm'
|
import ContactForm from './components/ContactForm'
|
||||||
import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySectionPage, StudyLandingPage, StudySignupPage, StudyAccountPage, StudyCommunityPage, StudyQuizPage } from './colossiansStudy'
|
import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySectionPage, StudyLandingPage, StudySignupPage, StudyAccountPage, StudyCommunityPage, StudyQuizPage } from './colossiansStudy'
|
||||||
@@ -2496,6 +2498,8 @@ export default function App() {
|
|||||||
<Route path="/certificate/:token" element={<PublicCertificatePage />} />
|
<Route path="/certificate/:token" element={<PublicCertificatePage />} />
|
||||||
<Route path="/admin" element={<AdminShell content={content} onSave={setContent} />} />
|
<Route path="/admin" element={<AdminShell content={content} onSave={setContent} />} />
|
||||||
<Route path="/email" element={<EmailShell />} />
|
<Route path="/email" element={<EmailShell />} />
|
||||||
|
<Route path="/contacts" element={<ContactsShell />} />
|
||||||
|
<Route path="/calendar" element={<CalendarShell />} />
|
||||||
<Route path="/preview" element={<PreviewPage />} />
|
<Route path="/preview" element={<PreviewPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="/privacy"
|
path="/privacy"
|
||||||
|
|||||||
@@ -0,0 +1,526 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface PodcastChecklistTask {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
required: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PodcastChecklistEpisode {
|
||||||
|
id: string
|
||||||
|
series: string
|
||||||
|
episodeNumber: number | null
|
||||||
|
title: string
|
||||||
|
datePublished: string
|
||||||
|
expanded: boolean
|
||||||
|
tasks: Record<string, boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <div className="em-auth-loading">Loading…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authState === 'needs-password') {
|
||||||
|
return (
|
||||||
|
<div className="em-auth-wrap">
|
||||||
|
<form className="em-auth-form" onSubmit={handleLogin}>
|
||||||
|
<h1 className="em-auth-title">Release Calendar</h1>
|
||||||
|
<label className="em-auth-label">Admin password
|
||||||
|
<input type="password" className="em-auth-input" value={password} onChange={e => setPassword(e.target.value)} autoFocus />
|
||||||
|
</label>
|
||||||
|
{authError && <p className="em-auth-error">{authError}</p>}
|
||||||
|
<button type="submit" className="em-btn em-btn--primary" disabled={authBusy}>
|
||||||
|
{authBusy ? 'Signing in…' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authState === 'needs-totp') {
|
||||||
|
return (
|
||||||
|
<div className="em-auth-wrap">
|
||||||
|
<form className="em-auth-form" onSubmit={handleTotp}>
|
||||||
|
<h1 className="em-auth-title">Two-factor code</h1>
|
||||||
|
<label className="em-auth-label">Authenticator code
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="em-auth-input"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
maxLength={6}
|
||||||
|
value={totp}
|
||||||
|
onChange={e => setTotp(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{authError && <p className="em-auth-error">{authError}</p>}
|
||||||
|
<button type="submit" className="em-btn em-btn--primary" disabled={authBusy}>
|
||||||
|
{authBusy ? 'Verifying…' : 'Verify'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <CalendarClient />
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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<PodcastChecklistData | null>(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<string | null>(null)
|
||||||
|
const [editEp, setEditEp] = useState<PodcastChecklistEpisode | null>(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<string, PodcastChecklistEpisode[]>()
|
||||||
|
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 (
|
||||||
|
<div className="cal-app">
|
||||||
|
<header className="cal-header">
|
||||||
|
<div className="cal-header-left">
|
||||||
|
<button type="button" className="cal-nav-btn" onClick={prevMonth} aria-label="Previous month">‹</button>
|
||||||
|
<h1 className="cal-month-title">
|
||||||
|
{MONTH_NAMES[viewMonth]} {viewYear}
|
||||||
|
</h1>
|
||||||
|
<button type="button" className="cal-nav-btn" onClick={nextMonth} aria-label="Next month">›</button>
|
||||||
|
{saving && <span className="cal-saving">Saving…</span>}
|
||||||
|
</div>
|
||||||
|
<div className="cal-header-right">
|
||||||
|
{selectedEpisodeId && (
|
||||||
|
<span className="cal-scheduling-hint">
|
||||||
|
Click a date to schedule ·{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="cal-cancel-link"
|
||||||
|
onClick={() => setSelectedEpisodeId(null)}
|
||||||
|
>
|
||||||
|
cancel
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="em-btn em-btn--secondary em-btn--sm"
|
||||||
|
onClick={() => setNewEpOpen(o => !o)}
|
||||||
|
>
|
||||||
|
{newEpOpen ? 'Cancel' : '+ New Episode'}
|
||||||
|
</button>
|
||||||
|
<Link to="/admin" className="em-btn em-btn--ghost em-btn--sm">← Admin</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{newEpOpen && (
|
||||||
|
<div className="cal-new-ep-banner">
|
||||||
|
<form className="cal-new-ep-form" onSubmit={handleNewEpisode}>
|
||||||
|
<h3 className="cal-new-ep-title">New Episode</h3>
|
||||||
|
<div className="cal-new-ep-row">
|
||||||
|
<label className="cal-new-ep-label">
|
||||||
|
Series
|
||||||
|
<input className="ct-input" type="text" placeholder="e.g. Colossians" value={newForm.series} onChange={e => setNewForm(f => ({ ...f, series: e.target.value }))} />
|
||||||
|
</label>
|
||||||
|
<label className="cal-new-ep-label">
|
||||||
|
Episode #
|
||||||
|
<input className="ct-input" type="number" placeholder="42" value={newForm.episodeNumber} onChange={e => setNewForm(f => ({ ...f, episodeNumber: e.target.value }))} />
|
||||||
|
</label>
|
||||||
|
<label className="cal-new-ep-label">
|
||||||
|
Title
|
||||||
|
<input className="ct-input" type="text" placeholder="Episode title" value={newForm.title} onChange={e => setNewForm(f => ({ ...f, title: e.target.value }))} />
|
||||||
|
</label>
|
||||||
|
<label className="cal-new-ep-label">
|
||||||
|
Date
|
||||||
|
<input className="ct-input" type="date" value={newForm.datePublished} onChange={e => setNewForm(f => ({ ...f, datePublished: e.target.value }))} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="cal-new-ep-actions">
|
||||||
|
<button type="submit" className="em-btn em-btn--primary em-btn--sm" disabled={newBusy}>
|
||||||
|
{newBusy ? 'Adding…' : 'Add Episode'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="cal-body">
|
||||||
|
<div className="cal-main">
|
||||||
|
{loading ? (
|
||||||
|
<p className="cal-loading">Loading calendar…</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="cal-dow-row">
|
||||||
|
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map(d => (
|
||||||
|
<div key={d} className="cal-dow">{d}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="cal-grid">
|
||||||
|
{calDays.map(({ date, inMonth }, i) => {
|
||||||
|
const dk = toDateKey(date)
|
||||||
|
const eps = episodesByDate.get(dk) ?? []
|
||||||
|
const isToday = dk === todayKey
|
||||||
|
const isSelecting = Boolean(selectedEpisodeId)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={[
|
||||||
|
'cal-day',
|
||||||
|
!inMonth ? 'cal-day--out' : '',
|
||||||
|
isToday ? 'cal-day--today' : '',
|
||||||
|
isSelecting && inMonth ? 'cal-day--selectable' : '',
|
||||||
|
].filter(Boolean).join(' ')}
|
||||||
|
onClick={() => handleDayClick(date, inMonth)}
|
||||||
|
>
|
||||||
|
<span className="cal-day-num">{date.getDate()}</span>
|
||||||
|
<div className="cal-day-events">
|
||||||
|
{eps.map(ep => (
|
||||||
|
<button
|
||||||
|
key={ep.id}
|
||||||
|
type="button"
|
||||||
|
className="cal-ep-chip"
|
||||||
|
title={[ep.series, ep.episodeNumber ? `Ep ${ep.episodeNumber}` : null, ep.title].filter(Boolean).join(' · ')}
|
||||||
|
onClick={e => { e.stopPropagation(); openEdit(ep) }}
|
||||||
|
>
|
||||||
|
{ep.episodeNumber ? `Ep ${ep.episodeNumber}` : ep.series?.slice(0, 6) ?? '—'}
|
||||||
|
{ep.title && <span className="cal-ep-chip-title"> {ep.title.slice(0, 18)}{ep.title.length > 18 ? '…' : ''}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="cal-sidebar">
|
||||||
|
<h2 className="cal-sidebar-title">
|
||||||
|
Unscheduled
|
||||||
|
{unscheduled.length > 0 && <span className="cal-sidebar-count">{unscheduled.length}</span>}
|
||||||
|
</h2>
|
||||||
|
{selectedEpisodeId && (
|
||||||
|
<p className="cal-sidebar-hint">Click a date on the calendar to schedule.</p>
|
||||||
|
)}
|
||||||
|
{unscheduled.length === 0 ? (
|
||||||
|
<p className="cal-sidebar-empty">All episodes are scheduled.</p>
|
||||||
|
) : (
|
||||||
|
<div className="cal-sidebar-list">
|
||||||
|
{unscheduled.map(ep => (
|
||||||
|
<div
|
||||||
|
key={ep.id}
|
||||||
|
className={`cal-sidebar-ep${selectedEpisodeId === ep.id ? ' cal-sidebar-ep--selected' : ''}`}
|
||||||
|
onClick={() => setSelectedEpisodeId(id => id === ep.id ? null : ep.id)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') setSelectedEpisodeId(id => id === ep.id ? null : ep.id) }}
|
||||||
|
>
|
||||||
|
<div className="cal-sidebar-ep-header">
|
||||||
|
<span className="cal-sidebar-ep-num">
|
||||||
|
{ep.episodeNumber ? `Ep ${ep.episodeNumber}` : ep.series}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="cal-sidebar-ep-edit"
|
||||||
|
title="Edit"
|
||||||
|
onClick={e => { e.stopPropagation(); openEdit(ep) }}
|
||||||
|
>
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="cal-sidebar-ep-title">{ep.title || <em>Untitled</em>}</div>
|
||||||
|
{ep.series && ep.episodeNumber && (
|
||||||
|
<div className="cal-sidebar-ep-series">{ep.series}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit popover */}
|
||||||
|
{editEp && (
|
||||||
|
<div className="cal-popover-overlay" onClick={() => setEditEp(null)}>
|
||||||
|
<div className="cal-popover" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="cal-popover-head">
|
||||||
|
<h3>Edit Episode</h3>
|
||||||
|
<button type="button" className="cal-popover-close" onClick={() => setEditEp(null)}>×</button>
|
||||||
|
</div>
|
||||||
|
<div className="cal-popover-body">
|
||||||
|
<label className="cal-popover-label">
|
||||||
|
Series
|
||||||
|
<input
|
||||||
|
className="ct-input"
|
||||||
|
type="text"
|
||||||
|
value={editForm.series}
|
||||||
|
onChange={e => setEditForm(f => ({ ...f, series: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="cal-popover-label">
|
||||||
|
Episode #
|
||||||
|
<input
|
||||||
|
className="ct-input"
|
||||||
|
type="number"
|
||||||
|
value={editForm.episodeNumber}
|
||||||
|
onChange={e => setEditForm(f => ({ ...f, episodeNumber: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="cal-popover-label">
|
||||||
|
Title
|
||||||
|
<input
|
||||||
|
className="ct-input"
|
||||||
|
type="text"
|
||||||
|
value={editForm.title}
|
||||||
|
onChange={e => setEditForm(f => ({ ...f, title: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="cal-popover-label">
|
||||||
|
Date Published
|
||||||
|
<input
|
||||||
|
className="ct-input"
|
||||||
|
type="date"
|
||||||
|
value={editForm.datePublished}
|
||||||
|
onChange={e => setEditForm(f => ({ ...f, datePublished: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="cal-popover-actions">
|
||||||
|
<button type="button" className="em-btn em-btn--primary em-btn--sm" onClick={saveEdit}>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => unschedule(editEp)}>
|
||||||
|
Unschedule
|
||||||
|
</button>
|
||||||
|
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => setEditEp(null)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 <div className="em-auth-loading">Loading…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authState === 'needs-password') {
|
||||||
|
return (
|
||||||
|
<div className="em-auth-wrap">
|
||||||
|
<form className="em-auth-form" onSubmit={handleLogin}>
|
||||||
|
<h1 className="em-auth-title">Contacts</h1>
|
||||||
|
<label className="em-auth-label">Admin password
|
||||||
|
<input type="password" className="em-auth-input" value={password} onChange={e => setPassword(e.target.value)} autoFocus />
|
||||||
|
</label>
|
||||||
|
{authError && <p className="em-auth-error">{authError}</p>}
|
||||||
|
<button type="submit" className="em-btn em-btn--primary" disabled={authBusy}>
|
||||||
|
{authBusy ? 'Signing in…' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authState === 'needs-totp') {
|
||||||
|
return (
|
||||||
|
<div className="em-auth-wrap">
|
||||||
|
<form className="em-auth-form" onSubmit={handleTotp}>
|
||||||
|
<h1 className="em-auth-title">Two-factor code</h1>
|
||||||
|
<label className="em-auth-label">Authenticator code
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="em-auth-input"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
maxLength={6}
|
||||||
|
value={totp}
|
||||||
|
onChange={e => setTotp(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{authError && <p className="em-auth-error">{authError}</p>}
|
||||||
|
<button type="submit" className="em-btn em-btn--primary" disabled={authBusy}>
|
||||||
|
{authBusy ? 'Verifying…' : 'Verify'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ContactsClient />
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Contacts Client ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ContactsClient() {
|
||||||
|
const [submissions, setSubmissions] = useState<ContactSubmission[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [showArchived, setShowArchived] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(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<string, ContactSubmission[]>()
|
||||||
|
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 <span className="ct-badge ct-badge--manual">manual</span>
|
||||||
|
if (source === 'inbound-email') return <span className="ct-badge ct-badge--email">email</span>
|
||||||
|
if (source === 'download') return <span className="ct-badge ct-badge--download">download</span>
|
||||||
|
return <span className="ct-badge ct-badge--form">form</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso: string) {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
|
} catch { return '—' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ct-app">
|
||||||
|
<header className="ct-header">
|
||||||
|
<div className="ct-header-brand">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="9" cy="7" r="4" />
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||||
|
</svg>
|
||||||
|
Contacts
|
||||||
|
<span className="ct-header-count">{contacts.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="ct-header-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="em-btn em-btn--secondary em-btn--sm"
|
||||||
|
onClick={() => { setAddOpen(o => !o); setAddError('') }}
|
||||||
|
>
|
||||||
|
{addOpen ? 'Cancel' : '+ Add Contact'}
|
||||||
|
</button>
|
||||||
|
<Link to="/email" className="em-btn em-btn--ghost em-btn--sm">Email</Link>
|
||||||
|
<Link to="/admin" className="em-btn em-btn--ghost em-btn--sm">← Admin</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{addOpen && (
|
||||||
|
<div className="ct-add-banner">
|
||||||
|
<form className="ct-add-form" onSubmit={handleAdd}>
|
||||||
|
<h3 className="ct-add-title">Add Contact</h3>
|
||||||
|
<div className="ct-add-row">
|
||||||
|
<label className="ct-add-label">
|
||||||
|
Name
|
||||||
|
<input
|
||||||
|
className="ct-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Full name"
|
||||||
|
value={addName}
|
||||||
|
onChange={e => setAddName(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="ct-add-label">
|
||||||
|
Email
|
||||||
|
<input
|
||||||
|
className="ct-input"
|
||||||
|
type="email"
|
||||||
|
placeholder="email@example.com"
|
||||||
|
value={addEmail}
|
||||||
|
onChange={e => setAddEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="ct-add-label ct-add-label--full">
|
||||||
|
Notes
|
||||||
|
<textarea
|
||||||
|
className="ct-input ct-notes-input"
|
||||||
|
placeholder="Notes (optional)"
|
||||||
|
value={addNotes}
|
||||||
|
onChange={e => setAddNotes(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{addError && <p className="ct-error">{addError}</p>}
|
||||||
|
<div className="ct-add-actions">
|
||||||
|
<button type="submit" className="em-btn em-btn--primary em-btn--sm" disabled={addBusy}>
|
||||||
|
{addBusy ? 'Adding…' : 'Add Contact'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="ct-toolbar">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="ct-search"
|
||||||
|
placeholder="Search name, email, message, or notes…"
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
<label className="ct-archived-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showArchived}
|
||||||
|
onChange={e => setShowArchived(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Show archived
|
||||||
|
</label>
|
||||||
|
<span className="ct-count-label">
|
||||||
|
{filtered.length} contact{filtered.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ct-list">
|
||||||
|
{loading && <p className="ct-empty">Loading contacts…</p>}
|
||||||
|
{!loading && filtered.length === 0 && (
|
||||||
|
<p className="ct-empty">
|
||||||
|
{search ? 'No contacts match your search.' : 'No contacts yet. Add one above.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filtered.map(c => (
|
||||||
|
<div key={c.mainId} className={`ct-card${c.archived ? ' ct-card--archived' : ''}`}>
|
||||||
|
<div className="ct-avatar" aria-hidden="true">
|
||||||
|
{(c.name || c.email || '?').charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ct-card-body">
|
||||||
|
{editingId === c.mainId ? (
|
||||||
|
<div className="ct-edit-form">
|
||||||
|
<div className="ct-edit-row">
|
||||||
|
<label className="ct-edit-label">
|
||||||
|
Name
|
||||||
|
<input
|
||||||
|
className="ct-input"
|
||||||
|
type="text"
|
||||||
|
value={editName}
|
||||||
|
onChange={e => setEditName(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="ct-edit-label">
|
||||||
|
Notes
|
||||||
|
<textarea
|
||||||
|
className="ct-input ct-notes-input"
|
||||||
|
rows={3}
|
||||||
|
value={editNotes}
|
||||||
|
onChange={e => setEditNotes(e.target.value)}
|
||||||
|
placeholder="Add a note about this contact…"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="ct-edit-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="em-btn em-btn--primary em-btn--sm"
|
||||||
|
onClick={saveEdit}
|
||||||
|
disabled={editSaving}
|
||||||
|
>
|
||||||
|
{editSaving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="em-btn em-btn--ghost em-btn--sm"
|
||||||
|
onClick={() => setEditingId(null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="ct-card-top">
|
||||||
|
<span className="ct-card-name">{c.name || <em>No name</em>}</span>
|
||||||
|
{c.email && (
|
||||||
|
<a className="ct-card-email" href={`mailto:${c.email}`}>{c.email}</a>
|
||||||
|
)}
|
||||||
|
{sourceBadge(c.source)}
|
||||||
|
{c.subscribe && <span className="ct-badge ct-badge--sub">subscriber</span>}
|
||||||
|
{c.submissionCount > 1 && (
|
||||||
|
<span className="ct-count-badge">{c.submissionCount}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className={`ct-card-notes${!c.notes ? ' ct-card-notes--empty' : ''}`}>
|
||||||
|
{c.notes || 'No notes — click Edit to add'}
|
||||||
|
</p>
|
||||||
|
<div className="ct-card-bottom">
|
||||||
|
<span className="ct-card-date">{fmtDate(c.submittedAt)}</span>
|
||||||
|
{c.message && (
|
||||||
|
<span className="ct-card-preview">
|
||||||
|
{c.message.length > 90 ? c.message.slice(0, 90) + '…' : c.message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editingId !== c.mainId && (
|
||||||
|
<div className="ct-card-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="em-btn em-btn--ghost em-btn--sm"
|
||||||
|
onClick={() => startEdit(c)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="em-btn em-btn--danger em-btn--sm"
|
||||||
|
onClick={() => deleteContact(c)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+84
-3
@@ -60,6 +60,10 @@ interface ReplyConfig {
|
|||||||
note: string
|
note: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface EmailSettings {
|
||||||
|
signature: string
|
||||||
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function extractSubject(message: string): string {
|
function extractSubject(message: string): string {
|
||||||
@@ -120,6 +124,11 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
|
|||||||
const [templateStatus, setTemplateStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
const [templateStatus, setTemplateStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||||
const [showTemplatesMgr, setShowTemplatesMgr] = useState(false)
|
const [showTemplatesMgr, setShowTemplatesMgr] = useState(false)
|
||||||
const [showHistory, setShowHistory] = useState(false)
|
const [showHistory, setShowHistory] = useState(false)
|
||||||
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
|
const [emailSettings, setEmailSettings] = useState<EmailSettings>({ 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 [actionMsg, setActionMsg] = useState('')
|
||||||
const composeRef = useRef<HTMLTextAreaElement>(null)
|
const composeRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const listRef = useRef<HTMLElement>(null)
|
const listRef = useRef<HTMLElement>(null)
|
||||||
@@ -128,11 +137,12 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
|
|||||||
|
|
||||||
const loadAll = useCallback(async () => {
|
const loadAll = useCallback(async () => {
|
||||||
try {
|
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-submissions'),
|
||||||
fetch('/api/admin-contact-reply-templates'),
|
fetch('/api/admin-contact-reply-templates'),
|
||||||
fetch('/api/admin-contact-reply-history'),
|
fetch('/api/admin-contact-reply-history'),
|
||||||
fetch('/api/admin-reply-config'),
|
fetch('/api/admin-reply-config'),
|
||||||
|
fetch('/api/admin-email-settings'),
|
||||||
])
|
])
|
||||||
if (!subRes.ok) throw new Error('submissions failed')
|
if (!subRes.ok) throw new Error('submissions failed')
|
||||||
const subData = await subRes.json() as { submissions?: ContactSubmission[] }
|
const subData = await subRes.json() as { submissions?: ContactSubmission[] }
|
||||||
@@ -147,6 +157,10 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
|
|||||||
setHistory(d.items ?? [])
|
setHistory(d.items ?? [])
|
||||||
}
|
}
|
||||||
if (cfgRes.ok) setConfig(await cfgRes.json() as ReplyConfig)
|
if (cfgRes.ok) setConfig(await cfgRes.json() as ReplyConfig)
|
||||||
|
if (settingsRes.ok) {
|
||||||
|
const s = await settingsRes.json() as EmailSettings
|
||||||
|
setEmailSettings(s)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setLoadStatus('error')
|
setLoadStatus('error')
|
||||||
}
|
}
|
||||||
@@ -543,8 +557,8 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
|
|||||||
onChange={e => setReplyDraft({ ...replyDraft, message: e.target.value })}
|
onChange={e => setReplyDraft({ ...replyDraft, message: e.target.value })}
|
||||||
/>
|
/>
|
||||||
<div className="em-compose-signature">
|
<div className="em-compose-signature">
|
||||||
Grace and peace, · Verse by Verse with Nate
|
<span className="em-compose-signature-text">{emailSettings.signature}</span>
|
||||||
<span className="em-compose-signature-note">auto-appended</span>
|
<span className="em-compose-signature-note">auto-appended · <button type="button" className="em-sig-edit-link" onClick={() => { setSettingsSig(emailSettings.signature); setShowSettings(true) }}>edit signature</button></span>
|
||||||
</div>
|
</div>
|
||||||
<div className="em-compose-actions">
|
<div className="em-compose-actions">
|
||||||
<button type="button" className="em-btn em-btn--primary" onClick={handleSend} disabled={replySending}>
|
<button type="button" className="em-btn em-btn--primary" onClick={handleSend} disabled={replySending}>
|
||||||
@@ -788,6 +802,70 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Settings drawer */}
|
||||||
|
{showSettings && (
|
||||||
|
<div className="em-drawer-overlay" onClick={e => { if (e.target === e.currentTarget) setShowSettings(false) }}>
|
||||||
|
<div className="em-drawer">
|
||||||
|
<div className="em-drawer-header">
|
||||||
|
<h3>Email Settings</h3>
|
||||||
|
<button type="button" className="em-compose-close" onClick={() => setShowSettings(false)}>×</button>
|
||||||
|
</div>
|
||||||
|
<div className="em-drawer-body">
|
||||||
|
<div className="em-settings-section">
|
||||||
|
<label className="em-settings-label">
|
||||||
|
Outgoing Signature
|
||||||
|
<p className="em-settings-note">Appended to every reply and compose email you send.</p>
|
||||||
|
<textarea
|
||||||
|
className="em-compose-body em-settings-sig-textarea"
|
||||||
|
rows={4}
|
||||||
|
value={settingsSig}
|
||||||
|
onChange={e => { setSettingsSig(e.target.value); setSettingsSaved(false) }}
|
||||||
|
placeholder="Grace and peace, Verse by Verse with Nate"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{config && (
|
||||||
|
<div className="em-settings-section">
|
||||||
|
<p className="em-settings-label">
|
||||||
|
Email Configuration
|
||||||
|
<span className={`em-settings-status${config.canSendReplies ? ' em-settings-status--ok' : ' em-settings-status--warn'}`}>
|
||||||
|
{config.canSendReplies ? 'Configured' : 'Not configured'}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="em-settings-note">{config.note}</p>
|
||||||
|
{config.fromEmail && <p className="em-settings-note">Reply-to: {config.fromEmail}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="em-drawer-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="em-btn em-btn--primary"
|
||||||
|
disabled={settingsSaving}
|
||||||
|
onClick={async () => {
|
||||||
|
setSettingsSaving(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin-email-settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ signature: settingsSig }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json() as { settings?: EmailSettings }
|
||||||
|
if (data.settings) setEmailSettings(data.settings)
|
||||||
|
setSettingsSaved(true)
|
||||||
|
}
|
||||||
|
} catch { /* silent */ }
|
||||||
|
setSettingsSaving(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{settingsSaving ? 'Saving…' : settingsSaved ? 'Saved ✓' : 'Save Settings'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Footer toolbar */}
|
{/* Footer toolbar */}
|
||||||
<div className="em-footer-toolbar">
|
<div className="em-footer-toolbar">
|
||||||
{config && !config.canSendReplies && (
|
{config && !config.canSendReplies && (
|
||||||
@@ -799,6 +877,9 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
|
|||||||
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => setShowHistory(true)}>
|
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => setShowHistory(true)}>
|
||||||
Sent History
|
Sent History
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => { setSettingsSig(emailSettings.signature); setShowSettings(true) }}>
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
<span className="em-footer-hint">↑ ↓ navigate · r reply · e archive · Esc close</span>
|
<span className="em-footer-hint">↑ ↓ navigate · r reply · e archive · Esc close</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user