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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user