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:
nmemmert
2026-07-28 10:12:52 -04:00
parent 7670b44d27
commit ce235ba9ef
13 changed files with 1952 additions and 145 deletions
+2 -132
View File
@@ -688,7 +688,7 @@ type AdminView =
| 'podcast' | 'current-series' | 'episode-highlights' | 'podcast-checklist'
| 'downloads' | 'custom-links' | 'content-blocks'
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
| 'subscribers' | 'contacts' | 'study-users' | 'email-templates'
| 'subscribers' | 'study-users' | 'email-templates'
| 'seo' | 'legal' | 'security' | 'brand' | 'global'
interface AdminSectionLink {
@@ -725,7 +725,6 @@ const ADMIN_VIEW_OPTIONS: Array<{ group: string; options: Array<{ value: AdminVi
options: [
{ value: 'questions', label: 'Questions' },
{ value: 'study-comments', label: 'Study Comments' },
{ value: 'contacts', label: 'Contacts' },
{ value: 'subscribers', label: 'Subscribers' },
{ value: 'study-users', label: 'Study Users' },
],
@@ -1123,7 +1122,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [navSearch, setNavSearch] = useState('')
const [subscribers, setSubscribers] = useState<Subscriber[]>([])
const [subscriberSearch, setSubscriberSearch] = useState('')
const [contactSearch, setContactSearch] = useState('')
const [downloadStats, setDownloadStats] = useState<Record<string, number>>({})
// Study comments moderation state
interface AdminComment { id: string; studySlug: string; sectionId: string; displayName: string; text: string; createdAt: string; isApproved: boolean; approvedAt: string | null }
@@ -1420,12 +1418,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
setStatsStatus('ready')
}
async function reloadContactSubmissions() {
const r = await fetch('/api/admin-contact-submissions')
if (!r.ok) throw new Error('Could not refresh contact submissions')
const data = await r.json() as { submissions?: ContactSubmission[] }
setContactSubmissions(Array.isArray(data.submissions) ? data.submissions : [])
}
useEffect(() => {
setQuestionPage(0)
@@ -2209,9 +2201,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
if (view === 'questions' && unansweredCount > 0) {
return { count: unansweredCount, neutral: false }
}
if (view === 'contacts' && contactSubmissions.length > 0) {
return { count: contactSubmissions.length, neutral: true }
}
if (view === 'subscribers' && subscribers.length > 0) {
return { count: subscribers.length, neutral: true }
}
@@ -3050,7 +3039,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
{c.message && <div className="admin-dashboard-feed-preview">{c.message.slice(0, 100)}{c.message.length > 100 ? '…' : ''}</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>
)
}
@@ -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 */}
{adminView === 'subscribers' && (() => {
const filteredSubs = subscribers.filter(s =>
+748
View File
@@ -10062,3 +10062,751 @@
.em-footer-hint { display: none; }
}
/* ── Contacts Page (/contacts) ────────────────────────────────────────────── */
.ct-app {
display: flex;
flex-direction: column;
height: 100dvh;
background: #0f0f0f;
color: #e8e2d5;
font-family: system-ui, -apple-system, sans-serif;
overflow: hidden;
}
.ct-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.25rem;
background: #1a1a1a;
border-bottom: 1px solid #2a2a2a;
flex-shrink: 0;
}
.ct-header-brand {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
font-weight: 600;
color: #e8e2d5;
}
.ct-header-count {
background: #2e2e2e;
color: #9a9080;
font-size: 0.72rem;
font-weight: 500;
padding: 0.15rem 0.5rem;
border-radius: 999px;
margin-left: 0.25rem;
}
.ct-header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.ct-add-banner {
background: #141414;
border-bottom: 1px solid #2a2a2a;
padding: 1rem 1.25rem;
flex-shrink: 0;
}
.ct-add-form { max-width: 720px; }
.ct-add-title {
font-size: 0.85rem;
font-weight: 600;
color: #b0a898;
margin: 0 0 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.ct-add-row {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 0.6rem;
}
.ct-add-label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.78rem;
color: #9a9080;
flex: 1;
min-width: 180px;
}
.ct-add-label--full { width: 100%; flex: none; }
.ct-input {
background: #1e1e1e;
border: 1px solid #333;
border-radius: 6px;
color: #e8e2d5;
padding: 0.45rem 0.65rem;
font-size: 0.88rem;
width: 100%;
box-sizing: border-box;
font-family: inherit;
}
.ct-input:focus {
outline: none;
border-color: #c8860a;
box-shadow: 0 0 0 2px rgba(200, 134, 10, 0.18);
}
.ct-notes-input { resize: vertical; }
.ct-add-actions { margin-top: 0.6rem; }
.ct-error {
color: #f87171;
font-size: 0.83rem;
margin: 0.4rem 0 0;
}
.ct-toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.65rem 1.25rem;
background: #161616;
border-bottom: 1px solid #242424;
flex-shrink: 0;
flex-wrap: wrap;
}
.ct-search {
flex: 1;
min-width: 200px;
max-width: 480px;
background: #1e1e1e;
border: 1px solid #2e2e2e;
border-radius: 6px;
color: #e8e2d5;
padding: 0.42rem 0.75rem;
font-size: 0.87rem;
}
.ct-search:focus { outline: none; border-color: #c8860a; }
.ct-archived-toggle {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.82rem;
color: #7a7060;
cursor: pointer;
}
.ct-count-label {
font-size: 0.82rem;
color: #7a7060;
margin-left: auto;
}
.ct-list {
flex: 1;
overflow-y: auto;
padding: 0.75rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.ct-empty {
color: #6a6050;
font-size: 0.9rem;
text-align: center;
margin-top: 2rem;
}
.ct-card {
display: flex;
gap: 0.9rem;
align-items: flex-start;
background: #1a1a1a;
border: 1px solid #2a2a2a;
border-radius: 10px;
padding: 0.85rem 1rem;
transition: border-color 0.15s;
}
.ct-card:hover { border-color: #3a3a3a; }
.ct-card--archived { opacity: 0.5; }
.ct-avatar {
width: 38px;
height: 38px;
border-radius: 50%;
background: linear-gradient(135deg, #c8860a, #9a6408);
color: #fff;
font-size: 1rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
user-select: none;
}
.ct-card-body { flex: 1; min-width: 0; }
.ct-card-top {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.4rem;
}
.ct-card-name {
font-size: 0.92rem;
font-weight: 600;
color: #e8e2d5;
}
.ct-card-email {
font-size: 0.82rem;
color: #c8860a;
text-decoration: none;
}
.ct-card-email:hover { text-decoration: underline; }
.ct-badge {
font-size: 0.7rem;
font-weight: 600;
padding: 0.15rem 0.45rem;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.ct-badge--form { background: #1e3a2e; color: #4ade80; }
.ct-badge--email { background: #1e2a4a; color: #60a5fa; }
.ct-badge--download { background: #2a2015; color: #f59e0b; }
.ct-badge--manual { background: #2a1a2e; color: #c084fc; }
.ct-badge--sub { background: #0f2a1e; color: #34d399; }
.ct-count-badge {
font-size: 0.7rem;
background: #2e2e2e;
color: #9a9080;
padding: 0.12rem 0.45rem;
border-radius: 999px;
}
.ct-card-notes {
font-size: 0.83rem;
color: #a09080;
margin: 0.25rem 0;
line-height: 1.4;
}
.ct-card-notes--empty { color: #554f45; font-style: italic; }
.ct-card-bottom {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 0.3rem;
flex-wrap: wrap;
}
.ct-card-date {
font-size: 0.78rem;
color: #6a6050;
flex-shrink: 0;
}
.ct-card-preview {
font-size: 0.8rem;
color: #706050;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.ct-card-actions {
display: flex;
flex-direction: column;
gap: 0.35rem;
flex-shrink: 0;
align-self: center;
}
.ct-edit-form { display: flex; flex-direction: column; gap: 0.55rem; }
.ct-edit-row { display: flex; gap: 0.6rem; }
.ct-edit-label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.78rem;
color: #9a9080;
flex: 1;
}
.ct-edit-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.2rem;
}
/* ── Calendar Page (/calendar) ─────────────────────────────────────────────── */
.cal-app {
display: flex;
flex-direction: column;
height: 100dvh;
background: #0f0f0f;
color: #e8e2d5;
font-family: system-ui, -apple-system, sans-serif;
overflow: hidden;
}
.cal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.25rem;
background: #1a1a1a;
border-bottom: 1px solid #2a2a2a;
flex-shrink: 0;
gap: 0.75rem;
flex-wrap: wrap;
}
.cal-header-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.cal-header-right {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.cal-month-title {
font-size: 1.1rem;
font-weight: 600;
color: #e8e2d5;
margin: 0;
min-width: 180px;
text-align: center;
}
.cal-nav-btn {
background: #2a2a2a;
border: 1px solid #363636;
color: #c0b8a8;
border-radius: 6px;
width: 32px;
height: 32px;
font-size: 1.1rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.12s;
}
.cal-nav-btn:hover { background: #333; }
.cal-saving {
font-size: 0.8rem;
color: #c8860a;
margin-left: 0.25rem;
}
.cal-scheduling-hint {
font-size: 0.82rem;
color: #c8860a;
}
.cal-cancel-link {
background: none;
border: none;
color: #c8860a;
cursor: pointer;
text-decoration: underline;
font-size: inherit;
padding: 0;
}
.cal-new-ep-banner {
background: #141414;
border-bottom: 1px solid #2a2a2a;
padding: 0.9rem 1.25rem;
flex-shrink: 0;
}
.cal-new-ep-form { max-width: 900px; }
.cal-new-ep-title {
font-size: 0.82rem;
font-weight: 600;
color: #8a8070;
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 0.6rem;
}
.cal-new-ep-row {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
margin-bottom: 0.6rem;
}
.cal-new-ep-label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.77rem;
color: #8a8070;
flex: 1;
min-width: 140px;
}
.cal-new-ep-actions { display: flex; gap: 0.5rem; }
.cal-body {
flex: 1;
display: flex;
overflow: hidden;
}
.cal-main {
flex: 1;
overflow-y: auto;
padding: 0.75rem;
min-width: 0;
}
.cal-loading {
color: #6a6050;
text-align: center;
margin-top: 3rem;
}
.cal-dow-row {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
margin-bottom: 2px;
}
.cal-dow {
font-size: 0.72rem;
font-weight: 600;
color: #6a6050;
text-align: center;
padding: 0.35rem 0;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.cal-day {
background: #161616;
border: 1px solid #232323;
border-radius: 6px;
min-height: 90px;
padding: 0.4rem;
cursor: default;
display: flex;
flex-direction: column;
transition: border-color 0.12s;
}
.cal-day--out { opacity: 0.35; }
.cal-day--today { border-color: #c8860a; }
.cal-day--selectable { cursor: pointer; }
.cal-day--selectable:hover { background: #1c1c12; border-color: #c8860a66; }
.cal-day-num {
font-size: 0.75rem;
font-weight: 600;
color: #7a7060;
display: block;
margin-bottom: 0.3rem;
}
.cal-day--today .cal-day-num { color: #c8860a; }
.cal-day-events {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.cal-ep-chip {
background: linear-gradient(135deg, #1e2e1a, #172015);
border: 1px solid #2a3a24;
color: #6adb88;
font-size: 0.68rem;
padding: 0.2rem 0.4rem;
border-radius: 4px;
cursor: pointer;
text-align: left;
transition: background 0.12s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
.cal-ep-chip:hover { background: #2a3e24; }
.cal-ep-chip-title { color: #4a8a58; }
.cal-sidebar {
width: 220px;
flex-shrink: 0;
background: #141414;
border-left: 1px solid #222;
display: flex;
flex-direction: column;
overflow: hidden;
}
.cal-sidebar-title {
font-size: 0.78rem;
font-weight: 600;
color: #8a8070;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.75rem 0.9rem 0.5rem;
border-bottom: 1px solid #232323;
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.cal-sidebar-count {
background: #2e2e2e;
color: #9a9080;
font-size: 0.68rem;
padding: 0.1rem 0.4rem;
border-radius: 999px;
}
.cal-sidebar-hint {
font-size: 0.78rem;
color: #c8860a;
padding: 0.4rem 0.9rem 0;
margin: 0;
}
.cal-sidebar-empty {
font-size: 0.82rem;
color: #6a6050;
padding: 0.75rem 0.9rem;
margin: 0;
}
.cal-sidebar-list {
overflow-y: auto;
flex: 1;
padding: 0.4rem 0.6rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.cal-sidebar-ep {
background: #1a1a1a;
border: 1px solid #2a2a2a;
border-radius: 7px;
padding: 0.5rem 0.65rem;
cursor: pointer;
transition: border-color 0.12s, background 0.12s;
}
.cal-sidebar-ep:hover { border-color: #3a3a3a; }
.cal-sidebar-ep--selected { border-color: #c8860a; background: #1e1a12; }
.cal-sidebar-ep-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.2rem;
}
.cal-sidebar-ep-num {
font-size: 0.72rem;
font-weight: 600;
color: #c8860a;
}
.cal-sidebar-ep-edit {
background: none;
border: none;
color: #5a5040;
cursor: pointer;
font-size: 0.8rem;
padding: 0;
line-height: 1;
}
.cal-sidebar-ep-edit:hover { color: #9a9080; }
.cal-sidebar-ep-title {
font-size: 0.8rem;
color: #c8b898;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cal-sidebar-ep-series {
font-size: 0.7rem;
color: #6a6050;
margin-top: 0.1rem;
}
/* Calendar popover */
.cal-popover-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.cal-popover {
background: #1e1e1e;
border: 1px solid #333;
border-radius: 12px;
width: 340px;
max-width: 90vw;
box-shadow: 0 16px 40px rgba(0,0,0,0.7);
overflow: hidden;
}
.cal-popover-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.85rem 1rem;
border-bottom: 1px solid #2a2a2a;
}
.cal-popover-head h3 {
margin: 0;
font-size: 0.95rem;
color: #e8e2d5;
}
.cal-popover-close {
background: none;
border: none;
color: #6a6050;
font-size: 1.2rem;
cursor: pointer;
line-height: 1;
padding: 0;
}
.cal-popover-close:hover { color: #c0b8a8; }
.cal-popover-body {
padding: 0.9rem 1rem;
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.cal-popover-label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.78rem;
color: #8a8070;
}
.cal-popover-actions {
display: flex;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-top: 1px solid #2a2a2a;
}
/* Email settings panel additions */
.em-settings-section {
margin-bottom: 1.25rem;
}
.em-settings-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
font-weight: 600;
color: #b0a898;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.em-settings-note {
font-size: 0.8rem;
color: #6a6050;
margin: 0.2rem 0 0.5rem;
line-height: 1.4;
}
.em-settings-sig-textarea {
width: 100%;
box-sizing: border-box;
margin-top: 0.4rem;
}
.em-settings-status {
font-size: 0.72rem;
font-weight: 600;
padding: 0.15rem 0.45rem;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.em-settings-status--ok { background: #1e3a2e; color: #4ade80; }
.em-settings-status--warn { background: #3a1a1a; color: #f87171; }
.em-compose-signature-text {
white-space: pre-line;
}
.em-sig-edit-link {
background: none;
border: none;
color: #c8860a;
cursor: pointer;
font-size: 0.75rem;
padding: 0;
text-decoration: underline;
}
.em-sig-edit-link:hover { color: #e0a020; }
@media (max-width: 600px) {
.ct-card { flex-direction: column; gap: 0.6rem; }
.ct-card-actions { flex-direction: row; }
.cal-sidebar { display: none; }
.cal-month-title { min-width: 120px; font-size: 0.95rem; }
}
+4
View File
@@ -3,6 +3,8 @@ import type { ReactElement } from 'react'
import { Link, NavLink, Routes, Route, useLocation, useNavigate, useParams } from 'react-router-dom'
import AdminPage from './AdminPage'
import EmailShell from './EmailPage'
import ContactsShell from './ContactsPage'
import CalendarShell from './CalendarPage'
import QASection from './components/QASection'
import ContactForm from './components/ContactForm'
import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySectionPage, StudyLandingPage, StudySignupPage, StudyAccountPage, StudyCommunityPage, StudyQuizPage } from './colossiansStudy'
@@ -2496,6 +2498,8 @@ export default function App() {
<Route path="/certificate/:token" element={<PublicCertificatePage />} />
<Route path="/admin" element={<AdminShell content={content} onSave={setContent} />} />
<Route path="/email" element={<EmailShell />} />
<Route path="/contacts" element={<ContactsShell />} />
<Route path="/calendar" element={<CalendarShell />} />
<Route path="/preview" element={<PreviewPage />} />
<Route
path="/privacy"
+526
View File
@@ -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>
)
}
+487
View File
@@ -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
View File
@@ -60,6 +60,10 @@ interface ReplyConfig {
note: string
}
interface EmailSettings {
signature: string
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function extractSubject(message: string): string {
@@ -120,6 +124,11 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
const [templateStatus, setTemplateStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [showTemplatesMgr, setShowTemplatesMgr] = useState(false)
const [showHistory, setShowHistory] = useState(false)
const [showSettings, setShowSettings] = useState(false)
const [emailSettings, setEmailSettings] = useState<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 composeRef = useRef<HTMLTextAreaElement>(null)
const listRef = useRef<HTMLElement>(null)
@@ -128,11 +137,12 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
const loadAll = useCallback(async () => {
try {
const [subRes, tplRes, histRes, cfgRes] = await Promise.all([
const [subRes, tplRes, histRes, cfgRes, settingsRes] = await Promise.all([
fetch('/api/admin-contact-submissions'),
fetch('/api/admin-contact-reply-templates'),
fetch('/api/admin-contact-reply-history'),
fetch('/api/admin-reply-config'),
fetch('/api/admin-email-settings'),
])
if (!subRes.ok) throw new Error('submissions failed')
const subData = await subRes.json() as { submissions?: ContactSubmission[] }
@@ -147,6 +157,10 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
setHistory(d.items ?? [])
}
if (cfgRes.ok) setConfig(await cfgRes.json() as ReplyConfig)
if (settingsRes.ok) {
const s = await settingsRes.json() as EmailSettings
setEmailSettings(s)
}
} catch {
setLoadStatus('error')
}
@@ -543,8 +557,8 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
onChange={e => setReplyDraft({ ...replyDraft, message: e.target.value })}
/>
<div className="em-compose-signature">
Grace and peace, · Verse by Verse with Nate
<span className="em-compose-signature-note">auto-appended</span>
<span className="em-compose-signature-text">{emailSettings.signature}</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 className="em-compose-actions">
<button type="button" className="em-btn em-btn--primary" onClick={handleSend} disabled={replySending}>
@@ -788,6 +802,70 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
</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,&#10;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 */}
<div className="em-footer-toolbar">
{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)}>
Sent History
</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>
</div>
</div>