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:
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.15",
|
"version": "1.1.16",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
loadPodcastChecklistFromDisk,
|
loadPodcastChecklistFromDisk,
|
||||||
loadAnalyticsEventsFromDisk,
|
loadAnalyticsEventsFromDisk,
|
||||||
loadEmailSettingsFromDisk,
|
loadEmailSettingsFromDisk,
|
||||||
|
loadCalendarEventsFromDisk,
|
||||||
createBackupSnapshot,
|
createBackupSnapshot,
|
||||||
refreshContentCaches,
|
refreshContentCaches,
|
||||||
queueHitStatsWrite,
|
queueHitStatsWrite,
|
||||||
@@ -50,6 +51,7 @@ import { register as registerStudyComments } from './server/routes/study-comment
|
|||||||
import { register as registerStudyCertificate } from './server/routes/study-certificate.js'
|
import { register as registerStudyCertificate } from './server/routes/study-certificate.js'
|
||||||
import { register as registerEpisodeScripts } from './server/routes/episode-scripts.js'
|
import { register as registerEpisodeScripts } from './server/routes/episode-scripts.js'
|
||||||
import { register as registerContact } from './server/routes/contact.js'
|
import { register as registerContact } from './server/routes/contact.js'
|
||||||
|
import { register as registerCalendarEvents } from './server/routes/calendar-events.js'
|
||||||
import { startReminderScheduler } from './server/reminder.js'
|
import { startReminderScheduler } from './server/reminder.js'
|
||||||
import { register as registerInboundEmail } from './server/routes/inbound-email.js'
|
import { register as registerInboundEmail } from './server/routes/inbound-email.js'
|
||||||
import { register as registerQuestions } from './server/routes/questions.js'
|
import { register as registerQuestions } from './server/routes/questions.js'
|
||||||
@@ -97,6 +99,7 @@ registerStudyComments(app)
|
|||||||
registerStudyCertificate(app)
|
registerStudyCertificate(app)
|
||||||
registerEpisodeScripts(app)
|
registerEpisodeScripts(app)
|
||||||
registerContact(app)
|
registerContact(app)
|
||||||
|
registerCalendarEvents(app)
|
||||||
registerInboundEmail(app)
|
registerInboundEmail(app)
|
||||||
registerQuestions(app)
|
registerQuestions(app)
|
||||||
registerAnalytics(app)
|
registerAnalytics(app)
|
||||||
@@ -146,6 +149,7 @@ Promise.all([
|
|||||||
loadPodcastChecklistFromDisk(),
|
loadPodcastChecklistFromDisk(),
|
||||||
loadAnalyticsEventsFromDisk(),
|
loadAnalyticsEventsFromDisk(),
|
||||||
loadEmailSettingsFromDisk(),
|
loadEmailSettingsFromDisk(),
|
||||||
|
loadCalendarEventsFromDisk(),
|
||||||
refreshContentCaches(),
|
refreshContentCaches(),
|
||||||
])
|
])
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export const QR_CODES_FILE = path.join(DATA_DIR, 'qr-codes.json')
|
|||||||
export const EPISODE_PLAYS_FILE = path.join(DATA_DIR, 'episode-plays.json')
|
export const EPISODE_PLAYS_FILE = path.join(DATA_DIR, 'episode-plays.json')
|
||||||
export const ANALYTICS_EVENTS_FILE = path.join(DATA_DIR, 'analytics-events.json')
|
export const ANALYTICS_EVENTS_FILE = path.join(DATA_DIR, 'analytics-events.json')
|
||||||
export const EMAIL_SETTINGS_FILE = path.join(DATA_DIR, 'email-settings.json')
|
export const EMAIL_SETTINGS_FILE = path.join(DATA_DIR, 'email-settings.json')
|
||||||
|
export const CALENDAR_EVENTS_FILE = path.join(DATA_DIR, 'calendar-events.json')
|
||||||
export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon
|
export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon
|
||||||
|
|
||||||
export const DIST_DIR = path.join(ROOT_DIR, 'dist')
|
export const DIST_DIR = path.join(ROOT_DIR, 'dist')
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
EPISODE_PLAYS_FILE,
|
EPISODE_PLAYS_FILE,
|
||||||
ANALYTICS_EVENTS_FILE,
|
ANALYTICS_EVENTS_FILE,
|
||||||
EMAIL_SETTINGS_FILE,
|
EMAIL_SETTINGS_FILE,
|
||||||
|
CALENDAR_EVENTS_FILE,
|
||||||
EMPTY_HIT_STATS,
|
EMPTY_HIT_STATS,
|
||||||
EMPTY_VISITOR_STATS,
|
EMPTY_VISITOR_STATS,
|
||||||
DEFAULT_REPLY_TEMPLATES,
|
DEFAULT_REPLY_TEMPLATES,
|
||||||
@@ -416,6 +417,36 @@ export function loadEmailSettingsFromDisk() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Calendar events ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function queueCalendarEventsWrite() {
|
||||||
|
state.calendarEventsWritePromise = state.calendarEventsWritePromise
|
||||||
|
.then(async () => {
|
||||||
|
await mkdir(DATA_DIR, { recursive: true })
|
||||||
|
await writeFile(
|
||||||
|
CALENDAR_EVENTS_FILE,
|
||||||
|
JSON.stringify({ events: state.calendarEvents, updatedAt: new Date().toISOString() }, null, 2),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('[calendar-events] failed to write:', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadCalendarEventsFromDisk() {
|
||||||
|
return readFile(CALENDAR_EVENTS_FILE, 'utf8')
|
||||||
|
.then(raw => {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (Array.isArray(parsed?.events)) {
|
||||||
|
state.calendarEvents = parsed.events
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// No file yet — start with empty array
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ── Podcast checklist ──────────────────────────────────────────────────────
|
// ── Podcast checklist ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function queuePodcastChecklistWrite() {
|
export function queuePodcastChecklistWrite() {
|
||||||
|
|||||||
+55
-32
@@ -1,6 +1,6 @@
|
|||||||
import { Resend } from 'resend'
|
import { Resend } from 'resend'
|
||||||
import { state } from './state.js'
|
import { state } from './state.js'
|
||||||
import { queuePodcastChecklistWrite } from './data.js'
|
import { queuePodcastChecklistWrite, queueCalendarEventsWrite } from './data.js'
|
||||||
import { DEFAULT_RESEND_FROM, DEFAULT_RESEND_TO } from './config.js'
|
import { DEFAULT_RESEND_FROM, DEFAULT_RESEND_TO } from './config.js'
|
||||||
|
|
||||||
function toDateKey(date) {
|
function toDateKey(date) {
|
||||||
@@ -13,35 +13,62 @@ export function startReminderScheduler() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function checkReminders() {
|
async function checkReminders() {
|
||||||
const episodes = state.podcastChecklist?.episodes
|
|
||||||
if (!Array.isArray(episodes)) return
|
|
||||||
|
|
||||||
const todayKey = toDateKey(new Date())
|
const todayKey = toDateKey(new Date())
|
||||||
let changed = false
|
|
||||||
|
|
||||||
|
// Episodes
|
||||||
|
const episodes = state.podcastChecklist?.episodes
|
||||||
|
if (Array.isArray(episodes)) {
|
||||||
|
let changed = false
|
||||||
for (const ep of episodes) {
|
for (const ep of episodes) {
|
||||||
if (!ep.datePublished || !(ep.reminderDays > 0) || ep.reminderSentAt) continue
|
if (!ep.datePublished || !(ep.reminderDays > 0) || ep.reminderSentAt) continue
|
||||||
|
|
||||||
const publish = new Date(ep.datePublished + 'T12:00:00Z')
|
const publish = new Date(ep.datePublished + 'T12:00:00Z')
|
||||||
if (isNaN(publish.getTime())) continue
|
if (isNaN(publish.getTime())) continue
|
||||||
|
|
||||||
const reminderDate = new Date(publish)
|
const reminderDate = new Date(publish)
|
||||||
reminderDate.setUTCDate(reminderDate.getUTCDate() - ep.reminderDays)
|
reminderDate.setUTCDate(reminderDate.getUTCDate() - ep.reminderDays)
|
||||||
const reminderKey = toDateKey(reminderDate)
|
if (todayKey >= toDateKey(reminderDate) && todayKey <= toDateKey(publish)) {
|
||||||
|
const sent = await sendReminderEmail({
|
||||||
if (todayKey >= reminderKey && todayKey <= toDateKey(publish)) {
|
label: ep.episodeNumber ? `Episode ${ep.episodeNumber}` : 'Episode',
|
||||||
const sent = await sendReminderEmail(ep)
|
title: ep.title || 'Untitled',
|
||||||
if (sent) {
|
series: ep.series,
|
||||||
ep.reminderSentAt = new Date().toISOString()
|
date: ep.datePublished,
|
||||||
changed = true
|
reminderDays: ep.reminderDays,
|
||||||
|
type: 'episode',
|
||||||
|
})
|
||||||
|
if (sent) { ep.reminderSentAt = new Date().toISOString(); changed = true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (changed) queuePodcastChecklistWrite()
|
if (changed) queuePodcastChecklistWrite()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendReminderEmail(ep) {
|
// Calendar events
|
||||||
|
const events = state.calendarEvents
|
||||||
|
if (Array.isArray(events)) {
|
||||||
|
let changed = false
|
||||||
|
for (const ev of events) {
|
||||||
|
if (!ev.date || !(ev.reminderDays > 0) || ev.reminderSentAt) continue
|
||||||
|
const publish = new Date(ev.date + 'T12:00:00Z')
|
||||||
|
if (isNaN(publish.getTime())) continue
|
||||||
|
const reminderDate = new Date(publish)
|
||||||
|
reminderDate.setUTCDate(reminderDate.getUTCDate() - ev.reminderDays)
|
||||||
|
if (todayKey >= toDateKey(reminderDate) && todayKey <= toDateKey(publish)) {
|
||||||
|
const sent = await sendReminderEmail({
|
||||||
|
label: ev.type.charAt(0).toUpperCase() + ev.type.slice(1),
|
||||||
|
title: ev.title,
|
||||||
|
series: null,
|
||||||
|
date: ev.date,
|
||||||
|
reminderDays: ev.reminderDays,
|
||||||
|
type: ev.type,
|
||||||
|
})
|
||||||
|
if (sent) { ev.reminderSentAt = new Date().toISOString(); changed = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) queueCalendarEventsWrite()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_ICONS = { episode: '📅', general: '📌', recording: '🎙️', social: '📱', task: '✅' }
|
||||||
|
|
||||||
|
async function sendReminderEmail({ label, title, series, date, reminderDays, type }) {
|
||||||
if (!process.env.RESEND_API_KEY) return false
|
if (!process.env.RESEND_API_KEY) return false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -49,37 +76,33 @@ async function sendReminderEmail(ep) {
|
|||||||
const from = process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM
|
const from = process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM
|
||||||
const to = process.env.RESEND_TO ?? DEFAULT_RESEND_TO
|
const to = process.env.RESEND_TO ?? DEFAULT_RESEND_TO
|
||||||
|
|
||||||
const publishDate = new Date(ep.datePublished + 'T12:00:00Z')
|
const eventDate = new Date(date + 'T12:00:00Z')
|
||||||
const msLeft = publishDate.getTime() - Date.now()
|
const msLeft = eventDate.getTime() - Date.now()
|
||||||
const daysLeft = Math.max(0, Math.ceil(msLeft / (1000 * 60 * 60 * 24)))
|
const daysLeft = Math.max(0, Math.ceil(msLeft / (1000 * 60 * 60 * 24)))
|
||||||
|
|
||||||
const label = ep.episodeNumber ? `Episode ${ep.episodeNumber}` : 'Episode'
|
const seriesStr = series ? ` (${series})` : ''
|
||||||
const title = ep.title || 'Untitled'
|
|
||||||
const series = ep.series ? ` (${ep.series})` : ''
|
|
||||||
const daysText = daysLeft === 0 ? 'today' : daysLeft === 1 ? 'in 1 day' : `in ${daysLeft} days`
|
const daysText = daysLeft === 0 ? 'today' : daysLeft === 1 ? 'in 1 day' : `in ${daysLeft} days`
|
||||||
const subject = `Reminder: "${label}: ${title}" publishes ${daysText}`
|
const icon = TYPE_ICONS[type] ?? '📅'
|
||||||
|
const subject = `Reminder: ${label}: ${title} — ${daysText}`
|
||||||
|
|
||||||
const html = `
|
const html = `
|
||||||
<div style="font-family:system-ui,sans-serif;max-width:540px;margin:0 auto;color:#222">
|
<div style="font-family:system-ui,sans-serif;max-width:540px;margin:0 auto;color:#222">
|
||||||
<h2 style="color:#c8860a;margin-bottom:4px">📅 Release Reminder</h2>
|
<h2 style="color:#c8860a;margin-bottom:4px">${icon} Calendar Reminder</h2>
|
||||||
<p style="font-size:1.1rem;margin-bottom:16px">
|
<p style="font-size:1.1rem;margin-bottom:16px">
|
||||||
<strong>${label}: ${title}</strong>${series}<br>
|
<strong>${label}: ${title}</strong>${seriesStr}<br>
|
||||||
<span style="color:#555">Publishes <strong>${ep.datePublished}</strong> — ${daysText}</span>
|
<span style="color:#555">Scheduled for <strong>${date}</strong> — ${daysText}</span>
|
||||||
</p>
|
</p>
|
||||||
<hr style="border:none;border-top:1px solid #eee;margin:16px 0">
|
<hr style="border:none;border-top:1px solid #eee;margin:16px 0">
|
||||||
<p style="color:#777;font-size:0.85rem">
|
<p style="color:#777;font-size:0.85rem">
|
||||||
This reminder was set ${ep.reminderDays} day${ep.reminderDays === 1 ? '' : 's'} before the publish date.
|
This reminder was set ${reminderDays} day${reminderDays === 1 ? '' : 's'} before the date.
|
||||||
To change or remove it, open the Calendar in your admin panel.
|
To change or remove it, open the Calendar in your admin panel.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
|
|
||||||
const { error } = await resend.emails.send({ from, to, subject, html })
|
const { error } = await resend.emails.send({ from, to, subject, html })
|
||||||
if (error) {
|
if (error) { console.error('[reminder] send error:', error); return false }
|
||||||
console.error('[reminder] send error:', error)
|
console.log(`[reminder] sent for "${title}" (${date})`)
|
||||||
return false
|
|
||||||
}
|
|
||||||
console.log(`[reminder] sent for episode "${title}" (${ep.datePublished})`)
|
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[reminder] send exception:', err)
|
console.error('[reminder] send exception:', err)
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { state } from '../state.js'
|
||||||
|
import { queueCalendarEventsWrite } from '../data.js'
|
||||||
|
import { requireAdminAuth } from '../auth.js'
|
||||||
|
|
||||||
|
const VALID_TYPES = ['general', 'recording', 'social', 'task']
|
||||||
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
|
||||||
|
|
||||||
|
function sanitize(ev) {
|
||||||
|
return {
|
||||||
|
id: ev.id,
|
||||||
|
type: VALID_TYPES.includes(ev.type) ? ev.type : 'general',
|
||||||
|
title: typeof ev.title === 'string' ? ev.title.slice(0, 300) : '',
|
||||||
|
date: typeof ev.date === 'string' && DATE_RE.test(ev.date) ? ev.date : '',
|
||||||
|
notes: typeof ev.notes === 'string' ? ev.notes.slice(0, 2000) : '',
|
||||||
|
completed: Boolean(ev.completed),
|
||||||
|
reminderDays: Number.isFinite(ev.reminderDays) && ev.reminderDays > 0 ? ev.reminderDays : 0,
|
||||||
|
reminderSentAt: typeof ev.reminderSentAt === 'string' ? ev.reminderSentAt : undefined,
|
||||||
|
createdAt: typeof ev.createdAt === 'string' ? ev.createdAt : new Date().toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function register(app) {
|
||||||
|
app.get('/api/admin-calendar-events', requireAdminAuth, (_req, res) => {
|
||||||
|
res.json({ events: state.calendarEvents })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/admin-calendar-events', requireAdminAuth, (req, res) => {
|
||||||
|
const { type, title, date, notes, reminderDays } = req.body ?? {}
|
||||||
|
if (!title?.trim()) { res.status(400).json({ message: 'Title is required.' }); return }
|
||||||
|
if (!date || !DATE_RE.test(date)) { res.status(400).json({ message: 'Valid date is required.' }); return }
|
||||||
|
if (type && !VALID_TYPES.includes(type)) { res.status(400).json({ message: 'Invalid type.' }); return }
|
||||||
|
|
||||||
|
const ev = sanitize({
|
||||||
|
id: randomUUID(),
|
||||||
|
type: type ?? 'general',
|
||||||
|
title: String(title).trim(),
|
||||||
|
date,
|
||||||
|
notes: notes ?? '',
|
||||||
|
completed: false,
|
||||||
|
reminderDays: reminderDays ?? 0,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
state.calendarEvents.unshift(ev)
|
||||||
|
queueCalendarEventsWrite()
|
||||||
|
res.json({ ok: true, event: ev })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch('/api/admin-calendar-events/:id', requireAdminAuth, (req, res) => {
|
||||||
|
const { id } = req.params
|
||||||
|
let found = false
|
||||||
|
state.calendarEvents = state.calendarEvents.map(ev => {
|
||||||
|
if (ev.id !== id) return ev
|
||||||
|
found = true
|
||||||
|
const patch = {}
|
||||||
|
if (typeof req.body?.title === 'string') patch.title = req.body.title.trim().slice(0, 300)
|
||||||
|
if (typeof req.body?.date === 'string' && DATE_RE.test(req.body.date)) patch.date = req.body.date
|
||||||
|
if (typeof req.body?.notes === 'string') patch.notes = req.body.notes.trim().slice(0, 2000)
|
||||||
|
if (typeof req.body?.completed === 'boolean') patch.completed = req.body.completed
|
||||||
|
if (VALID_TYPES.includes(req.body?.type)) patch.type = req.body.type
|
||||||
|
if (Number.isFinite(req.body?.reminderDays)) patch.reminderDays = req.body.reminderDays
|
||||||
|
const dateChanged = patch.date && patch.date !== ev.date
|
||||||
|
const reminderChanged = 'reminderDays' in patch && patch.reminderDays !== ev.reminderDays
|
||||||
|
if (dateChanged || reminderChanged) patch.reminderSentAt = undefined
|
||||||
|
return { ...ev, ...patch }
|
||||||
|
})
|
||||||
|
if (!found) { res.status(404).json({ message: 'Event not found.' }); return }
|
||||||
|
queueCalendarEventsWrite()
|
||||||
|
res.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/api/admin-calendar-events/:id', requireAdminAuth, (req, res) => {
|
||||||
|
const before = state.calendarEvents.length
|
||||||
|
state.calendarEvents = state.calendarEvents.filter(ev => ev.id !== req.params.id)
|
||||||
|
if (state.calendarEvents.length === before) { res.status(404).json({ message: 'Event not found.' }); return }
|
||||||
|
queueCalendarEventsWrite()
|
||||||
|
res.json({ ok: true })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -93,6 +93,9 @@ export const state = {
|
|||||||
emailSettings: { signature: 'Grace and peace,\nVerse by Verse with Nate' },
|
emailSettings: { signature: 'Grace and peace,\nVerse by Verse with Nate' },
|
||||||
emailSettingsWritePromise: Promise.resolve(),
|
emailSettingsWritePromise: Promise.resolve(),
|
||||||
|
|
||||||
|
calendarEvents: [],
|
||||||
|
calendarEventsWritePromise: Promise.resolve(),
|
||||||
|
|
||||||
lastBackupStatus: { ok: true, at: null, error: null, file: null },
|
lastBackupStatus: { ok: true, at: null, error: null, file: null },
|
||||||
lastCachePurgeStatus: { ok: true, at: null, error: null },
|
lastCachePurgeStatus: { ok: true, at: null, error: null },
|
||||||
lastDeployHookStatus: { ok: true, at: null, error: null },
|
lastDeployHookStatus: { ok: true, at: null, error: null },
|
||||||
|
|||||||
+27
@@ -10575,6 +10575,33 @@
|
|||||||
|
|
||||||
.cal-ep-chip-title { color: #4a8a58; }
|
.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 {
|
.cal-ep-bell {
|
||||||
font-size: 0.6rem;
|
font-size: 0.6rem;
|
||||||
margin-left: 2px;
|
margin-left: 2px;
|
||||||
|
|||||||
+260
-5
@@ -30,6 +30,27 @@ const REMINDER_OPTIONS = [
|
|||||||
{ value: '14', label: '2 weeks before' },
|
{ 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 {
|
interface PodcastChecklistData {
|
||||||
tasks: PodcastChecklistTask[]
|
tasks: PodcastChecklistTask[]
|
||||||
episodes: PodcastChecklistEpisode[]
|
episodes: PodcastChecklistEpisode[]
|
||||||
@@ -165,13 +186,28 @@ function CalendarClient() {
|
|||||||
const [newForm, setNewForm] = useState({ series: '', episodeNumber: '', title: '', datePublished: '', reminderDays: '0' })
|
const [newForm, setNewForm] = useState({ series: '', episodeNumber: '', title: '', datePublished: '', reminderDays: '0' })
|
||||||
const [newBusy, setNewBusy] = useState(false)
|
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 () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin-podcast-checklist', { credentials: 'include' })
|
const [clRes, evRes] = await Promise.all([
|
||||||
if (res.ok) {
|
fetch('/api/admin-podcast-checklist', { credentials: 'include' }),
|
||||||
const data = await res.json() as { checklist: PodcastChecklistData }
|
fetch('/api/admin-calendar-events', { credentials: 'include' }),
|
||||||
|
])
|
||||||
|
if (clRes.ok) {
|
||||||
|
const data = await clRes.json() as { checklist: PodcastChecklistData }
|
||||||
setChecklist(data.checklist)
|
setChecklist(data.checklist)
|
||||||
}
|
}
|
||||||
|
if (evRes.ok) {
|
||||||
|
const data = await evRes.json() as { events: CalendarEvent[] }
|
||||||
|
setEvents(Array.isArray(data.events) ? data.events : [])
|
||||||
|
}
|
||||||
} catch { /* silent */ }
|
} catch { /* silent */ }
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}, [])
|
}, [])
|
||||||
@@ -229,6 +265,17 @@ function CalendarClient() {
|
|||||||
[checklist]
|
[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)
|
const todayKey = toDateKey(today)
|
||||||
|
|
||||||
function prevMonth() {
|
function prevMonth() {
|
||||||
@@ -295,6 +342,86 @@ function CalendarClient() {
|
|||||||
setEditEp(null)
|
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) {
|
async function handleNewEpisode(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!checklist) return
|
if (!checklist) return
|
||||||
@@ -346,10 +473,18 @@ function CalendarClient() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="em-btn em-btn--secondary em-btn--sm"
|
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>
|
||||||
|
<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>
|
<Link to="/admin" className="em-btn em-btn--ghost em-btn--sm">← Admin</Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -391,6 +526,47 @@ function CalendarClient() {
|
|||||||
</div>
|
</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-body">
|
||||||
<div className="cal-main">
|
<div className="cal-main">
|
||||||
{loading ? (
|
{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>}
|
{(ep.reminderDays ?? 0) > 0 && <span className="cal-ep-bell" title={`Reminder: ${ep.reminderDays}d before${ep.reminderSentAt ? ' (sent)' : ''}`}>{ep.reminderSentAt ? '✓' : '🔔'}</span>}
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -561,6 +753,69 @@ function CalendarClient() {
|
|||||||
</div>
|
</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">
|
||||||
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -303,7 +303,8 @@ function ContactsClient() {
|
|||||||
>
|
>
|
||||||
{addOpen ? 'Cancel' : '+ Add Contact'}
|
{addOpen ? 'Cancel' : '+ Add Contact'}
|
||||||
</button>
|
</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>
|
<Link to="/admin" className="em-btn em-btn--ghost em-btn--sm">← Admin</Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
Reference in New Issue
Block a user