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