f0635753f1
- Four event types on the calendar: General, Recording, Social, Task — each color-coded - + Event button opens form with type picker, title, date, notes, reminder - Task events have a Mark Complete toggle; completed events show strikethrough - Edit/delete popover for each event - Email reminders work for calendar events same as episodes - Added Email/Calendar nav links to /contacts header - Added Email link to /calendar header (already had Admin) - /email already links to Contacts and Calendar Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
489 lines
17 KiB
TypeScript
489 lines
17 KiB
TypeScript
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="/calendar" className="em-btn em-btn--ghost em-btn--sm">Calendar</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>
|
|
)
|
|
}
|