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 =>