Files
Siteforge/src/CalendarPage.tsx
T

982 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, useEffect, useMemo, useRef, 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
startTime?: string
expanded: boolean
tasks: Record<string, boolean>
reminderDays?: number
reminderSentAt?: string
}
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 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' })
// 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 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 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),
})
}
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)
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,
}
: ep
)
saveChecklist(updated)
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 isScheduled = ep.datePublished === dk
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].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>}
{ep.startTime && <span className="cal-chip-time">{formatTimeDisplay(ep.startTime)}</span>}
{(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>}
</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>
)}
{/* 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">
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={() => 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>
)
}