Add recurring events, week/day view, drag-to-reschedule, iCal export, and start times to calendar; v1.1.22
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.21",
|
"version": "1.1.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+4
-1
@@ -1256,7 +1256,10 @@ export function sanitizePodcastChecklist(value) {
|
|||||||
for (const taskId of taskIds) {
|
for (const taskId of taskIds) {
|
||||||
taskState[taskId] = sourceTasks[taskId] === true
|
taskState[taskId] = sourceTasks[taskId] === true
|
||||||
}
|
}
|
||||||
episodes.push({ id, series, episodeNumber, title, datePublished, expanded: item.expanded === true, tasks: taskState })
|
const reminderDays = Number.isFinite(Number(item.reminderDays)) && Number(item.reminderDays) >= 0 ? Number(item.reminderDays) : 0
|
||||||
|
const reminderSentAt = typeof item.reminderSentAt === 'string' && item.reminderSentAt ? item.reminderSentAt : undefined
|
||||||
|
const startTime = typeof item.startTime === 'string' && /^\d{2}:\d{2}$/.test(item.startTime) ? item.startTime : undefined
|
||||||
|
episodes.push({ id, series, episodeNumber, title, datePublished, expanded: item.expanded === true, tasks: taskState, reminderDays, reminderSentAt, ...(startTime ? { startTime } : {}) })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (episodes.length === 0) {
|
if (episodes.length === 0) {
|
||||||
|
|||||||
@@ -4,29 +4,136 @@ import { queueCalendarEventsWrite } from '../data.js'
|
|||||||
import { requireAdminAuth } from '../auth.js'
|
import { requireAdminAuth } from '../auth.js'
|
||||||
|
|
||||||
const VALID_TYPES = ['general', 'recording', 'social', 'task']
|
const VALID_TYPES = ['general', 'recording', 'social', 'task']
|
||||||
|
const VALID_FREQS = ['none', 'weekly', 'biweekly', 'monthly']
|
||||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
|
||||||
|
const TIME_RE = /^\d{2}:\d{2}$/
|
||||||
|
|
||||||
|
function sanitizeRecurrence(raw) {
|
||||||
|
if (!raw || typeof raw !== 'object') return null
|
||||||
|
const freq = VALID_FREQS.includes(raw.freq) ? raw.freq : 'none'
|
||||||
|
if (freq === 'none') return null
|
||||||
|
const until = typeof raw.until === 'string' && DATE_RE.test(raw.until) ? raw.until : null
|
||||||
|
return { freq, until }
|
||||||
|
}
|
||||||
|
|
||||||
function sanitize(ev) {
|
function sanitize(ev) {
|
||||||
|
const recurrence = sanitizeRecurrence(ev.recurrence)
|
||||||
return {
|
return {
|
||||||
id: ev.id,
|
id: ev.id,
|
||||||
type: VALID_TYPES.includes(ev.type) ? ev.type : 'general',
|
type: VALID_TYPES.includes(ev.type) ? ev.type : 'general',
|
||||||
title: typeof ev.title === 'string' ? ev.title.slice(0, 300) : '',
|
title: typeof ev.title === 'string' ? ev.title.slice(0, 300) : '',
|
||||||
date: typeof ev.date === 'string' && DATE_RE.test(ev.date) ? ev.date : '',
|
date: typeof ev.date === 'string' && DATE_RE.test(ev.date) ? ev.date : '',
|
||||||
|
startTime: typeof ev.startTime === 'string' && TIME_RE.test(ev.startTime) ? ev.startTime : undefined,
|
||||||
notes: typeof ev.notes === 'string' ? ev.notes.slice(0, 2000) : '',
|
notes: typeof ev.notes === 'string' ? ev.notes.slice(0, 2000) : '',
|
||||||
completed: Boolean(ev.completed),
|
completed: Boolean(ev.completed),
|
||||||
reminderDays: Number.isFinite(ev.reminderDays) && ev.reminderDays > 0 ? ev.reminderDays : 0,
|
reminderDays: Number.isFinite(ev.reminderDays) && ev.reminderDays > 0 ? ev.reminderDays : 0,
|
||||||
reminderSentAt: typeof ev.reminderSentAt === 'string' ? ev.reminderSentAt : undefined,
|
reminderSentAt: typeof ev.reminderSentAt === 'string' ? ev.reminderSentAt : undefined,
|
||||||
|
recurrence: recurrence ?? undefined,
|
||||||
createdAt: typeof ev.createdAt === 'string' ? ev.createdAt : new Date().toISOString(),
|
createdAt: typeof ev.createdAt === 'string' ? ev.createdAt : new Date().toISOString(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── iCal helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function escapeIcs(s) {
|
||||||
|
return String(s ?? '').replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function foldIcsLine(line) {
|
||||||
|
const chars = [...line]
|
||||||
|
if (chars.length <= 75) return line
|
||||||
|
const parts = []
|
||||||
|
let current = ''
|
||||||
|
for (const ch of chars) {
|
||||||
|
if ((current + ch).length > 75) { parts.push(current); current = ' ' + ch }
|
||||||
|
else current += ch
|
||||||
|
}
|
||||||
|
if (current) parts.push(current)
|
||||||
|
return parts.join('\r\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIcsDatetime(dateStr, timeStr) {
|
||||||
|
const d = dateStr.replace(/-/g, '')
|
||||||
|
if (timeStr && TIME_RE.test(timeStr)) {
|
||||||
|
const t = timeStr.replace(':', '') + '00'
|
||||||
|
return `DTSTART:${d}T${t}`
|
||||||
|
}
|
||||||
|
return `DTSTART;VALUE=DATE:${d}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIcsTimestamp(iso) {
|
||||||
|
try {
|
||||||
|
const d = new Date(iso)
|
||||||
|
return d.toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '')
|
||||||
|
} catch { return '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function rruleFor(recurrence) {
|
||||||
|
if (!recurrence || recurrence.freq === 'none') return null
|
||||||
|
const freqMap = { weekly: 'WEEKLY', biweekly: 'WEEKLY', monthly: 'MONTHLY' }
|
||||||
|
const freq = freqMap[recurrence.freq]
|
||||||
|
if (!freq) return null
|
||||||
|
let rule = `RRULE:FREQ=${freq}`
|
||||||
|
if (recurrence.freq === 'biweekly') rule += ';INTERVAL=2'
|
||||||
|
if (recurrence.until) rule += `;UNTIL=${recurrence.until.replace(/-/g, '')}T235959Z`
|
||||||
|
return rule
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateIcal() {
|
||||||
|
const lines = [
|
||||||
|
'BEGIN:VCALENDAR',
|
||||||
|
'VERSION:2.0',
|
||||||
|
'PRODID:-//Siteforge//CalendarExport//EN',
|
||||||
|
'CALSCALE:GREGORIAN',
|
||||||
|
'METHOD:PUBLISH',
|
||||||
|
'X-WR-CALNAME:Verse by Verse Calendar',
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const ep of (state.podcastChecklist?.episodes ?? [])) {
|
||||||
|
if (!ep.datePublished) continue
|
||||||
|
const summary = [ep.series, ep.episodeNumber != null ? `Ep ${ep.episodeNumber}` : null, ep.title].filter(Boolean).join(' · ')
|
||||||
|
const dtstart = ep.startTime ? `DTSTART:${ep.datePublished.replace(/-/g, '')}T${ep.startTime.replace(':', '')}00` : `DTSTART;VALUE=DATE:${ep.datePublished.replace(/-/g, '')}`
|
||||||
|
lines.push('BEGIN:VEVENT')
|
||||||
|
lines.push(`UID:ep-${ep.id}@siteforge`)
|
||||||
|
lines.push(dtstart)
|
||||||
|
lines.push(`SUMMARY:${escapeIcs(summary || 'Episode')}`)
|
||||||
|
if (ep.title) lines.push(`DESCRIPTION:${escapeIcs(ep.title)}`)
|
||||||
|
lines.push('END:VEVENT')
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ev of (state.calendarEvents ?? [])) {
|
||||||
|
if (!ev.date) continue
|
||||||
|
lines.push('BEGIN:VEVENT')
|
||||||
|
lines.push(`UID:ev-${ev.id}@siteforge`)
|
||||||
|
lines.push(toIcsDatetime(ev.date, ev.startTime))
|
||||||
|
lines.push(`SUMMARY:${escapeIcs(ev.title || '(Event)')}`)
|
||||||
|
if (ev.notes) lines.push(`DESCRIPTION:${escapeIcs(ev.notes)}`)
|
||||||
|
const rrule = rruleFor(ev.recurrence)
|
||||||
|
if (rrule) lines.push(rrule)
|
||||||
|
if (ev.createdAt) lines.push(`CREATED:${toIcsTimestamp(ev.createdAt)}`)
|
||||||
|
lines.push('END:VEVENT')
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('END:VCALENDAR')
|
||||||
|
return lines.map(foldIcsLine).join('\r\n') + '\r\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Routes ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function register(app) {
|
export function register(app) {
|
||||||
app.get('/api/admin-calendar-events', requireAdminAuth, (_req, res) => {
|
app.get('/api/admin-calendar-events', requireAdminAuth, (_req, res) => {
|
||||||
res.json({ events: state.calendarEvents })
|
res.json({ events: state.calendarEvents })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.get('/api/admin-calendar.ics', requireAdminAuth, (_req, res) => {
|
||||||
|
const ical = generateIcal()
|
||||||
|
res.setHeader('Content-Type', 'text/calendar; charset=utf-8')
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="siteforge-calendar.ics"')
|
||||||
|
res.send(ical)
|
||||||
|
})
|
||||||
|
|
||||||
app.post('/api/admin-calendar-events', requireAdminAuth, (req, res) => {
|
app.post('/api/admin-calendar-events', requireAdminAuth, (req, res) => {
|
||||||
const { type, title, date, notes, reminderDays } = req.body ?? {}
|
const { type, title, date, startTime, notes, reminderDays, recurrence } = req.body ?? {}
|
||||||
if (!title?.trim()) { res.status(400).json({ message: 'Title is required.' }); return }
|
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 (!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 }
|
if (type && !VALID_TYPES.includes(type)) { res.status(400).json({ message: 'Invalid type.' }); return }
|
||||||
@@ -36,9 +143,11 @@ export function register(app) {
|
|||||||
type: type ?? 'general',
|
type: type ?? 'general',
|
||||||
title: String(title).trim(),
|
title: String(title).trim(),
|
||||||
date,
|
date,
|
||||||
|
startTime: startTime ?? undefined,
|
||||||
notes: notes ?? '',
|
notes: notes ?? '',
|
||||||
completed: false,
|
completed: false,
|
||||||
reminderDays: reminderDays ?? 0,
|
reminderDays: reminderDays ?? 0,
|
||||||
|
recurrence: recurrence ?? null,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -56,10 +165,13 @@ export function register(app) {
|
|||||||
const patch = {}
|
const patch = {}
|
||||||
if (typeof req.body?.title === 'string') patch.title = req.body.title.trim().slice(0, 300)
|
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?.date === 'string' && DATE_RE.test(req.body.date)) patch.date = req.body.date
|
||||||
|
if (typeof req.body?.startTime === 'string') patch.startTime = TIME_RE.test(req.body.startTime) ? req.body.startTime : undefined
|
||||||
|
if (req.body?.startTime === null) patch.startTime = undefined
|
||||||
if (typeof req.body?.notes === 'string') patch.notes = req.body.notes.trim().slice(0, 2000)
|
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 (typeof req.body?.completed === 'boolean') patch.completed = req.body.completed
|
||||||
if (VALID_TYPES.includes(req.body?.type)) patch.type = req.body.type
|
if (VALID_TYPES.includes(req.body?.type)) patch.type = req.body.type
|
||||||
if (Number.isFinite(req.body?.reminderDays)) patch.reminderDays = req.body.reminderDays
|
if (Number.isFinite(req.body?.reminderDays)) patch.reminderDays = req.body.reminderDays
|
||||||
|
if ('recurrence' in (req.body ?? {})) patch.recurrence = sanitizeRecurrence(req.body.recurrence) ?? undefined
|
||||||
const dateChanged = patch.date && patch.date !== ev.date
|
const dateChanged = patch.date && patch.date !== ev.date
|
||||||
const reminderChanged = 'reminderDays' in patch && patch.reminderDays !== ev.reminderDays
|
const reminderChanged = 'reminderDays' in patch && patch.reminderDays !== ev.reminderDays
|
||||||
if (dateChanged || reminderChanged) patch.reminderSentAt = undefined
|
if (dateChanged || reminderChanged) patch.reminderSentAt = undefined
|
||||||
|
|||||||
+101
@@ -10698,10 +10698,104 @@
|
|||||||
.cal-ev-chip--task { background: #2a2010; border-color: #3a3010; color: #fbbf24; }
|
.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--done { opacity: 0.45; text-decoration: line-through; }
|
||||||
.cal-ev-chip:hover { filter: brightness(1.15); }
|
.cal-ev-chip:hover { filter: brightness(1.15); }
|
||||||
|
.cal-ev-chip--recurring { opacity: 0.85; }
|
||||||
|
|
||||||
.cal-ev-icon { flex-shrink: 0; }
|
.cal-ev-icon { flex-shrink: 0; }
|
||||||
.cal-ev-title { flex: 1; overflow: hidden; text-overflow: ellipsis; }
|
.cal-ev-title { flex: 1; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|
||||||
|
/* Time of day */
|
||||||
|
.cal-chip-time {
|
||||||
|
color: rgba(255,255,255,0.45);
|
||||||
|
font-size: 0.63rem;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
margin-left: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recurrence indicator */
|
||||||
|
.cal-recur-icon {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
margin-left: 2px;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* View toggle */
|
||||||
|
.cal-header-center {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-view-toggle {
|
||||||
|
display: flex;
|
||||||
|
background: #1e1e1e;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-view-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-right: 1px solid #333;
|
||||||
|
color: #a09880;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.3rem 0.65rem;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
.cal-view-btn:last-child { border-right: none; }
|
||||||
|
.cal-view-btn:hover { background: rgba(255,255,255,0.06); color: #e0d8c8; }
|
||||||
|
.cal-view-btn--active { background: rgba(201,168,76,0.15); color: #c9a84c; }
|
||||||
|
|
||||||
|
/* Week / day grid variants */
|
||||||
|
.cal-grid--week {
|
||||||
|
grid-template-rows: minmax(200px, auto);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-grid--week .cal-day {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-grid--day {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-grid--day .cal-day {
|
||||||
|
min-height: 300px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-day--week .cal-day-head { padding: 0.35rem 0.5rem 0; }
|
||||||
|
.cal-day--day-view { padding: 1rem 1.25rem; }
|
||||||
|
|
||||||
|
/* Day header (number + click-to-drill) */
|
||||||
|
.cal-day-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-day-num--today {
|
||||||
|
background: #c8860a;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #fff;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
padding: 0 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drag-over day highlight */
|
||||||
|
.cal-day--drag-over {
|
||||||
|
background: rgba(201,168,76,0.1) !important;
|
||||||
|
border-color: rgba(201,168,76,0.4) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.cal-ep-bell {
|
.cal-ep-bell {
|
||||||
font-size: 0.6rem;
|
font-size: 0.6rem;
|
||||||
margin-left: 2px;
|
margin-left: 2px;
|
||||||
@@ -10883,6 +10977,13 @@
|
|||||||
color: #8a8070;
|
color: #8a8070;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cal-popover-label--row {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.cal-popover-actions {
|
.cal-popover-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|||||||
+518
-358
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user