Files
Siteforge/src/ContactsPage.tsx
T
nmemmert f0635753f1 Add general calendar events (recording, social, task, general); nav links; v1.1.16
- 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>
2026-07-28 11:26:48 -04:00

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>
)
}