@acms-subir/core 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/cli/preview.mjs +8 -4
  3. package/cli/sections.mjs +4 -2
  4. package/docs/mobile.md +1 -1
  5. package/docs/theme-authoring.md +19 -3
  6. package/integration.mjs +21 -4
  7. package/package.json +14 -12
  8. package/src/actions/index.ts +186 -3
  9. package/src/components/booking/BookingFlow.astro +6 -3
  10. package/src/components/calendar/BookingsHead.astro +51 -0
  11. package/src/components/calendar/CalendarBoard.tsx +618 -0
  12. package/src/components/calendar/MiniMonth.tsx +54 -0
  13. package/src/components/calendar/NewEventDialog.tsx +44 -0
  14. package/src/components/calendar/QuickAdd.tsx +263 -0
  15. package/src/components/calendar/RoomHolds.tsx +203 -0
  16. package/src/components/calendar/WhereItLands.tsx +51 -0
  17. package/src/components/editor/EventPanel.tsx +23 -2
  18. package/src/components/editor/PostEditor.tsx +32 -4
  19. package/src/components/import/ImportExport.tsx +4 -4
  20. package/src/components/import/runner.ts +20 -3
  21. package/src/layouts/Admin.astro +5 -6
  22. package/src/layouts/Site.astro +29 -2
  23. package/src/lib/backup/content.ts +6 -1
  24. package/src/lib/booking/agent.ts +3 -2
  25. package/src/lib/booking/approval.ts +70 -0
  26. package/src/lib/booking/holds.ts +120 -0
  27. package/src/lib/booking/service.ts +37 -3
  28. package/src/lib/calendar/agent-schema.ts +9 -1
  29. package/src/lib/calendar/agent.ts +16 -1
  30. package/src/lib/calendar/board.ts +171 -0
  31. package/src/lib/calendar/config.ts +3 -0
  32. package/src/lib/calendar/grid.ts +169 -0
  33. package/src/lib/calendar/migration.ts +16 -0
  34. package/src/lib/calendar/render.ts +9 -7
  35. package/src/lib/calendar/schedule.ts +166 -0
  36. package/src/lib/content-service.ts +5 -0
  37. package/src/lib/drafts.ts +1 -1
  38. package/src/lib/import/bundle.ts +2 -1
  39. package/src/lib/mobile/calendar.ts +10 -6
  40. package/src/lib/mobile/http.ts +6 -0
  41. package/src/lib/repo/migrations.ts +3 -0
  42. package/src/lib/repo/sql.ts +28 -16
  43. package/src/lib/repo/types.ts +2 -2
  44. package/src/lib/scheduler.ts +3 -1
  45. package/src/lib/schema.ts +49 -0
  46. package/src/lib/sections.ts +33 -8
  47. package/src/lib/site-chrome.ts +34 -0
  48. package/src/lib/theme-assets.ts +16 -0
  49. package/src/lib/theme.ts +17 -3
  50. package/src/middleware.ts +6 -2
  51. package/src/pages/admin/calendar/bookings.astro +32 -26
  52. package/src/pages/admin/calendar/calendars.astro +4 -263
  53. package/src/pages/admin/calendar/events/index.astro +41 -83
  54. package/src/pages/admin/calendar/index.astro +43 -0
  55. package/src/pages/admin/calendar/resources/[id].astro +27 -5
  56. package/src/pages/admin/calendar/resources/index.astro +16 -17
  57. package/src/pages/admin/calendar/resources/link.astro +52 -12
  58. package/src/pages/admin/calendar/settings.astro +458 -0
  59. package/src/pages/admin/integrations.astro +5 -11
  60. package/src/pages/book/[resource].ics.ts +7 -1
  61. package/src/styles/admin.css +48 -0
  62. package/src/styles/cards.css +5 -1
  63. package/src/styles/editor.css +36 -0
  64. package/src/styles/schedule.css +325 -0
  65. package/src/styles/sections.css +19 -16
  66. package/src/theme-sdk/fields.mjs +5 -1
  67. package/src/theme-sdk/index.ts +5 -1
  68. package/src/virtual.d.ts +3 -0
  69. package/src/pages/admin/calendar/index.ts +0 -4
