111b559865
- Webhook CRUD (GET/POST/DELETE /api/admin-webhooks, POST .../test) with ⚡ Webhooks drawer in EmailPage; fires contact.new and reply.sent events - Contact→Calendar: history panel shows checklist episodes within ±30 days - Checklist automation: saveEdit auto-sets productionStatus (idea→scheduled for future dates, idea→published for past); ⚙ Tasks button generates Record (−14d) and Edit (−7d) calendar task events for scheduled episodes - Audit log captures webhook-added, webhook-removed, webhook-test events - Webhook infrastructure: state, disk persistence, fire-and-forget with AbortSignal.timeout(8000), loaded on startup Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1081 lines
48 KiB
TypeScript
1081 lines
48 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||
import { Link, useNavigate } from 'react-router-dom'
|
||
|
||
// ── Types ────────────────────────────────────────────────────────────────────
|
||
|
||
interface PodcastChecklistTask {
|
||
id: string
|
||
label: string
|
||
required: boolean
|
||
}
|
||
|
||
type ProductionStatus = 'idea' | 'recorded' | 'edited' | 'scheduled' | 'published'
|
||
|
||
const PROD_STATUS_OPTIONS: { value: ProductionStatus; label: string; color: string }[] = [
|
||
{ value: 'idea', label: 'Idea', color: '#4a4040' },
|
||
{ value: 'recorded', label: 'Recorded', color: '#2a3a5a' },
|
||
{ value: 'edited', label: 'Edited', color: '#3a2a5a' },
|
||
{ value: 'scheduled', label: 'Scheduled', color: '#1a3a4a' },
|
||
{ value: 'published', label: 'Published', color: '#1a3a1a' },
|
||
]
|
||
|
||
interface PodcastChecklistEpisode {
|
||
id: string
|
||
series: string
|
||
episodeNumber: number | null
|
||
title: string
|
||
datePublished: string
|
||
startTime?: string
|
||
expanded: boolean
|
||
tasks: Record<string, boolean>
|
||
reminderDays?: number
|
||
reminderSentAt?: string
|
||
productionStatus?: ProductionStatus
|
||
}
|
||
|
||
type RecurrenceFreq = 'none' | 'weekly' | 'biweekly' | 'monthly'
|
||
|
||
interface Recurrence {
|
||
freq: RecurrenceFreq
|
||
until: string | null
|
||
}
|
||
|
||
type CalendarEventType = 'general' | 'recording' | 'social' | 'task'
|
||
|
||
interface CalendarEvent {
|
||
id: string
|
||
type: CalendarEventType
|
||
title: string
|
||
date: string
|
||
startTime?: string
|
||
notes: string
|
||
completed: boolean
|
||
reminderDays: number
|
||
reminderSentAt?: string
|
||
recurrence?: Recurrence
|
||
createdAt: string
|
||
recurrenceOf?: string // runtime only: id of base event if this is an expanded instance
|
||
}
|
||
|
||
interface PodcastChecklistData {
|
||
tasks: PodcastChecklistTask[]
|
||
episodes: PodcastChecklistEpisode[]
|
||
}
|
||
|
||
type ViewMode = 'month' | 'week' | 'day'
|
||
|
||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||
|
||
const REMINDER_OPTIONS = [
|
||
{ value: '0', label: 'No reminder' },
|
||
{ value: '1', label: '1 day before' },
|
||
{ value: '2', label: '2 days before' },
|
||
{ value: '3', label: '3 days before' },
|
||
{ value: '7', label: '1 week before' },
|
||
{ value: '14', label: '2 weeks before' },
|
||
]
|
||
|
||
const EVENT_TYPE_OPTIONS: { value: CalendarEventType; label: string; icon: string }[] = [
|
||
{ value: 'general', label: 'General', icon: '📌' },
|
||
{ value: 'recording', label: 'Recording', icon: '🎙️' },
|
||
{ value: 'social', label: 'Social', icon: '📱' },
|
||
{ value: 'task', label: 'Task', icon: '✅' },
|
||
]
|
||
|
||
const RECURRENCE_OPTIONS: { value: RecurrenceFreq; label: string }[] = [
|
||
{ value: 'none', label: 'Does not repeat' },
|
||
{ value: 'weekly', label: 'Weekly' },
|
||
{ value: 'biweekly', label: 'Every 2 weeks' },
|
||
{ value: 'monthly', label: 'Monthly' },
|
||
]
|
||
|
||
const MONTH_NAMES = [
|
||
'January', 'February', 'March', 'April', 'May', 'June',
|
||
'July', 'August', 'September', 'October', 'November', 'December',
|
||
]
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
function toDateKey(date: Date) {
|
||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||
}
|
||
|
||
function getMondayOf(date: Date): Date {
|
||
const d = new Date(date)
|
||
const dow = d.getDay()
|
||
const diff = dow === 0 ? -6 : 1 - dow
|
||
d.setDate(d.getDate() + diff)
|
||
return d
|
||
}
|
||
|
||
function addDays(date: Date, n: number): Date {
|
||
const d = new Date(date)
|
||
d.setDate(d.getDate() + n)
|
||
return d
|
||
}
|
||
|
||
function addMonths(date: Date, n: number): Date {
|
||
const d = new Date(date)
|
||
d.setMonth(d.getMonth() + n)
|
||
return d
|
||
}
|
||
|
||
function formatTimeDisplay(t: string | undefined): string {
|
||
if (!t) return ''
|
||
const [h, m] = t.split(':').map(Number)
|
||
if (isNaN(h)) return ''
|
||
const ampm = h >= 12 ? 'PM' : 'AM'
|
||
const h12 = h % 12 || 12
|
||
return `${h12}:${String(m).padStart(2, '0')} ${ampm}`
|
||
}
|
||
|
||
function expandRecurring(events: CalendarEvent[], firstKey: string, lastKey: string): CalendarEvent[] {
|
||
const result: CalendarEvent[] = []
|
||
for (const ev of events) {
|
||
// always include base event if it falls in range
|
||
if (ev.date >= firstKey && ev.date <= lastKey) result.push(ev)
|
||
|
||
if (!ev.recurrence || ev.recurrence.freq === 'none') continue
|
||
|
||
const base = new Date(ev.date + 'T12:00:00')
|
||
const untilDate = ev.recurrence.until
|
||
? new Date(ev.recurrence.until + 'T23:59:59')
|
||
: new Date(base.getFullYear() + 2, base.getMonth(), base.getDate()) // 2-year horizon
|
||
|
||
let d = new Date(base)
|
||
for (let i = 0; i < 1000; i++) {
|
||
if (ev.recurrence.freq === 'weekly') d = addDays(d, 7)
|
||
else if (ev.recurrence.freq === 'biweekly') d = addDays(d, 14)
|
||
else d = new Date(d.getFullYear(), d.getMonth() + 1, d.getDate())
|
||
|
||
if (d > untilDate) break
|
||
const dk = toDateKey(d)
|
||
if (dk > lastKey) break // past visible range
|
||
if (dk === ev.date) continue // skip base date duplicate
|
||
if (dk < firstKey) continue // before visible range, keep iterating
|
||
|
||
// Don't add if it's the same as a date that's already included as a non-recurring event
|
||
result.push({ ...ev, id: `${ev.id}:${dk}`, date: dk, recurrenceOf: ev.id })
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
// ── 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)
|
||
const [pendingToken, setPendingToken] = useState('')
|
||
|
||
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; totpRequired?: boolean; pendingToken?: string; message?: string }
|
||
if (!res.ok) { setAuthError(data.message ?? 'Invalid password.'); setAuthBusy(false); return }
|
||
if (data.totpRequired && data.pendingToken) { setPendingToken(data.pendingToken); 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-verify', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ pendingToken, code: 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 CalendarClient() {
|
||
const navigate = useNavigate()
|
||
const today = useMemo(() => new Date(), [])
|
||
const [checklist, setChecklist] = useState<PodcastChecklistData | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [saving, setSaving] = useState(false)
|
||
const [events, setEvents] = useState<CalendarEvent[]>([])
|
||
|
||
// View
|
||
const [viewMode, setViewMode] = useState<ViewMode>('month')
|
||
const [viewDate, setViewDate] = useState<Date>(() => new Date())
|
||
|
||
// Episode scheduling (click-to-place)
|
||
const [selectedEpisodeId, setSelectedEpisodeId] = useState<string | null>(null)
|
||
|
||
// Episode edit popover
|
||
const [editEp, setEditEp] = useState<PodcastChecklistEpisode | null>(null)
|
||
const [editForm, setEditForm] = useState({ series: '', episodeNumber: '', title: '', datePublished: '', startTime: '', reminderDays: '0', productionStatus: '' as ProductionStatus | '' })
|
||
|
||
// New episode form
|
||
const [newEpOpen, setNewEpOpen] = useState(false)
|
||
const [newForm, setNewForm] = useState({ series: '', episodeNumber: '', title: '', datePublished: '', startTime: '', reminderDays: '0' })
|
||
const [newBusy, setNewBusy] = useState(false)
|
||
|
||
// New event form
|
||
const [newEvOpen, setNewEvOpen] = useState(false)
|
||
const [newEvForm, setNewEvForm] = useState({ type: 'general' as CalendarEventType, title: '', date: '', startTime: '', notes: '', reminderDays: '0', recurrenceFreq: 'none' as RecurrenceFreq, recurrenceUntil: '' })
|
||
const [newEvBusy, setNewEvBusy] = useState(false)
|
||
|
||
// Event edit popover
|
||
const [editEv, setEditEv] = useState<CalendarEvent | null>(null)
|
||
const [editEvForm, setEditEvForm] = useState({ type: 'general' as CalendarEventType, title: '', date: '', startTime: '', notes: '', reminderDays: '0', recurrenceFreq: 'none' as RecurrenceFreq, recurrenceUntil: '' })
|
||
const [evSaving, setEvSaving] = useState(false)
|
||
|
||
// Drag state
|
||
const [dragOverKey, setDragOverKey] = useState<string | null>(null)
|
||
const [calFlash, setCalFlash] = useState('')
|
||
const dragDataRef = useRef<{ type: 'episode' | 'event'; id: string } | null>(null)
|
||
|
||
// ── Load ──
|
||
|
||
const load = useCallback(async () => {
|
||
try {
|
||
const [clRes, evRes] = await Promise.all([
|
||
fetch('/api/admin-podcast-checklist', { credentials: 'include' }),
|
||
fetch('/api/admin-calendar-events', { credentials: 'include' }),
|
||
])
|
||
if (clRes.ok) {
|
||
const data = await clRes.json() as { checklist: PodcastChecklistData }
|
||
setChecklist(data.checklist)
|
||
}
|
||
if (evRes.ok) {
|
||
const data = await evRes.json() as { events: CalendarEvent[] }
|
||
setEvents(Array.isArray(data.events) ? data.events : [])
|
||
}
|
||
} catch { /* silent */ }
|
||
setLoading(false)
|
||
}, [])
|
||
|
||
useEffect(() => { load() }, [load])
|
||
|
||
// ── Navigation ──
|
||
|
||
const viewYear = viewDate.getFullYear()
|
||
const viewMonth = viewDate.getMonth()
|
||
|
||
function prevPeriod() {
|
||
if (viewMode === 'month') setViewDate(d => addMonths(d, -1))
|
||
else if (viewMode === 'week') setViewDate(d => addDays(d, -7))
|
||
else setViewDate(d => addDays(d, -1))
|
||
}
|
||
|
||
function nextPeriod() {
|
||
if (viewMode === 'month') setViewDate(d => addMonths(d, 1))
|
||
else if (viewMode === 'week') setViewDate(d => addDays(d, 7))
|
||
else setViewDate(d => addDays(d, 1))
|
||
}
|
||
|
||
function gotoToday() { setViewDate(new Date(today)) }
|
||
|
||
// ── Computed days ──
|
||
|
||
const calDays = useMemo(() => {
|
||
if (viewMode === 'week') {
|
||
const monday = getMondayOf(viewDate)
|
||
return Array.from({ length: 7 }, (_, i) => ({ date: addDays(monday, i), inMonth: true }))
|
||
}
|
||
if (viewMode === 'day') {
|
||
return [{ date: new Date(viewDate), inMonth: true }]
|
||
}
|
||
// month
|
||
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
|
||
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
|
||
}, [viewMode, viewDate, viewYear, viewMonth])
|
||
|
||
const firstKey = calDays.length > 0 ? toDateKey(calDays[0].date) : ''
|
||
const lastKey = calDays.length > 0 ? toDateKey(calDays[calDays.length - 1].date) : ''
|
||
|
||
// ── Expanded events (with recurring instances) ──
|
||
|
||
const expandedEventsByDate = useMemo(() => {
|
||
const all = expandRecurring(events, firstKey, lastKey)
|
||
const map = new Map<string, CalendarEvent[]>()
|
||
for (const ev of all) {
|
||
const arr = map.get(ev.date) ?? []
|
||
arr.push(ev)
|
||
map.set(ev.date, arr)
|
||
}
|
||
return map
|
||
}, [events, firstKey, lastKey])
|
||
|
||
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)
|
||
|
||
// ── View header label ──
|
||
|
||
const viewLabel = useMemo(() => {
|
||
if (viewMode === 'month') return `${MONTH_NAMES[viewMonth]} ${viewYear}`
|
||
if (viewMode === 'week') {
|
||
const monday = getMondayOf(viewDate)
|
||
const sunday = addDays(monday, 6)
|
||
if (monday.getMonth() === sunday.getMonth()) {
|
||
return `${MONTH_NAMES[monday.getMonth()]} ${monday.getDate()}–${sunday.getDate()}, ${monday.getFullYear()}`
|
||
}
|
||
return `${MONTH_NAMES[monday.getMonth()]} ${monday.getDate()} – ${MONTH_NAMES[sunday.getMonth()]} ${sunday.getDate()}, ${monday.getFullYear()}`
|
||
}
|
||
return `${MONTH_NAMES[viewDate.getMonth()]} ${viewDate.getDate()}, ${viewDate.getFullYear()}`
|
||
}, [viewMode, viewDate, viewYear, viewMonth])
|
||
|
||
// ── Checklist save ──
|
||
|
||
async function saveChecklist(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)
|
||
}
|
||
|
||
// ── Episode actions ──
|
||
|
||
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
|
||
)
|
||
saveChecklist(updated)
|
||
setSelectedEpisodeId(null)
|
||
}
|
||
|
||
function announceEpisode(ep: PodcastChecklistEpisode) {
|
||
const parts = [ep.series, ep.episodeNumber ? `Episode ${ep.episodeNumber}` : null, ep.title].filter(Boolean)
|
||
const subject = `New Episode: ${parts.join(' – ')}`
|
||
const dateStr = ep.datePublished
|
||
? new Date(ep.datePublished + 'T12:00:00').toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })
|
||
: ''
|
||
const body = [parts.join(' – '), dateStr ? `Published ${dateStr}` : ''].filter(Boolean).join('\n\n')
|
||
setEditEp(null)
|
||
navigate('/email', { state: { compose: true, subject, body } })
|
||
}
|
||
|
||
function openEdit(ep: PodcastChecklistEpisode) {
|
||
setEditEp(ep)
|
||
setEditForm({
|
||
series: ep.series,
|
||
episodeNumber: ep.episodeNumber != null ? String(ep.episodeNumber) : '',
|
||
title: ep.title,
|
||
datePublished: ep.datePublished?.trim() ?? '',
|
||
startTime: ep.startTime ?? '',
|
||
reminderDays: String(ep.reminderDays ?? 0),
|
||
productionStatus: ep.productionStatus ?? '',
|
||
})
|
||
}
|
||
|
||
function saveEdit() {
|
||
if (!editEp || !checklist) return
|
||
const newDate = editForm.datePublished.trim()
|
||
const newReminder = Number(editForm.reminderDays)
|
||
const dateChanged = newDate !== (editEp.datePublished?.trim() ?? '')
|
||
const reminderChanged = newReminder !== (editEp.reminderDays ?? 0)
|
||
|
||
let nextStatus = (editForm.productionStatus as ProductionStatus) || undefined
|
||
if (dateChanged && newDate) {
|
||
const today = new Date().toISOString().slice(0, 10)
|
||
const isPast = newDate <= today
|
||
if (!nextStatus || nextStatus === 'idea') {
|
||
nextStatus = isPast ? 'published' : 'scheduled'
|
||
}
|
||
}
|
||
|
||
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: newDate,
|
||
startTime: editForm.startTime || undefined,
|
||
reminderDays: newReminder,
|
||
reminderSentAt: dateChanged || reminderChanged ? undefined : ep.reminderSentAt,
|
||
productionStatus: nextStatus,
|
||
}
|
||
: ep
|
||
)
|
||
saveChecklist(updated)
|
||
setEditEp(null)
|
||
}
|
||
|
||
async function generateTasks(ep: PodcastChecklistEpisode) {
|
||
if (!ep.datePublished) return
|
||
const epDate = new Date(ep.datePublished + 'T12:00:00')
|
||
const label = ep.title || [ep.series, ep.episodeNumber != null ? `Ep. ${ep.episodeNumber}` : null].filter(Boolean).join(' ')
|
||
const tasks = [
|
||
{ offsetDays: -14, title: `Record: ${label}` },
|
||
{ offsetDays: -7, title: `Edit: ${label}` },
|
||
]
|
||
for (const t of tasks) {
|
||
const d = new Date(epDate)
|
||
d.setDate(d.getDate() + t.offsetDays)
|
||
const dateKey = d.toISOString().slice(0, 10)
|
||
try {
|
||
const res = await fetch('/api/admin-calendar-events', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'include',
|
||
body: JSON.stringify({ type: 'task', title: t.title, date: dateKey, notes: '', reminderDays: 0 }),
|
||
})
|
||
if (res.ok) {
|
||
const data = await res.json() as { event: CalendarEvent }
|
||
setEvents(prev => [data.event, ...prev])
|
||
}
|
||
} catch { /* silent */ }
|
||
}
|
||
setCalFlash(`Tasks generated for "${label}"`)
|
||
setTimeout(() => setCalFlash(''), 3000)
|
||
setEditEp(null)
|
||
}
|
||
|
||
function unschedule(ep: PodcastChecklistEpisode) {
|
||
if (!checklist) return
|
||
const updated = checklist.episodes.map(e => e.id === ep.id ? { ...e, datePublished: '' } : e)
|
||
saveChecklist(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(),
|
||
startTime: newForm.startTime || undefined,
|
||
reminderDays: Number(newForm.reminderDays),
|
||
expanded: false,
|
||
tasks: {},
|
||
}
|
||
await saveChecklist([...checklist.episodes, ep])
|
||
setNewForm({ series: '', episodeNumber: '', title: '', datePublished: '', startTime: '', reminderDays: '0' })
|
||
setNewEpOpen(false)
|
||
setNewBusy(false)
|
||
}
|
||
|
||
// ── Event actions ──
|
||
|
||
async function handleNewEvent(e: React.FormEvent) {
|
||
e.preventDefault()
|
||
if (!newEvForm.title.trim() || !newEvForm.date) return
|
||
setNewEvBusy(true)
|
||
try {
|
||
const recurrence = newEvForm.recurrenceFreq !== 'none'
|
||
? { freq: newEvForm.recurrenceFreq, until: newEvForm.recurrenceUntil || null }
|
||
: null
|
||
const res = await fetch('/api/admin-calendar-events', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'include',
|
||
body: JSON.stringify({
|
||
type: newEvForm.type,
|
||
title: newEvForm.title.trim(),
|
||
date: newEvForm.date,
|
||
startTime: newEvForm.startTime || undefined,
|
||
notes: newEvForm.notes.trim(),
|
||
reminderDays: Number(newEvForm.reminderDays),
|
||
recurrence,
|
||
}),
|
||
})
|
||
if (res.ok) {
|
||
const data = await res.json() as { event: CalendarEvent }
|
||
setEvents(prev => [data.event, ...prev])
|
||
setNewEvForm({ type: 'general', title: '', date: '', startTime: '', notes: '', reminderDays: '0', recurrenceFreq: 'none', recurrenceUntil: '' })
|
||
setNewEvOpen(false)
|
||
}
|
||
} catch { /* silent */ }
|
||
setNewEvBusy(false)
|
||
}
|
||
|
||
function openEditEvent(ev: CalendarEvent) {
|
||
// If clicking a recurring instance, edit the base event
|
||
const baseId = ev.recurrenceOf ?? ev.id
|
||
const base = events.find(e => e.id === baseId) ?? ev
|
||
setEditEv(base)
|
||
setEditEvForm({
|
||
type: base.type,
|
||
title: base.title,
|
||
date: base.date,
|
||
startTime: base.startTime ?? '',
|
||
notes: base.notes,
|
||
reminderDays: String(base.reminderDays),
|
||
recurrenceFreq: base.recurrence?.freq ?? 'none',
|
||
recurrenceUntil: base.recurrence?.until ?? '',
|
||
})
|
||
}
|
||
|
||
async function saveEditEvent() {
|
||
if (!editEv) return
|
||
setEvSaving(true)
|
||
try {
|
||
const recurrence = editEvForm.recurrenceFreq !== 'none'
|
||
? { freq: editEvForm.recurrenceFreq, until: editEvForm.recurrenceUntil || null }
|
||
: null
|
||
const res = await fetch(`/api/admin-calendar-events/${editEv.id}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'include',
|
||
body: JSON.stringify({
|
||
type: editEvForm.type,
|
||
title: editEvForm.title.trim(),
|
||
date: editEvForm.date,
|
||
startTime: editEvForm.startTime || null,
|
||
notes: editEvForm.notes.trim(),
|
||
reminderDays: Number(editEvForm.reminderDays),
|
||
recurrence,
|
||
}),
|
||
})
|
||
if (res.ok) {
|
||
setEvents(prev => prev.map(ev => {
|
||
if (ev.id !== editEv.id) return ev
|
||
const dateChanged = editEvForm.date !== ev.date
|
||
const reminderChanged = Number(editEvForm.reminderDays) !== ev.reminderDays
|
||
return {
|
||
...ev,
|
||
type: editEvForm.type,
|
||
title: editEvForm.title.trim(),
|
||
date: editEvForm.date,
|
||
startTime: editEvForm.startTime || undefined,
|
||
notes: editEvForm.notes.trim(),
|
||
reminderDays: Number(editEvForm.reminderDays),
|
||
recurrence: recurrence ?? undefined,
|
||
reminderSentAt: (dateChanged || reminderChanged) ? undefined : ev.reminderSentAt,
|
||
}
|
||
}))
|
||
setEditEv(null)
|
||
}
|
||
} catch { /* silent */ }
|
||
setEvSaving(false)
|
||
}
|
||
|
||
async function deleteEvent(ev: CalendarEvent) {
|
||
const baseId = ev.recurrenceOf ?? ev.id
|
||
try {
|
||
const res = await fetch(`/api/admin-calendar-events/${baseId}`, { method: 'DELETE', credentials: 'include' })
|
||
if (res.ok) { setEvents(prev => prev.filter(e => e.id !== baseId)); setEditEv(null) }
|
||
} catch { /* silent */ }
|
||
}
|
||
|
||
async function toggleComplete(ev: CalendarEvent) {
|
||
const next = !ev.completed
|
||
try {
|
||
const res = await fetch(`/api/admin-calendar-events/${ev.id}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'include',
|
||
body: JSON.stringify({ completed: next }),
|
||
})
|
||
if (res.ok) setEvents(prev => prev.map(e => e.id === ev.id ? { ...e, completed: next } : e))
|
||
} catch { /* silent */ }
|
||
}
|
||
|
||
// ── Drag-to-reschedule ──
|
||
|
||
function onChipDragStart(e: React.DragEvent, type: 'episode' | 'event', id: string) {
|
||
dragDataRef.current = { type, id }
|
||
e.dataTransfer.effectAllowed = 'move'
|
||
e.dataTransfer.setData('text/plain', JSON.stringify({ type, id }))
|
||
}
|
||
|
||
async function onDayDrop(e: React.DragEvent, date: Date, inMonth: boolean) {
|
||
e.preventDefault()
|
||
setDragOverKey(null)
|
||
if (!inMonth && viewMode === 'month') return
|
||
const dk = toDateKey(date)
|
||
let raw: { type: string; id: string } | null = null
|
||
try { raw = JSON.parse(e.dataTransfer.getData('text/plain')) } catch {}
|
||
const dragged = raw ?? dragDataRef.current
|
||
dragDataRef.current = null
|
||
if (!dragged) return
|
||
|
||
if (dragged.type === 'episode' && checklist) {
|
||
const updated = checklist.episodes.map(ep =>
|
||
ep.id === dragged.id ? { ...ep, datePublished: dk } : ep
|
||
)
|
||
saveChecklist(updated)
|
||
} else if (dragged.type === 'event') {
|
||
// Strip recurrenceOf prefix if this is an instance id (e.g. "baseId:2026-08-01")
|
||
const baseId = dragged.id.includes(':') ? dragged.id.split(':')[0] : dragged.id
|
||
try {
|
||
const res = await fetch(`/api/admin-calendar-events/${baseId}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'include',
|
||
body: JSON.stringify({ date: dk }),
|
||
})
|
||
if (res.ok) setEvents(prev => prev.map(ev => ev.id === baseId ? { ...ev, date: dk } : ev))
|
||
} catch { /* silent */ }
|
||
}
|
||
}
|
||
|
||
// ── Render chip ──
|
||
|
||
function renderEpChip(ep: PodcastChecklistEpisode, _dk: string) {
|
||
const statusOpt = PROD_STATUS_OPTIONS.find(o => o.value === ep.productionStatus)
|
||
return (
|
||
<button
|
||
key={ep.id}
|
||
type="button"
|
||
className="cal-ep-chip"
|
||
draggable
|
||
onDragStart={e => onChipDragStart(e, 'episode', ep.id)}
|
||
title={[ep.series, ep.episodeNumber ? `Ep ${ep.episodeNumber}` : null, ep.title, ep.startTime ? formatTimeDisplay(ep.startTime) : null, statusOpt ? statusOpt.label : null].filter(Boolean).join(' · ')}
|
||
style={statusOpt ? { borderLeft: `3px solid ${statusOpt.color}` } : undefined}
|
||
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>}
|
||
{ep.startTime && <span className="cal-chip-time">{formatTimeDisplay(ep.startTime)}</span>}
|
||
{statusOpt && <span className="cal-prod-status-dot" style={{ background: statusOpt.color }} title={statusOpt.label} />}
|
||
{(ep.reminderDays ?? 0) > 0 && <span className="cal-ep-bell" title={`Reminder: ${ep.reminderDays}d before${ep.reminderSentAt ? ' (sent)' : ''}`}>{ep.reminderSentAt ? '✓' : '🔔'}</span>}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function renderEvChip(ev: CalendarEvent) {
|
||
const opt = EVENT_TYPE_OPTIONS.find(o => o.value === ev.type)
|
||
const isInstance = Boolean(ev.recurrenceOf)
|
||
return (
|
||
<button
|
||
key={ev.id}
|
||
type="button"
|
||
className={`cal-ev-chip cal-ev-chip--${ev.type}${ev.completed ? ' cal-ev-chip--done' : ''}${isInstance ? ' cal-ev-chip--recurring' : ''}`}
|
||
draggable
|
||
onDragStart={e => onChipDragStart(e, 'event', ev.id)}
|
||
title={[ev.title, ev.startTime ? formatTimeDisplay(ev.startTime) : null, ev.notes || null, isInstance ? '(recurring)' : null].filter(Boolean).join(' · ')}
|
||
onClick={e => { e.stopPropagation(); openEditEvent(ev) }}
|
||
>
|
||
<span className="cal-ev-icon">{opt?.icon}</span>
|
||
<span className="cal-ev-title">{ev.title.slice(0, 20)}{ev.title.length > 20 ? '…' : ''}</span>
|
||
{ev.startTime && <span className="cal-chip-time">{formatTimeDisplay(ev.startTime)}</span>}
|
||
{isInstance && <span className="cal-recur-icon" title="Recurring">🔁</span>}
|
||
{(ev.reminderDays ?? 0) > 0 && <span className="cal-ep-bell">{ev.reminderSentAt ? '✓' : '🔔'}</span>}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
// ── Day cell renderer ──
|
||
|
||
function renderDayCell({ date, inMonth }: { date: Date; inMonth: boolean }, i: number) {
|
||
const dk = toDateKey(date)
|
||
const eps = episodesByDate.get(dk) ?? []
|
||
const evs = expandedEventsByDate.get(dk) ?? []
|
||
const isToday = dk === todayKey
|
||
const isSelecting = Boolean(selectedEpisodeId)
|
||
const isDragOver = dragOverKey === dk
|
||
|
||
return (
|
||
<div
|
||
key={`${i}-${dk}`}
|
||
className={[
|
||
'cal-day',
|
||
!inMonth && viewMode === 'month' ? 'cal-day--out' : '',
|
||
isToday ? 'cal-day--today' : '',
|
||
isSelecting && inMonth ? 'cal-day--selectable' : '',
|
||
isDragOver ? 'cal-day--drag-over' : '',
|
||
viewMode === 'week' ? 'cal-day--week' : '',
|
||
viewMode === 'day' ? 'cal-day--day-view' : '',
|
||
].filter(Boolean).join(' ')}
|
||
onClick={() => handleDayClick(date, inMonth)}
|
||
onDragOver={e => { if (inMonth || viewMode !== 'month') { e.preventDefault(); setDragOverKey(dk) } }}
|
||
onDragLeave={() => setDragOverKey(null)}
|
||
onDrop={e => onDayDrop(e, date, inMonth)}
|
||
>
|
||
<div className="cal-day-head">
|
||
<span
|
||
className={`cal-day-num${isToday ? ' cal-day-num--today' : ''}`}
|
||
onClick={e => { e.stopPropagation(); setViewDate(date); setViewMode('day') }}
|
||
title="Go to day view"
|
||
>
|
||
{viewMode !== 'month' ? `${MONTH_NAMES[date.getMonth()].slice(0,3)} ${date.getDate()}` : date.getDate()}
|
||
</span>
|
||
</div>
|
||
<div className="cal-day-events">
|
||
{eps.map(ep => renderEpChip(ep, dk))}
|
||
{evs.map(ev => renderEvChip(ev))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Main render ──
|
||
|
||
return (
|
||
<div className="cal-app">
|
||
{/* Header */}
|
||
<header className="cal-header">
|
||
<div className="cal-header-left">
|
||
<button type="button" className="cal-nav-btn" onClick={prevPeriod} aria-label="Previous">‹</button>
|
||
<h1 className="cal-month-title">{viewLabel}</h1>
|
||
<button type="button" className="cal-nav-btn" onClick={nextPeriod} aria-label="Next">›</button>
|
||
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={gotoToday}>Today</button>
|
||
{saving && <span className="cal-saving">Saving…</span>}
|
||
{calFlash && <span className="cal-flash-msg">{calFlash}</span>}
|
||
</div>
|
||
<div className="cal-header-center">
|
||
<div className="cal-view-toggle">
|
||
{(['month', 'week', 'day'] as ViewMode[]).map(mode => (
|
||
<button
|
||
key={mode}
|
||
type="button"
|
||
className={`cal-view-btn${viewMode === mode ? ' cal-view-btn--active' : ''}`}
|
||
onClick={() => setViewMode(mode)}
|
||
>
|
||
{mode.charAt(0).toUpperCase() + mode.slice(1)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</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); setNewEvOpen(false) }}>
|
||
{newEpOpen ? 'Cancel' : '+ Episode'}
|
||
</button>
|
||
<button type="button" className="em-btn em-btn--secondary em-btn--sm" onClick={() => { setNewEvOpen(o => !o); setNewEpOpen(false) }}>
|
||
{newEvOpen ? 'Cancel' : '+ Event'}
|
||
</button>
|
||
<a href="/api/admin-calendar.ics" download="calendar.ics" className="em-btn em-btn--ghost em-btn--sm" title="Export to Apple Calendar / Google Calendar">
|
||
📅 iCal
|
||
</a>
|
||
<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>
|
||
|
||
{/* New episode banner */}
|
||
{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="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" style={{ flex: 2 }}>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>
|
||
<label className="cal-new-ep-label">Time<input className="ct-input" type="time" value={newForm.startTime} onChange={e => setNewForm(f => ({ ...f, startTime: e.target.value }))} /></label>
|
||
<label className="cal-new-ep-label">Reminder<select className="ct-input" value={newForm.reminderDays} onChange={e => setNewForm(f => ({ ...f, reminderDays: e.target.value }))}>{REMINDER_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}</select></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>
|
||
)}
|
||
|
||
{/* New event banner */}
|
||
{newEvOpen && (
|
||
<div className="cal-new-ep-banner">
|
||
<form className="cal-new-ep-form" onSubmit={handleNewEvent}>
|
||
<h3 className="cal-new-ep-title">New Event</h3>
|
||
<div className="cal-new-ep-row">
|
||
<label className="cal-new-ep-label">Type<select className="ct-input" value={newEvForm.type} onChange={e => setNewEvForm(f => ({ ...f, type: e.target.value as CalendarEventType }))}>{EVENT_TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.icon} {o.label}</option>)}</select></label>
|
||
<label className="cal-new-ep-label" style={{ flex: 2 }}>Title<input className="ct-input" type="text" required placeholder="Event title" value={newEvForm.title} onChange={e => setNewEvForm(f => ({ ...f, title: e.target.value }))} /></label>
|
||
<label className="cal-new-ep-label">Date<input className="ct-input" type="date" required value={newEvForm.date} onChange={e => setNewEvForm(f => ({ ...f, date: e.target.value }))} /></label>
|
||
<label className="cal-new-ep-label">Time<input className="ct-input" type="time" value={newEvForm.startTime} onChange={e => setNewEvForm(f => ({ ...f, startTime: e.target.value }))} /></label>
|
||
<label className="cal-new-ep-label">Reminder<select className="ct-input" value={newEvForm.reminderDays} onChange={e => setNewEvForm(f => ({ ...f, reminderDays: e.target.value }))}>{REMINDER_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}</select></label>
|
||
</div>
|
||
<div className="cal-new-ep-row">
|
||
<label className="cal-new-ep-label">Repeats<select className="ct-input" value={newEvForm.recurrenceFreq} onChange={e => setNewEvForm(f => ({ ...f, recurrenceFreq: e.target.value as RecurrenceFreq }))}>{RECURRENCE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}</select></label>
|
||
{newEvForm.recurrenceFreq !== 'none' && (
|
||
<label className="cal-new-ep-label">Until (optional)<input className="ct-input" type="date" value={newEvForm.recurrenceUntil} onChange={e => setNewEvForm(f => ({ ...f, recurrenceUntil: e.target.value }))} /></label>
|
||
)}
|
||
<label className="cal-new-ep-label" style={{ flex: 1 }}>Notes<input className="ct-input" type="text" placeholder="Optional notes" value={newEvForm.notes} onChange={e => setNewEvForm(f => ({ ...f, notes: e.target.value }))} /></label>
|
||
</div>
|
||
<div className="cal-new-ep-actions">
|
||
<button type="submit" className="em-btn em-btn--primary em-btn--sm" disabled={newEvBusy}>{newEvBusy ? 'Adding…' : 'Add Event'}</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
)}
|
||
|
||
{/* Publishing today banner */}
|
||
{!loading && (() => {
|
||
const todayEps = (checklist?.episodes ?? []).filter(ep => ep.datePublished === todayKey)
|
||
if (todayEps.length === 0) return null
|
||
return (
|
||
<div className="cal-publish-banner">
|
||
<span className="cal-publish-banner-icon">📣</span>
|
||
<span className="cal-publish-banner-text">
|
||
{todayEps.length === 1
|
||
? `"${todayEps[0].title || `Ep ${todayEps[0].episodeNumber}`}" publishes today`
|
||
: `${todayEps.length} episodes publish today`}
|
||
</span>
|
||
{todayEps.map(ep => (
|
||
<button key={ep.id} type="button" className="em-btn em-btn--sm em-btn--secondary" onClick={() => announceEpisode(ep)}>
|
||
✉ Draft announcement
|
||
</button>
|
||
))}
|
||
</div>
|
||
)
|
||
})()}
|
||
|
||
{/* Calendar body */}
|
||
<div className="cal-body">
|
||
<div className="cal-main">
|
||
{loading ? (
|
||
<p className="cal-loading">Loading calendar…</p>
|
||
) : (
|
||
<>
|
||
{/* Day-of-week headers */}
|
||
{viewMode !== 'day' && (
|
||
<div className="cal-dow-row">
|
||
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map(d => (
|
||
<div key={d} className="cal-dow">{d}</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{/* Grid */}
|
||
<div className={`cal-grid${viewMode === 'week' ? ' cal-grid--week' : ''}${viewMode === 'day' ? ' cal-grid--day' : ''}`}>
|
||
{calDays.map((day, i) => renderDayCell(day, i))}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Sidebar — unscheduled */}
|
||
<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' : ''}`}
|
||
draggable
|
||
onDragStart={e => onChipDragStart(e, 'episode', ep.id)}
|
||
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>
|
||
|
||
{/* Episode 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>
|
||
<label className="cal-popover-label">Start Time (optional)<input className="ct-input" type="time" value={editForm.startTime} onChange={e => setEditForm(f => ({ ...f, startTime: e.target.value }))} /></label>
|
||
<label className="cal-popover-label">
|
||
Production Status
|
||
<select className="ct-input" value={editForm.productionStatus} onChange={e => setEditForm(f => ({ ...f, productionStatus: e.target.value as ProductionStatus | '' }))}>
|
||
<option value="">— not set —</option>
|
||
{PROD_STATUS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||
</select>
|
||
</label>
|
||
<label className="cal-popover-label">
|
||
Email Reminder
|
||
<select className="ct-input" value={editForm.reminderDays} onChange={e => setEditForm(f => ({ ...f, reminderDays: e.target.value }))}>
|
||
{REMINDER_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||
</select>
|
||
{editEp.reminderSentAt && <span className="cal-reminder-sent">Reminder sent {new Date(editEp.reminderSentAt).toLocaleDateString()}</span>}
|
||
</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={() => announceEpisode(editEp)} title="Open email compose pre-filled to announce this episode">✉ Announce</button>
|
||
{editEp.datePublished && <button type="button" className="em-btn em-btn--ghost em-btn--sm" title="Create Record (−14d) and Edit (−7d) task events" onClick={() => generateTasks(editEp)}>⚙ Tasks</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>
|
||
)}
|
||
|
||
{/* Event edit popover */}
|
||
{editEv && (
|
||
<div className="cal-popover-overlay" onClick={() => setEditEv(null)}>
|
||
<div className="cal-popover" onClick={e => e.stopPropagation()}>
|
||
<div className="cal-popover-head">
|
||
<h3>Edit Event</h3>
|
||
<button type="button" className="cal-popover-close" onClick={() => setEditEv(null)}>×</button>
|
||
</div>
|
||
<div className="cal-popover-body">
|
||
<label className="cal-popover-label">Type<select className="ct-input" value={editEvForm.type} onChange={e => setEditEvForm(f => ({ ...f, type: e.target.value as CalendarEventType }))}>{EVENT_TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.icon} {o.label}</option>)}</select></label>
|
||
<label className="cal-popover-label">Title<input className="ct-input" type="text" value={editEvForm.title} onChange={e => setEditEvForm(f => ({ ...f, title: e.target.value }))} /></label>
|
||
<label className="cal-popover-label">Date<input className="ct-input" type="date" value={editEvForm.date} onChange={e => setEditEvForm(f => ({ ...f, date: e.target.value }))} /></label>
|
||
<label className="cal-popover-label">Start Time (optional)<input className="ct-input" type="time" value={editEvForm.startTime} onChange={e => setEditEvForm(f => ({ ...f, startTime: e.target.value }))} /></label>
|
||
<label className="cal-popover-label">Notes<input className="ct-input" type="text" value={editEvForm.notes} onChange={e => setEditEvForm(f => ({ ...f, notes: e.target.value }))} /></label>
|
||
<label className="cal-popover-label">
|
||
Email Reminder
|
||
<select className="ct-input" value={editEvForm.reminderDays} onChange={e => setEditEvForm(f => ({ ...f, reminderDays: e.target.value }))}>
|
||
{REMINDER_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||
</select>
|
||
{editEv.reminderSentAt && <span className="cal-reminder-sent">Reminder sent {new Date(editEv.reminderSentAt).toLocaleDateString()}</span>}
|
||
</label>
|
||
<label className="cal-popover-label">
|
||
Repeats
|
||
<select className="ct-input" value={editEvForm.recurrenceFreq} onChange={e => setEditEvForm(f => ({ ...f, recurrenceFreq: e.target.value as RecurrenceFreq }))}>
|
||
{RECURRENCE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||
</select>
|
||
</label>
|
||
{editEvForm.recurrenceFreq !== 'none' && (
|
||
<label className="cal-popover-label">
|
||
Until (optional)
|
||
<input className="ct-input" type="date" value={editEvForm.recurrenceUntil} onChange={e => setEditEvForm(f => ({ ...f, recurrenceUntil: e.target.value }))} />
|
||
</label>
|
||
)}
|
||
{editEv.type === 'task' && (
|
||
<label className="cal-popover-label cal-popover-label--row">
|
||
<input type="checkbox" checked={editEv.completed} onChange={() => toggleComplete(editEv)} style={{ width: 'auto' }} />
|
||
Mark as completed
|
||
</label>
|
||
)}
|
||
</div>
|
||
<div className="cal-popover-actions">
|
||
<button type="button" className="em-btn em-btn--primary em-btn--sm" onClick={saveEditEvent} disabled={evSaving}>{evSaving ? 'Saving…' : 'Save'}</button>
|
||
<button type="button" className="em-btn em-btn--danger em-btn--sm" onClick={() => deleteEvent(editEv)}>Delete</button>
|
||
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => setEditEv(null)}>Cancel</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|