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
+526
View File
@@ -0,0 +1,526 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
// ── Types ────────────────────────────────────────────────────────────────────
interface PodcastChecklistTask {
id: string
label: string
required: boolean
}
interface PodcastChecklistEpisode {
id: string
series: string
episodeNumber: number | null
title: string
datePublished: string
expanded: boolean
tasks: Record<string, boolean>
}
interface PodcastChecklistData {
tasks: PodcastChecklistTask[]
episodes: PodcastChecklistEpisode[]
}
// ── Auth Shell ───────────────────────────────────────────────────────────────
export default function CalendarShell() {
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">Release Calendar</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 <CalendarClient />
}
// ── Calendar Client ──────────────────────────────────────────────────────────
function toDateKey(date: Date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
]
function CalendarClient() {
const today = new Date()
const [checklist, setChecklist] = useState<PodcastChecklistData | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [viewYear, setViewYear] = useState(today.getFullYear())
const [viewMonth, setViewMonth] = useState(today.getMonth())
const [selectedEpisodeId, setSelectedEpisodeId] = useState<string | null>(null)
const [editEp, setEditEp] = useState<PodcastChecklistEpisode | null>(null)
const [editForm, setEditForm] = useState({ series: '', episodeNumber: '', title: '', datePublished: '' })
const [newEpOpen, setNewEpOpen] = useState(false)
const [newForm, setNewForm] = useState({ series: '', episodeNumber: '', title: '', datePublished: '' })
const [newBusy, setNewBusy] = useState(false)
const load = useCallback(async () => {
try {
const res = await fetch('/api/admin-podcast-checklist', { credentials: 'include' })
if (res.ok) {
const data = await res.json() as { checklist: PodcastChecklistData }
setChecklist(data.checklist)
}
} catch { /* silent */ }
setLoading(false)
}, [])
useEffect(() => { load() }, [load])
async function save(episodes: PodcastChecklistEpisode[]) {
if (!checklist) return
setSaving(true)
try {
const updated = { ...checklist, episodes }
const res = await fetch('/api/admin-podcast-checklist', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ checklist: updated }),
})
if (res.ok) setChecklist(updated)
} catch { /* silent */ }
setSaving(false)
}
const calDays = useMemo(() => {
const first = new Date(viewYear, viewMonth, 1)
const last = new Date(viewYear, viewMonth + 1, 0)
const days: Array<{ date: Date; inMonth: boolean }> = []
const startDow = (first.getDay() + 6) % 7 // Mon=0 Sun=6
for (let i = startDow - 1; i >= 0; i--) {
days.push({ date: new Date(viewYear, viewMonth, -i), inMonth: false })
}
for (let d = 1; d <= last.getDate(); d++) {
days.push({ date: new Date(viewYear, viewMonth, d), inMonth: true })
}
const rem = (7 - (days.length % 7)) % 7
for (let i = 1; i <= rem; i++) {
days.push({ date: new Date(viewYear, viewMonth + 1, i), inMonth: false })
}
return days
}, [viewYear, viewMonth])
const episodesByDate = useMemo(() => {
const map = new Map<string, PodcastChecklistEpisode[]>()
for (const ep of (checklist?.episodes ?? [])) {
if (!ep.datePublished?.trim()) continue
const key = ep.datePublished.trim().slice(0, 10)
const arr = map.get(key) ?? []
arr.push(ep)
map.set(key, arr)
}
return map
}, [checklist])
const unscheduled = useMemo(
() => (checklist?.episodes ?? []).filter(ep => !ep.datePublished?.trim()),
[checklist]
)
const todayKey = toDateKey(today)
function prevMonth() {
if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1) }
else setViewMonth(m => m - 1)
}
function nextMonth() {
if (viewMonth === 11) { setViewMonth(0); setViewYear(y => y + 1) }
else setViewMonth(m => m + 1)
}
function handleDayClick(date: Date, inMonth: boolean) {
if (!inMonth || !selectedEpisodeId || !checklist) return
const dk = toDateKey(date)
const updated = checklist.episodes.map(ep =>
ep.id === selectedEpisodeId ? { ...ep, datePublished: dk } : ep
)
save(updated)
setSelectedEpisodeId(null)
}
function openEdit(ep: PodcastChecklistEpisode) {
setEditEp(ep)
setEditForm({
series: ep.series,
episodeNumber: ep.episodeNumber != null ? String(ep.episodeNumber) : '',
title: ep.title,
datePublished: ep.datePublished?.trim() ?? '',
})
}
function saveEdit() {
if (!editEp || !checklist) return
const updated = checklist.episodes.map(ep =>
ep.id === editEp.id
? {
...ep,
series: editForm.series.trim(),
episodeNumber: editForm.episodeNumber.trim() ? Number(editForm.episodeNumber) : null,
title: editForm.title.trim(),
datePublished: editForm.datePublished.trim(),
}
: ep
)
save(updated)
setEditEp(null)
}
function unschedule(ep: PodcastChecklistEpisode) {
if (!checklist) return
const updated = checklist.episodes.map(e =>
e.id === ep.id ? { ...e, datePublished: '' } : e
)
save(updated)
setEditEp(null)
}
async function handleNewEpisode(e: React.FormEvent) {
e.preventDefault()
if (!checklist) return
setNewBusy(true)
const id = typeof crypto?.randomUUID === 'function'
? crypto.randomUUID()
: `ep-${Date.now()}-${Math.random().toString(36).slice(2)}`
const ep: PodcastChecklistEpisode = {
id,
series: newForm.series.trim(),
episodeNumber: newForm.episodeNumber.trim() ? Number(newForm.episodeNumber) : null,
title: newForm.title.trim(),
datePublished: newForm.datePublished.trim(),
expanded: false,
tasks: {},
}
const updated = [...checklist.episodes, ep]
await save(updated)
setNewForm({ series: '', episodeNumber: '', title: '', datePublished: '' })
setNewEpOpen(false)
setNewBusy(false)
}
return (
<div className="cal-app">
<header className="cal-header">
<div className="cal-header-left">
<button type="button" className="cal-nav-btn" onClick={prevMonth} aria-label="Previous month"></button>
<h1 className="cal-month-title">
{MONTH_NAMES[viewMonth]} {viewYear}
</h1>
<button type="button" className="cal-nav-btn" onClick={nextMonth} aria-label="Next month"></button>
{saving && <span className="cal-saving">Saving</span>}
</div>
<div className="cal-header-right">
{selectedEpisodeId && (
<span className="cal-scheduling-hint">
Click a date to schedule ·{' '}
<button
type="button"
className="cal-cancel-link"
onClick={() => setSelectedEpisodeId(null)}
>
cancel
</button>
</span>
)}
<button
type="button"
className="em-btn em-btn--secondary em-btn--sm"
onClick={() => setNewEpOpen(o => !o)}
>
{newEpOpen ? 'Cancel' : '+ New Episode'}
</button>
<Link to="/admin" className="em-btn em-btn--ghost em-btn--sm"> Admin</Link>
</div>
</header>
{newEpOpen && (
<div className="cal-new-ep-banner">
<form className="cal-new-ep-form" onSubmit={handleNewEpisode}>
<h3 className="cal-new-ep-title">New Episode</h3>
<div className="cal-new-ep-row">
<label className="cal-new-ep-label">
Series
<input className="ct-input" type="text" placeholder="e.g. Colossians" value={newForm.series} onChange={e => setNewForm(f => ({ ...f, series: e.target.value }))} />
</label>
<label className="cal-new-ep-label">
Episode #
<input className="ct-input" type="number" placeholder="42" value={newForm.episodeNumber} onChange={e => setNewForm(f => ({ ...f, episodeNumber: e.target.value }))} />
</label>
<label className="cal-new-ep-label">
Title
<input className="ct-input" type="text" placeholder="Episode title" value={newForm.title} onChange={e => setNewForm(f => ({ ...f, title: e.target.value }))} />
</label>
<label className="cal-new-ep-label">
Date
<input className="ct-input" type="date" value={newForm.datePublished} onChange={e => setNewForm(f => ({ ...f, datePublished: e.target.value }))} />
</label>
</div>
<div className="cal-new-ep-actions">
<button type="submit" className="em-btn em-btn--primary em-btn--sm" disabled={newBusy}>
{newBusy ? 'Adding…' : 'Add Episode'}
</button>
</div>
</form>
</div>
)}
<div className="cal-body">
<div className="cal-main">
{loading ? (
<p className="cal-loading">Loading calendar</p>
) : (
<>
<div className="cal-dow-row">
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map(d => (
<div key={d} className="cal-dow">{d}</div>
))}
</div>
<div className="cal-grid">
{calDays.map(({ date, inMonth }, i) => {
const dk = toDateKey(date)
const eps = episodesByDate.get(dk) ?? []
const isToday = dk === todayKey
const isSelecting = Boolean(selectedEpisodeId)
return (
<div
key={i}
className={[
'cal-day',
!inMonth ? 'cal-day--out' : '',
isToday ? 'cal-day--today' : '',
isSelecting && inMonth ? 'cal-day--selectable' : '',
].filter(Boolean).join(' ')}
onClick={() => handleDayClick(date, inMonth)}
>
<span className="cal-day-num">{date.getDate()}</span>
<div className="cal-day-events">
{eps.map(ep => (
<button
key={ep.id}
type="button"
className="cal-ep-chip"
title={[ep.series, ep.episodeNumber ? `Ep ${ep.episodeNumber}` : null, ep.title].filter(Boolean).join(' · ')}
onClick={e => { e.stopPropagation(); openEdit(ep) }}
>
{ep.episodeNumber ? `Ep ${ep.episodeNumber}` : ep.series?.slice(0, 6) ?? '—'}
{ep.title && <span className="cal-ep-chip-title"> {ep.title.slice(0, 18)}{ep.title.length > 18 ? '…' : ''}</span>}
</button>
))}
</div>
</div>
)
})}
</div>
</>
)}
</div>
<aside className="cal-sidebar">
<h2 className="cal-sidebar-title">
Unscheduled
{unscheduled.length > 0 && <span className="cal-sidebar-count">{unscheduled.length}</span>}
</h2>
{selectedEpisodeId && (
<p className="cal-sidebar-hint">Click a date on the calendar to schedule.</p>
)}
{unscheduled.length === 0 ? (
<p className="cal-sidebar-empty">All episodes are scheduled.</p>
) : (
<div className="cal-sidebar-list">
{unscheduled.map(ep => (
<div
key={ep.id}
className={`cal-sidebar-ep${selectedEpisodeId === ep.id ? ' cal-sidebar-ep--selected' : ''}`}
onClick={() => setSelectedEpisodeId(id => id === ep.id ? null : ep.id)}
role="button"
tabIndex={0}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') setSelectedEpisodeId(id => id === ep.id ? null : ep.id) }}
>
<div className="cal-sidebar-ep-header">
<span className="cal-sidebar-ep-num">
{ep.episodeNumber ? `Ep ${ep.episodeNumber}` : ep.series}
</span>
<button
type="button"
className="cal-sidebar-ep-edit"
title="Edit"
onClick={e => { e.stopPropagation(); openEdit(ep) }}
>
</button>
</div>
<div className="cal-sidebar-ep-title">{ep.title || <em>Untitled</em>}</div>
{ep.series && ep.episodeNumber && (
<div className="cal-sidebar-ep-series">{ep.series}</div>
)}
</div>
))}
</div>
)}
</aside>
</div>
{/* Edit popover */}
{editEp && (
<div className="cal-popover-overlay" onClick={() => setEditEp(null)}>
<div className="cal-popover" onClick={e => e.stopPropagation()}>
<div className="cal-popover-head">
<h3>Edit Episode</h3>
<button type="button" className="cal-popover-close" onClick={() => setEditEp(null)}>×</button>
</div>
<div className="cal-popover-body">
<label className="cal-popover-label">
Series
<input
className="ct-input"
type="text"
value={editForm.series}
onChange={e => setEditForm(f => ({ ...f, series: e.target.value }))}
/>
</label>
<label className="cal-popover-label">
Episode #
<input
className="ct-input"
type="number"
value={editForm.episodeNumber}
onChange={e => setEditForm(f => ({ ...f, episodeNumber: e.target.value }))}
/>
</label>
<label className="cal-popover-label">
Title
<input
className="ct-input"
type="text"
value={editForm.title}
onChange={e => setEditForm(f => ({ ...f, title: e.target.value }))}
/>
</label>
<label className="cal-popover-label">
Date Published
<input
className="ct-input"
type="date"
value={editForm.datePublished}
onChange={e => setEditForm(f => ({ ...f, datePublished: e.target.value }))}
/>
</label>
</div>
<div className="cal-popover-actions">
<button type="button" className="em-btn em-btn--primary em-btn--sm" onClick={saveEdit}>
Save
</button>
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => unschedule(editEp)}>
Unschedule
</button>
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => setEditEp(null)}>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
)
}