@@ -0,0 +1,618 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent as ReactMouseEvent } from 'react';
2
+ import { actions } from 'astro:actions';
3
+ import {
4
+ addDays, addMonths, diffDays, layoutDay, layoutWeek, monthOf, monthWeeks, weekDays, type Iso,
5
+ } from '../../lib/calendar/grid';
6
+ import type { BoardCalendar, BoardData, BoardItem } from '../../lib/calendar/board';
7
+ import QuickAdd, { type QuickCalendar, type QuickStart } from './QuickAdd';
8
+ import { weekdayLetters } from './MiniMonth';
9
+ import '../../styles/schedule.css';
10
+
11
+ // The admin Schedule: a month (or week) grid of everything on the calendar —
12
+ // events, drafts, subscribed calendars, bookings. Click a day to add, drag
13
+ // an event to move it, click a day number for that day's list. Data comes
14
+ // from actions.calendar.range; this file only does UI and layout.
15
+
16
+ type View = 'month' | 'week';
17
+ type MoveUndo = { kind: 'series'; id: string; days: number } | { kind: 'split'; id: string; seriesId: string; occurrence: string };
18
+
19
+ interface Props {
20
+ initial: BoardData;
21
+ view: View;
22
+ anchor: Iso; // any day in the shown month/week
23
+ today: Iso;
24
+ weekStart: 0 | 1;
25
+ locale: string;
26
+ defaultCalendarId: string;
27
+ bookings: boolean;
28
+ listHref: string;
29
+ }
30
+
31
+ const LINES = 4; // stacked items per day cell on desktop
32
+ const HOUR = 44; // px per hour in week view
33
+ const HIDDEN_KEY = 'acms:schedule:hidden';
34
+ const BOOKINGS = '__bookings';
35
+ const NONE = '__none';
36
+
37
+ const utc = (d: Iso) => new Date(Date.UTC(+d.slice(0, 4), +d.slice(5, 7) - 1, +d.slice(8, 10)));
38
+ const rangeOf = (view: View, anchor: Iso, weekStart: 0 | 1): [Iso, Iso] => {
39
+ if (view === 'week') { const w = weekDays(anchor, weekStart); return [w[0], w[6]]; }
40
+ const weeks = monthWeeks(monthOf(anchor), weekStart);
41
+ return [weeks[0][0], weeks[weeks.length - 1][6]];
42
+ };
43
+ const keyOf = (r: [Iso, Iso]) => `${r[0]}|${r[1]}`;
44
+ const filterKey = (it: BoardItem) => (it.kind === 'booking' || it.kind === 'block' ? BOOKINGS : it.calendarId || NONE);
45
+ const editable = (it: BoardItem) => it.kind === 'event';
46
+
47
+ export default function CalendarBoard(props: Props) {
48
+ const { today, weekStart, locale } = props;
49
+ const [view, setView] = useState<View>(props.view);
50
+ const [anchor, setAnchor] = useState<Iso>(props.anchor);
51
+ const cache = useRef(new Map<string, BoardData>([[keyOf([props.initial.from, props.initial.to]), props.initial]]));
52
+ const [data, setData] = useState<BoardData>(props.initial);
53
+ const [loading, setLoading] = useState(false);
54
+ const [error, setError] = useState(props.initial.errors.join(' · '));
55
+ const [hidden, setHidden] = useState<Set<string>>(new Set());
56
+ const [panelDay, setPanelDay] = useState<Iso | null>(null);
57
+ const [quick, setQuick] = useState<{ start: QuickStart; anchor: DOMRect | null; mode: 'popover' | 'dialog' } | null>(null);
58
+ const [draft, setDraft] = useState<BoardItem | null>(null);
59
+ const [peek, setPeek] = useState<{ item: BoardItem; rect: DOMRect } | null>(null);
60
+ const [dragKey, setDragKey] = useState<string | null>(null);
61
+ const [dropDay, setDropDay] = useState<Iso | null>(null);
62
+ const [ask, setAsk] = useState<{ item: BoardItem; days: number; to: Iso } | null>(null);
63
+ const [toast, setToast] = useState<{ text: string; undo?: MoveUndo; error?: boolean } | null>(null);
64
+ const [menu, setMenu] = useState(false);
65
+ const [compact, setCompact] = useState(false);
66
+ const [selected, setSelected] = useState<Iso>(props.anchor);
67
+ const selection = useRef<{ from: Iso; to: Iso } | null>(null);
68
+ const [selecting, setSelecting] = useState<{ from: Iso; to: Iso } | null>(null);
69
+ const weekScroll = useRef<HTMLDivElement>(null);
70
+ const root = useRef<HTMLDivElement>(null);
71
+
72
+ // ---- data ----------------------------------------------------------------
73
+ const range = useMemo(() => rangeOf(view, anchor, weekStart), [view, anchor, weekStart]);
74
+ const load = useCallback(async (r: [Iso, Iso], force = false) => {
75
+ const k = keyOf(r);
76
+ if (!force && cache.current.has(k)) return cache.current.get(k)!;
77
+ const res = await actions.calendar.range({ from: r[0], to: r[1] });
78
+ if (res.error || !res.data) throw new Error(res.error?.message ?? 'Couldn’t load the calendar.');
79
+ cache.current.set(k, res.data as BoardData);
80
+ return res.data as BoardData;
81
+ }, []);
82
+ const refresh = useCallback(async (force = true) => {
83
+ setLoading(true);
84
+ try {
85
+ if (force) cache.current.clear();
86
+ const d = await load(range, force);
87
+ setData(d);
88
+ setError(d.errors.join(' · '));
89
+ } catch (e) { setError(e instanceof Error ? e.message : String(e)); }
90
+ setLoading(false);
91
+ }, [load, range]);
92
+ useEffect(() => {
93
+ const k = keyOf(range);
94
+ if (k === keyOf([data.from, data.to])) return;
95
+ void refresh(false);
96
+ }, [range]);
97
+ // neighbours in the background so ‹ › feels instant
98
+ useEffect(() => {
99
+ const step = view === 'week' ? 7 : 0;
100
+ const next = view === 'week' ? addDays(anchor, step) : `${addMonths(monthOf(anchor), 1)}-01`;
101
+ const prev = view === 'week' ? addDays(anchor, -step) : `${addMonths(monthOf(anchor), -1)}-01`;
102
+ const t = setTimeout(() => { load(rangeOf(view, next, weekStart)).catch(() => {}); load(rangeOf(view, prev, weekStart)).catch(() => {}); }, 400);
103
+ return () => clearTimeout(t);
104
+ }, [data]);
105
+
106
+ // ---- url, storage, media ----------------------------------------------------
107
+ useEffect(() => {
108
+ const u = new URL(location.href);
109
+ u.searchParams.delete('m'); u.searchParams.delete('d'); u.searchParams.delete('view');
110
+ if (view === 'week') { u.searchParams.set('view', 'week'); u.searchParams.set('d', anchor); }
111
+ else if (monthOf(anchor) !== monthOf(today)) u.searchParams.set('m', monthOf(anchor));
112
+ history.replaceState(null, '', u.pathname + (u.search ? u.search : ''));
113
+ }, [view, anchor]);
114
+ useEffect(() => {
115
+ try { setHidden(new Set(JSON.parse(localStorage.getItem(HIDDEN_KEY) ?? '[]'))); } catch { /* fresh */ }
116
+ // compact = the board itself is narrow (phones, or a tablet with the
117
+ // sidebar open) — a 7-column grid under ~620px only fits 3-letter titles
118
+ const el = root.current;
119
+ if (!el) return;
120
+ const ro = new ResizeObserver(([e]) => setCompact(e.contentRect.width < 620));
121
+ ro.observe(el);
122
+ return () => ro.disconnect();
123
+ }, []);
124
+ const toggle = (k: string) => setHidden((h) => {
125
+ const n = new Set(h);
126
+ n.has(k) ? n.delete(k) : n.add(k);
127
+ try { localStorage.setItem(HIDDEN_KEY, JSON.stringify([...n])); } catch { /* private mode */ }
128
+ return n;
129
+ });
130
+ useEffect(() => {
131
+ if (view === 'week' && weekScroll.current) weekScroll.current.scrollTop = 7 * HOUR - 10;
132
+ }, [view]);
133
+
134
+ // ---- navigation ----------------------------------------------------------
135
+ const go = (dir: -1 | 1) => {
136
+ setPanelDay(null); setPeek(null);
137
+ if (view === 'week') { setAnchor((a) => addDays(a, dir * 7)); setSelected((s) => addDays(s, dir * 7)); }
138
+ else setAnchor((a) => { const m = addMonths(monthOf(a), dir); setSelected(`${m}-01`); return `${m}-01`; });
139
+ };
140
+ const goToday = () => { setAnchor(today); setSelected(today); setPanelDay(null); };
141
+ const switchView = (v: View) => { setView(v); setPanelDay(null); setPeek(null); setAnchor(selected); };
142
+
143
+ const openNew = (day?: Iso) => { setPeek(null); setQuick({ start: { day: day ?? (selected || today) }, anchor: null, mode: 'dialog' }); };
144
+ useEffect(() => {
145
+ const onKey = (e: KeyboardEvent) => {
146
+ const t = e.target as HTMLElement;
147
+ if (t.closest('input, textarea, select, [contenteditable], dialog[open]') || e.metaKey || e.ctrlKey || e.altKey) return;
148
+ if (e.key === 'Escape') { setQuick(null); setPeek(null); setPanelDay(null); setMenu(false); setAsk(null); }
149
+ if (quick || ask) return;
150
+ if (e.key === 'ArrowLeft') go(-1);
151
+ else if (e.key === 'ArrowRight') go(1);
152
+ else if (e.key === 't' || e.key === 'T') goToday();
153
+ else if (e.key === 'n' || e.key === 'N') { e.preventDefault(); openNew(); }
154
+ else if (e.key === 'm' || e.key === 'M') switchView('month');
155
+ else if (e.key === 'w' || e.key === 'W') switchView('week');
156
+ };
157
+ // header buttons outside this island (e.g. empty states) can open quick add
158
+ const onClick = (e: MouseEvent) => {
159
+ const el = (e.target as HTMLElement).closest('[data-new-event]');
160
+ if (el) { e.preventDefault(); openNew(); }
161
+ };
162
+ addEventListener('keydown', onKey);
163
+ document.addEventListener('click', onClick);
164
+ return () => { removeEventListener('keydown', onKey); document.removeEventListener('click', onClick); };
165
+ });
166
+
167
+ // ---- items ---------------------------------------------------------------
168
+ const items = useMemo(() => {
169
+ const list = data.items.filter((it) => !hidden.has(filterKey(it)));
170
+ return draft ? [...list, draft] : list;
171
+ }, [data, hidden, draft]);
172
+ const dayItems = (d: Iso) => items.filter((it) => it.startDay <= d && it.endDay >= d && it !== draft);
173
+ const calendars: QuickCalendar[] = data.calendars.filter((c) => c.kind === 'native').map(({ id, name, color }) => ({ id, name, color }));
174
+ const hasUncategorized = data.items.some((it) => it.kind === 'event' && !it.calendarId);
175
+ const hasBookings = props.bookings && data.items.some((it) => it.kind === 'booking' || it.kind === 'block');
176
+
177
+ const showDraft = (d: Parameters<NonNullable<Parameters<typeof QuickAdd>[0]['onDraft']>>[0]) => {
178
+ if (!d) { setDraft(null); return; }
179
+ const color = calendars.find((c) => c.id === d.calendarId)?.color ?? '#8a8378';
180
+ setDraft({
181
+ key: '__draft', kind: 'event', id: '', title: d.title, startDay: d.day, endDay: d.endDay, allDay: d.allDay || d.endDay > d.day,
182
+ startMin: d.startMin, endMin: d.endMin, time: d.allDay ? '' : shortTime(d.startMin), range: '', color, calendarId: d.calendarId,
183
+ calendarName: '', status: 'draft', recurring: false, repeat: '', rooms: [], roomsHeld: false, occurrence: '', cancelled: false, location: '', edited: false, url: '',
184
+ });
185
+ };
186
+
187
+ // ---- moving ----------------------------------------------------------------
188
+ const dayLabel = (d: Iso, o: Intl.DateTimeFormatOptions = { weekday: 'short', month: 'short', day: 'numeric' }) =>
189
+ new Intl.DateTimeFormat(locale, { ...o, timeZone: 'UTC' }).format(utc(d));
190
+ async function move(item: BoardItem, days: number, mode: 'series' | 'one', to: Iso) {
191
+ setAsk(null);
192
+ const r = await actions.events.move({ id: item.id, days, mode, occurrence: item.occurrence });
193
+ if (r.error || !r.data) { setToast({ text: r.error?.message ?? 'Couldn’t move the event.', error: true }); return; }
194
+ setToast({ text: `Moved “${item.title}” to ${dayLabel(to)}`, undo: r.data.undo as MoveUndo });
195
+ await refresh();
196
+ }
197
+ async function undo(u: MoveUndo) {
198
+ setToast(null);
199
+ const r = await actions.events.undoMove(u);
200
+ if (r.error) { setToast({ text: r.error.message, error: true }); return; }
201
+ setToast({ text: 'Move undone' });
202
+ await refresh();
203
+ }
204
+ useEffect(() => {
205
+ if (!toast) return;
206
+ const t = setTimeout(() => setToast(null), toast.undo ? 8000 : 4000);
207
+ return () => clearTimeout(t);
208
+ }, [toast]);
209
+
210
+ const onDragStart = (e: DragEvent, it: BoardItem) => {
211
+ if (!editable(it)) { e.preventDefault(); return; }
212
+ e.dataTransfer.setData('text/plain', it.key);
213
+ e.dataTransfer.effectAllowed = 'move';
214
+ setPeek(null);
215
+ setDragKey(it.key);
216
+ };
217
+ const onDrop = (e: DragEvent, day: Iso) => {
218
+ e.preventDefault();
219
+ const it = data.items.find((x) => x.key === (dragKey ?? e.dataTransfer.getData('text/plain')));
220
+ setDragKey(null); setDropDay(null);
221
+ if (!it) return;
222
+ const days = diffDays(it.startDay, day);
223
+ if (!days) return;
224
+ if (it.recurring) setAsk({ item: it, days, to: day });
225
+ else void move(it, days, 'series', day);
226
+ };
227
+ const dropProps = (day: Iso) => ({
228
+ onDragOver: (e: DragEvent) => { if (dragKey) { e.preventDefault(); if (dropDay !== day) setDropDay(day); } },
229
+ onDrop: (e: DragEvent) => onDrop(e, day),
230
+ });
231
+
232
+ // ---- actions on one item ---------------------------------------------------
233
+ async function duplicate(it: BoardItem) {
234
+ const r = await actions.events.duplicate({ id: it.id });
235
+ if (r.error || !r.data) { setToast({ text: r.error?.message ?? 'Couldn’t duplicate.', error: true }); return; }
236
+ location.href = `/admin/calendar/events/${r.data.id}`;
237
+ }
238
+ async function remove(it: BoardItem) {
239
+ if (!confirm(it.recurring ? `Delete “${it.title}” and every repeat?` : `Delete “${it.title}”?`)) return;
240
+ const r = await actions.events.delete({ id: it.id });
241
+ setPeek(null);
242
+ if (r.error) { setToast({ text: r.error.message, error: true }); return; }
243
+ setToast({ text: `Deleted “${it.title}”` });
244
+ await refresh();
245
+ }
246
+
247
+ // ---- click handling ---------------------------------------------------------
248
+ const startAdd = (e: ReactMouseEvent, start: QuickStart) => {
249
+ setPeek(null); setPanelDay(null);
250
+ const cell = (e.currentTarget as HTMLElement).getBoundingClientRect();
251
+ setQuick({ start, anchor: cell, mode: compact ? 'dialog' : 'popover' });
252
+ };
253
+ const openPeek = (e: ReactMouseEvent, it: BoardItem) => {
254
+ e.stopPropagation();
255
+ if (it === draft) return;
256
+ setPeek({ item: it, rect: (e.currentTarget as HTMLElement).getBoundingClientRect() });
257
+ };
258
+ // press on a day, drag across days, release → multi-day quick add
259
+ const onCellDown = (e: ReactMouseEvent, day: Iso) => {
260
+ if (e.button !== 0 || (e.target as HTMLElement).closest('.sch-chip, .sch-bar, .sch-more, .sch-num')) return;
261
+ selection.current = { from: day, to: day };
262
+ setSelecting({ from: day, to: day });
263
+ };
264
+ const onCellEnter = (day: Iso) => {
265
+ if (!selection.current) return;
266
+ selection.current = { ...selection.current, to: day };
267
+ setSelecting({ ...selection.current });
268
+ };
269
+ const onCellUp = (e: ReactMouseEvent, day: Iso) => {
270
+ const sel = selection.current;
271
+ selection.current = null;
272
+ setSelecting(null);
273
+ if (!sel || (e.target as HTMLElement).closest('.sch-chip, .sch-bar, .sch-more, .sch-num')) return;
274
+ const [a, b] = sel.from <= day ? [sel.from, day] : [day, sel.from];
275
+ startAdd(e, a === b ? { day: a } : { day: a, endDay: b, allDay: true });
276
+ };
277
+ useEffect(() => {
278
+ const up = () => { if (selection.current) { selection.current = null; setSelecting(null); } };
279
+ addEventListener('mouseup', up);
280
+ return () => removeEventListener('mouseup', up);
281
+ }, []);
282
+ const inSelection = (d: Iso) => !!selecting && d >= (selecting.from < selecting.to ? selecting.from : selecting.to) && d <= (selecting.from < selecting.to ? selecting.to : selecting.from);
283
+
284
+ // ---- rendering helpers -------------------------------------------------------
285
+ const month = monthOf(anchor);
286
+ const title = view === 'month'
287
+ ? new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric', timeZone: 'UTC' }).formatToParts(utc(`${month}-01`))
288
+ : null;
289
+ const weekTitle = () => {
290
+ const w = weekDays(anchor, weekStart);
291
+ const f = new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
292
+ try { return f.formatRange(utc(w[0]), utc(w[6])); } catch { return `${f.format(utc(w[0]))} – ${f.format(utc(w[6]))}`; }
293
+ };
294
+ const chipClass = (it: BoardItem) => ['sch-chip',
295
+ it.status === 'draft' || it.status === 'scheduled' ? 'is-draft' : '',
296
+ it.kind === 'booking' || it.kind === 'block' ? 'is-booking' : '',
297
+ it.status === 'pending' ? 'is-pending' : '',
298
+ it.kind === 'feed' ? 'is-feed' : '',
299
+ it.cancelled ? 'is-cancelled' : '',
300
+ it === draft ? 'is-preview' : '',
301
+ it.key === dragKey ? 'is-dragging' : '',
302
+ editable(it) ? 'can-drag' : ''].filter(Boolean).join(' ');
303
+ const statusWord = (it: BoardItem) => (it.status === 'draft' ? 'Draft' : it.status === 'scheduled' ? 'Scheduled' : it.status === 'pending' ? 'Needs approval' : '');
304
+ const chip = (it: BoardItem, extra: Record<string, unknown> = {}) => (
305
+ <button key={it.key} type="button" className={chipClass(it)} style={{ ['--c' as string]: it.color }} draggable={editable(it)}
306
+ onDragStart={(e) => onDragStart(e, it)} onDragEnd={() => { setDragKey(null); setDropDay(null); }}
307
+ onClick={(e) => openPeek(e, it)} title={`${it.range}${it.time ? '' : ''} · ${it.title}${statusWord(it) ? ` · ${statusWord(it)}` : ''}`} {...extra}>
308
+ <i />
309
+ {it.time && <span className="t">{it.time}</span>}
310
+ <span className="n">{it.title}</span>
311
+ </button>
312
+ );
313
+ const bar = (b: ReturnType<typeof layoutWeek<BoardItem>>['bars'][number], row: number) => {
314
+ const it = b.item;
315
+ return (
316
+ <button key={`${it.key}-bar`} type="button" draggable={editable(it)}
317
+ className={[chipClass(it).replace('sch-chip', 'sch-bar'), b.continuesLeft ? 'cl' : '', b.continuesRight ? 'cr' : ''].join(' ')}
318
+ style={{ ['--c' as string]: it.color, gridColumn: `${b.col + 1} / span ${b.span}`, gridRow: row + b.lane }}
319
+ onDragStart={(e) => onDragStart(e, it)} onDragEnd={() => { setDragKey(null); setDropDay(null); }}
320
+ onClick={(e) => openPeek(e, it)} title={`${it.range} · ${it.title}`}>
321
+ {b.continuesLeft && <span aria-hidden="true">↳ </span>}{it.title}{statusWord(it) && <em> · {statusWord(it)}</em>}
322
+ </button>
323
+ );
324
+ };
325
+
326
+ // ---- legend ---------------------------------------------------------------
327
+ const legend: { key: string; name: string; color: string; feed?: boolean; booking?: boolean }[] = [
328
+ ...data.calendars.map((c: BoardCalendar) => ({ key: c.id, name: c.name, color: c.color, feed: c.kind === 'feed' })),
329
+ ...(hasUncategorized ? [{ key: NONE, name: 'No calendar', color: '#8a8378' }] : []),
330
+ ...(hasBookings ? [{ key: BOOKINGS, name: 'Bookings', color: '#8e919a', booking: true }] : []),
331
+ ];
332
+
333
+ // ---- month ---------------------------------------------------------------
334
+ const weeks = monthWeeks(month, weekStart);
335
+ const dows = weekdayLetters(locale, weekStart, 'short');
336
+ const monthGrid = (
337
+ <div className="sch-grid" role="grid" aria-label="Month">
338
+ <div className="sch-dows" role="row">{dows.map((d, i) => <div key={i} role="columnheader">{d}</div>)}</div>
339
+ {weeks.map((days) => {
340
+ const l = layoutWeek(days, items, LINES);
341
+ return (
342
+ <div key={days[0]} className="sch-week" role="row" style={{ gridTemplateRows: `auto repeat(${LINES}, minmax(22px, auto)) 1fr` }}>
343
+ {days.map((d, c) => (
344
+ <div key={d} role="gridcell" aria-label={dayLabel(d, { weekday: 'long', month: 'long', day: 'numeric' })}
345
+ className={['sch-cell', monthOf(d) !== month ? 'out' : '', d === today ? 'today' : '', d === panelDay ? 'sel' : '', d === dropDay ? 'drop' : '', inSelection(d) ? 'selecting' : ''].filter(Boolean).join(' ')}
346
+ style={{ gridColumn: c + 1 }}
347
+ onMouseDown={(e) => onCellDown(e, d)} onMouseEnter={() => onCellEnter(d)} onMouseUp={(e) => onCellUp(e, d)} {...dropProps(d)} />
348
+ ))}
349
+ {days.map((d, c) => (
350
+ <button key={`n${d}`} type="button" className={`sch-num ${monthOf(d) !== month ? 'out' : ''} ${d === today ? 'today' : ''}`} style={{ gridColumn: c + 1 }}
351
+ onClick={() => { setPeek(null); setPanelDay(d === panelDay ? null : d); setSelected(d); }} aria-label={`Show ${dayLabel(d, { weekday: 'long', month: 'long', day: 'numeric' })}`}>
352
+ <b>{+d.slice(8) === 1 ? dayLabel(d, { month: 'short', day: 'numeric' }) : +d.slice(8)}</b>
353
+ </button>
354
+ ))}
355
+ {l.bars.map((b) => bar(b, 2))}
356
+ {days.map((d, c) => l.chips[c].map(({ item, line }) => chip(item, { style: { ['--c' as string]: item.color, gridColumn: c + 1, gridRow: 2 + line } })))}
357
+ {days.map((d, c) => l.hidden[c] > 0 && (
358
+ <button key={`m${d}`} type="button" className="sch-more" style={{ gridColumn: c + 1, gridRow: 1 + LINES }}
359
+ onClick={() => { setPeek(null); setPanelDay(d); setSelected(d); }}>+{l.hidden[c]} more</button>
360
+ ))}
361
+ </div>
362
+ );
363
+ })}
364
+ </div>
365
+ );
366
+
367
+ // ---- week ----------------------------------------------------------------
368
+ const wdays = weekDays(anchor, weekStart);
369
+ const allDayLayout = layoutWeek(wdays, items.filter((it) => it.allDay || it.endDay > it.startDay), 3);
370
+ const nowMin = (() => { const n = new Date(); return n.getHours() * 60 + n.getMinutes(); })();
371
+ const weekGrid = (
372
+ <div className="sch-wk">
373
+ <div className="sch-wk-head">
374
+ <div className="sch-wk-gutter" />
375
+ {wdays.map((d) => (
376
+ <button key={d} type="button" className={`sch-wk-day ${d === today ? 'today' : ''}`} onClick={() => { setPanelDay(d); setSelected(d); }}>
377
+ <span>{dayLabel(d, { weekday: 'short' })}</span><b>{+d.slice(8)}</b>
378
+ </button>
379
+ ))}
380
+ </div>
381
+ <div className="sch-wk-allday" style={{ gridTemplateRows: `repeat(${Math.max(1, Math.min(3, allDayLayout.bars.reduce((n, b) => Math.max(n, b.lane + 1), 0)))}, 24px)` }}>
382
+ <div className="sch-wk-gutter">All day</div>
383
+ <div className="sch-wk-allday-grid">
384
+ {wdays.map((d, c) => <div key={d} className={`sch-wk-adcell ${d === dropDay ? 'drop' : ''}`} style={{ gridColumn: c + 1 }} {...dropProps(d)}
385
+ onClick={(e) => startAdd(e, { day: d, allDay: true })} />)}
386
+ {allDayLayout.bars.map((b) => bar(b, 1))}
387
+ {wdays.map((d, c) => allDayLayout.hidden[c] > 0 && <button key={`m${d}`} type="button" className="sch-more" style={{ gridColumn: c + 1, gridRow: 3 }} onClick={() => setPanelDay(d)}>+{allDayLayout.hidden[c]}</button>)}
388
+ </div>
389
+ </div>
390
+ <div className="sch-wk-body" ref={weekScroll}>
391
+ <div className="sch-wk-hours">
392
+ {Array.from({ length: 24 }, (_, h) => <span key={h} style={{ top: h * HOUR }}>{h === 0 ? '' : shortTime(h * 60)}</span>)}
393
+ </div>
394
+ <div className="sch-wk-cols" style={{ height: 24 * HOUR }}>
395
+ {wdays.map((d) => (
396
+ <div key={d} className={`sch-wk-col ${d === today ? 'today' : ''} ${d === dropDay ? 'drop' : ''}`} {...dropProps(d)}
397
+ onClick={(e) => {
398
+ if ((e.target as HTMLElement).closest('.sch-ev')) return;
399
+ const y = e.clientY - (e.currentTarget as HTMLElement).getBoundingClientRect().top;
400
+ startAdd(e, { day: d, startMin: Math.max(0, Math.min(23 * 60 + 30, Math.floor(y / HOUR * 2) * 30)) });
401
+ }}>
402
+ {layoutDay(d, items).map(({ item, top, height, col, cols }) => (
403
+ <button key={item.key} type="button" className={chipClass(item).replace('sch-chip', 'sch-ev')} draggable={editable(item)}
404
+ style={{ ['--c' as string]: item.color, top: top / 60 * HOUR, height: Math.max(18, height / 60 * HOUR - 2), left: `calc(${(col / cols) * 100}% + 2px)`, width: `calc(${100 / cols}% - 4px)` }}
405
+ onDragStart={(e) => onDragStart(e, item)} onDragEnd={() => { setDragKey(null); setDropDay(null); }}
406
+ onClick={(e) => openPeek(e, item)}>
407
+ <b>{item.title}</b><span>{item.range || shortTime(item.startMin)}</span>
408
+ </button>
409
+ ))}
410
+ {d === today && <div className="sch-now" style={{ top: nowMin / 60 * HOUR }} />}
411
+ </div>
412
+ ))}
413
+ </div>
414
+ </div>
415
+ </div>
416
+ );
417
+
418
+ // ---- phone ---------------------------------------------------------------
419
+ const selItems = dayItems(selected);
420
+ const phone = (
421
+ <div className="sch-phone"
422
+ onTouchStart={(e) => { (e.currentTarget as HTMLElement).dataset.x = String(e.touches[0].clientX); }}
423
+ onTouchEnd={(e) => { const x0 = Number((e.currentTarget as HTMLElement).dataset.x); const dx = e.changedTouches[0].clientX - x0; if (Math.abs(dx) > 60) go(dx < 0 ? 1 : -1); }}>
424
+ <div className="sch-pgrid">
425
+ {weekdayLetters(locale, weekStart).map((d, i) => <span key={i} className="sch-pdow">{d}</span>)}
426
+ {weeks.flat().map((d) => {
427
+ const colors = [...new Set(dayItems(d).map((it) => it.color))].slice(0, 3);
428
+ return (
429
+ <button key={d} type="button" onClick={() => setSelected(d)} aria-pressed={d === selected}
430
+ className={['sch-pday', monthOf(d) !== month ? 'out' : '', d === today ? 'today' : '', d === selected ? 'sel' : ''].filter(Boolean).join(' ')}>
431
+ {+d.slice(8)}
432
+ {colors.length > 0 && <span className="sch-pdots">{colors.map((c) => <i key={c} style={{ background: c }} />)}</span>}
433
+ </button>
434
+ );
435
+ })}
436
+ </div>
437
+ <div className="sch-plist">
438
+ <div className="sch-plist-head">{dayLabel(selected, { weekday: 'long', month: 'long', day: 'numeric' })}</div>
439
+ {selItems.length === 0 && <p className="sch-empty-day">Nothing scheduled. Tap + to add something.</p>}
440
+ {selItems.map((it) => <DayRow key={it.key} it={it} status={statusWord(it)} onOpen={(e) => openPeek(e, it)} />)}
441
+ </div>
442
+ <button type="button" className="sch-fab" aria-label="Add event" onClick={() => openNew(selected)}>+</button>
443
+ </div>
444
+ );
445
+
446
+ // ---- peek ----------------------------------------------------------------
447
+ const peekBox = peek && (() => {
448
+ const it = peek.item;
449
+ const style = compact ? undefined : (() => {
450
+ const w = 330;
451
+ let left = peek.rect.left;
452
+ if (left + w > innerWidth - 12) left = innerWidth - w - 12;
453
+ const below = peek.rect.bottom + 6;
454
+ const top = below + 220 > innerHeight ? Math.max(12, peek.rect.top - 226) : below;
455
+ return { left: Math.max(12, left), top };
456
+ })();
457
+ return (
458
+ <div className="sch-peek-layer" onMouseDown={(e) => { if (e.target === e.currentTarget) setPeek(null); }}>
459
+ <div className="sch-peek" style={style} role="dialog" aria-label={it.title}>
460
+ <div className="sch-peek-head">
461
+ <i style={{ background: it.color }} className={it.kind === 'booking' || it.kind === 'block' || it.status === 'draft' ? 'hollow' : ''} />
462
+ <b>{it.title}</b>
463
+ <button type="button" className="sch-x" aria-label="Close" onClick={() => setPeek(null)}>✕</button>
464
+ </div>
465
+ <p className="sch-peek-when">{dayLabel(it.startDay, { weekday: 'long', month: 'long', day: 'numeric' })}{it.endDay !== it.startDay ? ` – ${dayLabel(it.endDay, { month: 'long', day: 'numeric' })}` : ''} · {it.range}</p>
466
+ <ul className="sch-peek-meta">
467
+ {it.calendarName && <li>{it.kind === 'booking' || it.kind === 'block' ? 'Room or person' : 'Calendar'}: {it.calendarName}{it.kind === 'feed' ? ' (subscribed, read-only)' : ''}</li>}
468
+ {it.location && it.kind === 'event' && <li>{it.location}</li>}
469
+ {it.recurring && <li>{it.repeat || 'Repeats'}</li>}
470
+ {it.rooms.length > 0 && <li>{it.roomsHeld ? 'Holds' : 'Will hold once published:'} {it.rooms.join(', ')}</li>}
471
+ {statusWord(it) && <li>{statusWord(it)}{it.edited ? ' · has unpublished changes' : ''}</li>}
472
+ {!statusWord(it) && it.edited && <li>Has unpublished changes</li>}
473
+ {it.cancelled && <li>Cancelled</li>}
474
+ </ul>
475
+ <div className="sch-peek-actions">
476
+ {it.kind === 'event' && <>
477
+ <a className="btn sm primary" href={`/admin/calendar/events/${it.id}`}>Edit</a>
478
+ <button type="button" className="btn sm" onClick={() => duplicate(it)}>Duplicate</button>
479
+ {it.url && <a className="btn sm ghost" href={it.url} target="_blank" rel="noopener">View ↗</a>}
480
+ <button type="button" className="btn sm ghost danger" onClick={() => remove(it)}>Delete</button>
481
+ </>}
482
+ {(it.kind === 'booking' || it.kind === 'block') && <a className="btn sm" href={`/admin/calendar/bookings?b=${it.id}`}>Open in Bookings</a>}
483
+ {it.kind === 'feed' && <a className="btn sm" href={`/admin/calendar/events?cal=${it.calendarId}`}>Make an editable copy…</a>}
484
+ </div>
485
+ </div>
486
+ </div>
487
+ );
488
+ })();
489
+
490
+ // ---- day panel -------------------------------------------------------------
491
+ const panel = panelDay && (
492
+ <aside className="sch-panel" aria-label={dayLabel(panelDay, { weekday: 'long', month: 'long', day: 'numeric' })}>
493
+ <div className="sch-panel-head">
494
+ <div>
495
+ <b>{dayLabel(panelDay, { weekday: 'long', month: 'long', day: 'numeric' })}</b>
496
+ <small>{dayItems(panelDay).length || 'No'} item{dayItems(panelDay).length === 1 ? '' : 's'}</small>
497
+ </div>
498
+ <span>
499
+ <button type="button" className="btn sm ghost" aria-label="Previous day" onClick={() => setPanelDay(addDays(panelDay, -1))}>‹</button>
500
+ <button type="button" className="btn sm ghost" aria-label="Next day" onClick={() => setPanelDay(addDays(panelDay, 1))}>›</button>
501
+ <button type="button" className="btn sm ghost" aria-label="Close" onClick={() => setPanelDay(null)}>✕</button>
502
+ </span>
503
+ </div>
504
+ <div className="sch-panel-list">
505
+ {dayItems(panelDay).map((it) => <DayRow key={it.key} it={it} status={statusWord(it)} onOpen={(e) => openPeek(e, it)} />)}
506
+ </div>
507
+ <button type="button" className="sch-panel-add" onClick={(e) => startAdd(e, { day: panelDay })}>+ Add event on {dayLabel(panelDay, { month: 'short', day: 'numeric' })}</button>
508
+ </aside>
509
+ );
510
+
511
+ return (
512
+ <div ref={root} className={`sch ${loading ? 'is-loading' : ''} ${compact ? 'is-compact' : ''}`}>
513
+ <div className="sch-bar1">
514
+ <h1 className="sch-title">
515
+ {view === 'month' && title
516
+ ? title.map((p, i) => (p.type === 'year' ? <span key={i} className="yr">{p.value}</span> : <span key={i}>{p.value}</span>))
517
+ : weekTitle()}
518
+ </h1>
519
+ <div className="sch-nav">
520
+ <button type="button" className="btn ico" aria-label={view === 'week' ? 'Previous week' : 'Previous month'} onClick={() => go(-1)}>‹</button>
521
+ <button type="button" className="btn ico" aria-label={view === 'week' ? 'Next week' : 'Next month'} onClick={() => go(1)}>›</button>
522
+ <button type="button" className="btn" onClick={goToday}>Today</button>
523
+ </div>
524
+ <div className="sch-sp" />
525
+ <nav className="seg sch-views" aria-label="View">
526
+ <button type="button" className={view === 'month' ? 'on' : ''} onClick={() => switchView('month')}>Month</button>
527
+ {!compact && <button type="button" className={view === 'week' ? 'on' : ''} onClick={() => switchView('week')}>Week</button>}
528
+ <a href={props.listHref}>List</a>
529
+ </nav>
530
+ <a className="btn ico" href="/admin/calendar/settings" title="Calendar settings" aria-label="Calendar settings">
531
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" /></svg>
532
+ </a>
533
+ <div className="sch-new">
534
+ <button type="button" className="btn primary" onClick={() => openNew()}>New event</button>
535
+ <button type="button" className="btn primary sch-caret" aria-label="More ways to add" aria-expanded={menu} onClick={() => setMenu((m) => !m)}>▾</button>
536
+ {menu && (
537
+ <div className="sch-menu" role="menu" onMouseLeave={() => setMenu(false)}>
538
+ <button type="button" role="menuitem" onClick={() => { setMenu(false); openNew(); }}>Event <kbd>N</kbd></button>
539
+ {props.bookings && <a role="menuitem" href="/admin/calendar/bookings?block=1">Block time <small>room closed, time off</small></a>}
540
+ <a role="menuitem" href={`${props.listHref}?import=1`}>Import events… <small>.ics or .csv</small></a>
541
+ </div>
542
+ )}
543
+ </div>
544
+ </div>
545
+
546
+ {legend.length > 0 && (
547
+ <div className="sch-legend" aria-label="Show or hide calendars">
548
+ {legend.map((c) => (
549
+ <button key={c.key} type="button" aria-pressed={!hidden.has(c.key)} className={`${hidden.has(c.key) ? 'off' : ''} ${c.booking ? 'bk' : ''}`} style={{ ['--c' as string]: c.color }} onClick={() => toggle(c.key)}>
550
+ <i />{c.name}{c.feed && <small>subscribed</small>}
551
+ </button>
552
+ ))}
553
+ <a className="sch-legend-edit" href="/admin/calendar/settings#calendars">Edit calendars</a>
554
+ </div>
555
+ )}
556
+
557
+ {error && <p className="notice warn sch-error">{error}</p>}
558
+
559
+ <div className="sch-stage">
560
+ {compact ? phone : view === 'week' ? weekGrid : monthGrid}
561
+ {!compact && panel}
562
+ </div>
563
+
564
+ {compact && panelDay && <div className="sch-sheet-layer" onClick={() => setPanelDay(null)}>{panel}</div>}
565
+ {peekBox}
566
+ {quick && (
567
+ <QuickAdd start={quick.start} mode={quick.mode} anchor={quick.anchor} calendars={calendars} defaultCalendarId={props.defaultCalendarId}
568
+ weekStart={weekStart} locale={locale} today={today} items={quick.mode === 'dialog' && view === 'week' ? undefined : data.items}
569
+ onDraft={quick.mode === 'popover' ? showDraft : undefined}
570
+ onClose={() => { setQuick(null); setDraft(null); }}
571
+ onSaved={(_id, status) => {
572
+ setQuick(null); setDraft(null);
573
+ setToast({ text: status === 'published' ? 'Event published' : 'Draft saved — it shows dashed until you publish it' });
574
+ void refresh();
575
+ }} />
576
+ )}
577
+ {ask && (
578
+ <div className="sch-peek-layer center" onMouseDown={(e) => { if (e.target === e.currentTarget) setAsk(null); }}>
579
+ <div className="sch-ask" role="alertdialog" aria-label={`Move ${ask.item.title}`}>
580
+ <b>Move “{ask.item.title}”?</b>
581
+ <p>This event repeats. Move only {dayLabel(ask.item.startDay, { weekday: 'long', month: 'short', day: 'numeric' })}, or every date in the series?</p>
582
+ <div className="sch-ask-actions">
583
+ <button type="button" className="btn primary" onClick={() => move(ask.item, ask.days, 'one', ask.to)}>Only this date</button>
584
+ <button type="button" className="btn" onClick={() => move(ask.item, ask.days, 'series', ask.to)}>Every date</button>
585
+ <button type="button" className="btn ghost" onClick={() => setAsk(null)}>Cancel</button>
586
+ </div>
587
+ </div>
588
+ </div>
589
+ )}
590
+ {toast && (
591
+ <div className={`sch-toast ${toast.error ? 'err' : ''}`} role="status">
592
+ {toast.text}
593
+ {toast.undo && <button type="button" onClick={() => undo(toast.undo!)}>Undo</button>}
594
+ </div>
595
+ )}
596
+ </div>
597
+ );
598
+ }
599
+
600
+ function shortTime(min: number) {
601
+ const hh = Math.floor(min / 60), mm = min % 60;
602
+ return `${hh % 12 || 12}${mm ? `:${String(mm).padStart(2, '0')}` : ''}${hh < 12 ? 'a' : 'p'}`;
603
+ }
604
+
605
+ function DayRow({ it, status, onOpen }: { it: BoardItem; status: string; onOpen: (e: ReactMouseEvent) => void }) {
606
+ const hollow = it.kind === 'booking' || it.kind === 'block' || it.status === 'draft' || it.status === 'scheduled';
607
+ return (
608
+ <button type="button" className={`sch-row ${hollow ? 'hollow' : ''} ${it.cancelled ? 'is-cancelled' : ''}`} style={{ ['--c' as string]: it.color }} onClick={onOpen}>
609
+ <i />
610
+ <span className="sch-row-main">
611
+ <b>{it.title}</b>
612
+ <small>{it.range}{it.calendarName ? ` · ${it.calendarName}` : ''}{it.recurring ? ` · ${it.repeat || 'Repeats'}` : ''}</small>
613
+ </span>
614
+ <span className="sch-row-st">{status || (it.kind === 'feed' ? 'Subscribed' : it.kind === 'block' ? 'Blocked' : it.kind === 'booking' ? 'Booking' : '')}</span>
615
+ </button>
616
+ );
617
+ }
618
+