Add general calendar events (recording, social, task, general); nav links; v1.1.16
- Four event types on the calendar: General, Recording, Social, Task — each color-coded - + Event button opens form with type picker, title, date, notes, reminder - Task events have a Mark Complete toggle; completed events show strikethrough - Edit/delete popover for each event - Email reminders work for calendar events same as episodes - Added Email/Calendar nav links to /contacts header - Added Email link to /calendar header (already had Admin) - /email already links to Contacts and Calendar Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+27
@@ -10575,6 +10575,33 @@
|
||||
|
||||
.cal-ep-chip-title { color: #4a8a58; }
|
||||
|
||||
/* Calendar event chips (general / recording / social / task) */
|
||||
.cal-ev-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 0.68rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
border: 1px solid transparent;
|
||||
transition: opacity 0.12s;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.cal-ev-chip--general { background: #1a2340; border-color: #243060; color: #93c5fd; }
|
||||
.cal-ev-chip--recording { background: #28183a; border-color: #3a205a; color: #c084fc; }
|
||||
.cal-ev-chip--social { background: #3a1a28; border-color: #5a2038; color: #f9a8d4; }
|
||||
.cal-ev-chip--task { background: #2a2010; border-color: #3a3010; color: #fbbf24; }
|
||||
.cal-ev-chip--done { opacity: 0.45; text-decoration: line-through; }
|
||||
.cal-ev-chip:hover { filter: brightness(1.15); }
|
||||
|
||||
.cal-ev-icon { flex-shrink: 0; }
|
||||
.cal-ev-title { flex: 1; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.cal-ep-bell {
|
||||
font-size: 0.6rem;
|
||||
margin-left: 2px;
|
||||
|
||||
+260
-5
@@ -30,6 +30,27 @@ const REMINDER_OPTIONS = [
|
||||
{ value: '14', label: '2 weeks before' },
|
||||
]
|
||||
|
||||
type CalendarEventType = 'general' | 'recording' | 'social' | 'task'
|
||||
|
||||
interface CalendarEvent {
|
||||
id: string
|
||||
type: CalendarEventType
|
||||
title: string
|
||||
date: string
|
||||
notes: string
|
||||
completed: boolean
|
||||
reminderDays: number
|
||||
reminderSentAt?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
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: '✅' },
|
||||
]
|
||||
|
||||
interface PodcastChecklistData {
|
||||
tasks: PodcastChecklistTask[]
|
||||
episodes: PodcastChecklistEpisode[]
|
||||
@@ -165,13 +186,28 @@ function CalendarClient() {
|
||||
const [newForm, setNewForm] = useState({ series: '', episodeNumber: '', title: '', datePublished: '', reminderDays: '0' })
|
||||
const [newBusy, setNewBusy] = useState(false)
|
||||
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([])
|
||||
const [newEvOpen, setNewEvOpen] = useState(false)
|
||||
const [newEvForm, setNewEvForm] = useState({ type: 'general' as CalendarEventType, title: '', date: '', notes: '', reminderDays: '0' })
|
||||
const [newEvBusy, setNewEvBusy] = useState(false)
|
||||
const [editEv, setEditEv] = useState<CalendarEvent | null>(null)
|
||||
const [editEvForm, setEditEvForm] = useState({ type: 'general' as CalendarEventType, title: '', date: '', notes: '', reminderDays: '0' })
|
||||
const [evSaving, setEvSaving] = 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 }
|
||||
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)
|
||||
}, [])
|
||||
@@ -229,6 +265,17 @@ function CalendarClient() {
|
||||
[checklist]
|
||||
)
|
||||
|
||||
const eventsByDate = useMemo(() => {
|
||||
const map = new Map<string, CalendarEvent[]>()
|
||||
for (const ev of events) {
|
||||
if (!ev.date) continue
|
||||
const arr = map.get(ev.date) ?? []
|
||||
arr.push(ev)
|
||||
map.set(ev.date, arr)
|
||||
}
|
||||
return map
|
||||
}, [events])
|
||||
|
||||
const todayKey = toDateKey(today)
|
||||
|
||||
function prevMonth() {
|
||||
@@ -295,6 +342,86 @@ function CalendarClient() {
|
||||
setEditEp(null)
|
||||
}
|
||||
|
||||
async function handleNewEvent(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!newEvForm.title.trim() || !newEvForm.date) return
|
||||
setNewEvBusy(true)
|
||||
try {
|
||||
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,
|
||||
notes: newEvForm.notes.trim(),
|
||||
reminderDays: Number(newEvForm.reminderDays),
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { event: CalendarEvent }
|
||||
setEvents(prev => [data.event, ...prev])
|
||||
setNewEvForm({ type: 'general', title: '', date: '', notes: '', reminderDays: '0' })
|
||||
setNewEvOpen(false)
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
setNewEvBusy(false)
|
||||
}
|
||||
|
||||
function openEditEvent(ev: CalendarEvent) {
|
||||
setEditEv(ev)
|
||||
setEditEvForm({ type: ev.type, title: ev.title, date: ev.date, notes: ev.notes, reminderDays: String(ev.reminderDays) })
|
||||
}
|
||||
|
||||
async function saveEditEvent() {
|
||||
if (!editEv) return
|
||||
setEvSaving(true)
|
||||
try {
|
||||
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,
|
||||
notes: editEvForm.notes.trim(),
|
||||
reminderDays: Number(editEvForm.reminderDays),
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
setEvents(prev => prev.map(ev => ev.id === editEv.id
|
||||
? { ...ev, ...editEvForm, reminderDays: Number(editEvForm.reminderDays),
|
||||
reminderSentAt: (editEvForm.date !== editEv.date || Number(editEvForm.reminderDays) !== editEv.reminderDays) ? undefined : ev.reminderSentAt }
|
||||
: ev
|
||||
))
|
||||
setEditEv(null)
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
setEvSaving(false)
|
||||
}
|
||||
|
||||
async function deleteEvent(ev: CalendarEvent) {
|
||||
try {
|
||||
const res = await fetch(`/api/admin-calendar-events/${ev.id}`, { method: 'DELETE', credentials: 'include' })
|
||||
if (res.ok) { setEvents(prev => prev.filter(e => e.id !== ev.id)); 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 */ }
|
||||
}
|
||||
|
||||
async function handleNewEpisode(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!checklist) return
|
||||
@@ -346,10 +473,18 @@ function CalendarClient() {
|
||||
<button
|
||||
type="button"
|
||||
className="em-btn em-btn--secondary em-btn--sm"
|
||||
onClick={() => setNewEpOpen(o => !o)}
|
||||
onClick={() => { setNewEpOpen(o => !o); setNewEvOpen(false) }}
|
||||
>
|
||||
{newEpOpen ? 'Cancel' : '+ New Episode'}
|
||||
{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>
|
||||
<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>
|
||||
@@ -391,6 +526,47 @@ function CalendarClient() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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">
|
||||
Email 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" 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>
|
||||
)}
|
||||
|
||||
<div className="cal-body">
|
||||
<div className="cal-main">
|
||||
{loading ? (
|
||||
@@ -434,6 +610,22 @@ function CalendarClient() {
|
||||
{(ep.reminderDays ?? 0) > 0 && <span className="cal-ep-bell" title={`Reminder: ${ep.reminderDays}d before${ep.reminderSentAt ? ' (sent)' : ''}`}>{ep.reminderSentAt ? '✓' : '🔔'}</span>}
|
||||
</button>
|
||||
))}
|
||||
{(eventsByDate.get(dk) ?? []).map(ev => {
|
||||
const opt = EVENT_TYPE_OPTIONS.find(o => o.value === ev.type)
|
||||
return (
|
||||
<button
|
||||
key={ev.id}
|
||||
type="button"
|
||||
className={`cal-ev-chip cal-ev-chip--${ev.type}${ev.completed ? ' cal-ev-chip--done' : ''}`}
|
||||
title={ev.title + (ev.notes ? ` · ${ev.notes}` : '')}
|
||||
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.reminderDays ?? 0) > 0 && <span className="cal-ep-bell">{ev.reminderSentAt ? '✓' : '🔔'}</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -561,6 +753,69 @@ function CalendarClient() {
|
||||
</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">
|
||||
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>
|
||||
{editEv.type === 'task' && (
|
||||
<label className="cal-popover-label" style={{ flexDirection: 'row', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -303,7 +303,8 @@ function ContactsClient() {
|
||||
>
|
||||
{addOpen ? 'Cancel' : '+ Add Contact'}
|
||||
</button>
|
||||
<Link to="/email" className="em-btn em-btn--ghost em-btn--sm">Email</Link>
|
||||
<Link to="/email" className="em-btn em-btn--ghost em-btn--sm">✉ Email</Link>
|
||||
<Link to="/calendar" className="em-btn em-btn--ghost em-btn--sm">Calendar</Link>
|
||||
<Link to="/admin" className="em-btn em-btn--ghost em-btn--sm">← Admin</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
Reference in New Issue
Block a user