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:
+2
-132
@@ -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 =>
|
||||
|
||||
Reference in New Issue
Block a user