@acms-subir/core 0.3.6 → 0.3.7

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 (106) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/integration.mjs +38 -2
  3. package/package.json +1 -1
  4. package/src/actions/index.ts +146 -2
  5. package/src/components/EventPage.astro +25 -5
  6. package/src/components/calendar/BookingsHead.astro +4 -3
  7. package/src/components/calendar/RoomHolds.tsx +13 -8
  8. package/src/components/editor/EventPanel.tsx +9 -383
  9. package/src/components/editor/LivePane.tsx +16 -3
  10. package/src/components/editor/PostEditor.tsx +59 -46
  11. package/src/components/editor/PreviewOverlay.tsx +7 -1
  12. package/src/components/editor/event/EventSheet.tsx +136 -0
  13. package/src/components/editor/event/LocationField.tsx +266 -0
  14. package/src/components/editor/event/OrganizeCard.tsx +87 -0
  15. package/src/components/editor/event/RegistrationCard.tsx +114 -0
  16. package/src/components/editor/event/RegistrationFormEditor.tsx +142 -0
  17. package/src/components/editor/event/Section.tsx +27 -0
  18. package/src/components/editor/event/WhenCard.tsx +217 -0
  19. package/src/components/editor/event/dates.ts +28 -0
  20. package/src/components/editor/event/registration.ts +39 -0
  21. package/src/components/editor/extensions/form.tsx +60 -0
  22. package/src/components/editor/extensions/slash.tsx +5 -0
  23. package/src/components/editor/frameNav.ts +20 -0
  24. package/src/components/forms/Canvas.tsx +149 -0
  25. package/src/components/forms/FormBuildPage.tsx +155 -0
  26. package/src/components/forms/FormBuilder.tsx +283 -0
  27. package/src/components/forms/FormHeader.astro +31 -0
  28. package/src/components/forms/InsertMenu.tsx +77 -0
  29. package/src/components/forms/Inspector.tsx +232 -0
  30. package/src/components/forms/Outline.tsx +93 -0
  31. package/src/components/forms/ResponsesTable.tsx +123 -0
  32. package/src/components/forms/TypeIcon.tsx +24 -0
  33. package/src/components/forms/dnd.ts +3 -0
  34. package/src/components/forms/meta.ts +84 -0
  35. package/src/components/forms/useFormHistory.ts +41 -0
  36. package/src/layouts/Admin.astro +3 -2
  37. package/src/lib/backup/content.ts +10 -5
  38. package/src/lib/booking/service.ts +6 -2
  39. package/src/lib/calendar/agent-schema.ts +7 -1
  40. package/src/lib/calendar/agent.ts +28 -3
  41. package/src/lib/calendar/api.ts +2 -0
  42. package/src/lib/calendar/ics-write.ts +2 -0
  43. package/src/lib/calendar/ics.ts +4 -0
  44. package/src/lib/calendar/import.ts +3 -3
  45. package/src/lib/calendar/schedule.ts +6 -2
  46. package/src/lib/calendar/seo.ts +4 -1
  47. package/src/lib/content-service.ts +30 -4
  48. package/src/lib/drafts.ts +9 -1
  49. package/src/lib/dynamicBlocks.ts +14 -3
  50. package/src/lib/editor/documents.ts +12 -3
  51. package/src/lib/editor/schemas.ts +4 -3
  52. package/src/lib/forms/events.ts +40 -0
  53. package/src/lib/forms/export.ts +62 -0
  54. package/src/lib/forms/fields.ts +50 -0
  55. package/src/lib/forms/http.ts +29 -0
  56. package/src/lib/forms/render.ts +11 -0
  57. package/src/lib/forms/schema.ts +56 -0
  58. package/src/lib/forms/service.ts +38 -0
  59. package/src/lib/forms/store.ts +78 -0
  60. package/src/lib/forms/validate.ts +71 -0
  61. package/src/lib/maps/config.ts +64 -0
  62. package/src/lib/maps/migration.ts +12 -0
  63. package/src/lib/maps/parse.ts +180 -0
  64. package/src/lib/maps/places.ts +109 -0
  65. package/src/lib/maps/providers/geoapify.ts +36 -0
  66. package/src/lib/maps/providers/google.ts +54 -0
  67. package/src/lib/maps/providers/http.ts +96 -0
  68. package/src/lib/maps/providers/index.ts +37 -0
  69. package/src/lib/maps/providers/locationiq.ts +35 -0
  70. package/src/lib/maps/providers/mapbox.ts +38 -0
  71. package/src/lib/maps/providers/osm.ts +59 -0
  72. package/src/lib/maps/providers/pelias.ts +51 -0
  73. package/src/lib/maps/service.ts +281 -0
  74. package/src/lib/maps/types.ts +35 -0
  75. package/src/lib/mobile/bookings.ts +1 -1
  76. package/src/lib/mobile/schemas.ts +2 -2
  77. package/src/lib/repo/migrations.ts +8 -0
  78. package/src/lib/repo/sql.ts +99 -10
  79. package/src/lib/repo/types.ts +21 -1
  80. package/src/lib/schema.ts +38 -0
  81. package/src/lib/umami.ts +8 -1
  82. package/src/pages/admin/calendar/bookings.astro +12 -7
  83. package/src/pages/admin/calendar/events/[id].astro +5 -2
  84. package/src/pages/admin/calendar/events/index.astro +3 -0
  85. package/src/pages/admin/calendar/resources/[id].astro +11 -0
  86. package/src/pages/admin/calendar/settings.astro +140 -3
  87. package/src/pages/admin/forms/[id]/share.astro +121 -0
  88. package/src/pages/admin/forms/[id]/submissions.astro +143 -0
  89. package/src/pages/admin/forms/[id].astro +27 -0
  90. package/src/pages/admin/forms/index.astro +198 -0
  91. package/src/pages/admin/integrations.astro +57 -0
  92. package/src/pages/api/agent/index.ts +1 -1
  93. package/src/pages/api/calendar/docs.ts +2 -0
  94. package/src/pages/api/calendar/events/[id].ts +1 -1
  95. package/src/pages/api/calendar/events/index.ts +1 -1
  96. package/src/pages/api/mcp.ts +1 -1
  97. package/src/pages/book/manage/[token].astro +4 -4
  98. package/src/pages/events/[slug].astro +24 -10
  99. package/src/pages/forms/[id].astro +27 -0
  100. package/src/styles/admin.css +2 -2
  101. package/src/styles/editor.css +275 -34
  102. package/src/styles/form-block.css +38 -0
  103. package/src/styles/forms-admin.css +184 -0
  104. package/src/styles/forms.css +281 -0
  105. package/src/styles/site.css +43 -0
  106. package/src/virtual.d.ts +2 -0
@@ -1,14 +1,12 @@
1
- import { useMemo, useState } from 'react';
1
+ import type { FormDefinition } from '../../lib/forms/schema';
2
2
  import type { EventCta, EventLocation } from '../../lib/schema';
3
- import type { EventFieldConfig } from '../../lib/calendar/config';
4
- import WhereItLands from '../calendar/WhereItLands';
5
- import RoomHolds, { type RoomHoldValue } from '../calendar/RoomHolds';
6
- import { buildRepeat, describeRepeat, parseRepeat, WEEKDAY_CODES, weekdayOf, type RepeatParts } from '../../lib/calendar/rrule';
3
+ import type { RoomHoldValue } from '../calendar/RoomHolds';
7
4
 
8
- // The event half of the editor sidebar: When · Repeats · Where · Button ·
9
- // Calendars · custom fields. Times are wall-clock strings in the event's
10
- // zone ('YYYY-MM-DDTHH:mm', or 'YYYY-MM-DD' for all-day with an inclusive
11
- // end day). The server re-validates everything on save.
5
+ // The event fields the editor holds, and the date label. The settings
6
+ // themselves live in ./event/ (EventSheet: When · Where · Registration ·
7
+ // Organize). Times are wall-clock strings in the event's zone
8
+ // ('YYYY-MM-DDTHH:mm', or 'YYYY-MM-DD' for all-day with an inclusive end
9
+ // day). The server re-validates everything on save.
12
10
 
13
11
  export interface EventMeta {
14
12
  start: string;
@@ -22,7 +20,7 @@ export interface EventMeta {
22
20
  cta: EventCta;
23
21
  calendarIds: string[];
24
22
  fields: Record<string, string>;
25
- rsvp: { enabled: boolean; capacity: number; autoConfirm: boolean; maxSeats: number };
23
+ rsvp: { enabled: boolean; capacity: number; autoConfirm: boolean; maxSeats: number; form?: FormDefinition };
26
24
  roomHold: RoomHoldValue;
27
25
  }
28
26
 
@@ -32,379 +30,7 @@ export interface CalendarOption {
32
30
  color: string;
33
31
  }
34
32
 
35
- interface Props {
36
- meta: EventMeta;
37
- onChange: (patch: Partial<EventMeta>) => void;
38
- calendars: CalendarOption[];
39
- fields: EventFieldConfig[];
40
- siteTimezone: string;
41
- // shows the "where it lands" mini month (Schedule data, minus this event)
42
- eventId?: string;
43
- // for the Rooms section (conflict wording, "Hold now" on drafts)
44
- title?: string;
45
- status?: string;
46
- }
47
-
48
- const DAY_LETTERS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
49
- const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
50
- const ORD = ['first', 'second', 'third', 'fourth', 'last'];
51
-
52
- const datePart = (s: string) => s.slice(0, 10);
53
- const timePart = (s: string) => (s.length > 10 ? s.slice(11, 16) : '');
54
- const toMin = (s: string) => {
55
- const [y, m, d] = s.slice(0, 10).split('-').map(Number);
56
- const [hh, mm] = s.length > 10 ? s.slice(11, 16).split(':').map(Number) : [0, 0];
57
- return Date.UTC(y, m - 1, d, hh, mm) / 60000;
58
- };
59
- const fromMin = (min: number, withTime: boolean) => {
60
- const d = new Date(min * 60000);
61
- const p = (n: number) => String(n).padStart(2, '0');
62
- const date = `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
63
- return withTime ? `${date}T${p(d.getUTCHours())}:${p(d.getUTCMinutes())}` : date;
64
- };
65
-
66
- // every IANA zone the browser knows, for the timezone datalist
67
- function zoneList(): string[] {
68
- try {
69
- return (Intl as unknown as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf?.('timeZone') ?? [];
70
- } catch {
71
- return [];
72
- }
73
- }
74
-
75
- export function EventPanel({ meta, onChange, calendars, fields, siteTimezone, eventId, title = '', status = 'draft' }: Props) {
76
- const repeat = useMemo(() => parseRepeat(meta.rrule, meta.start), [meta.rrule, meta.start]);
77
- const [skipDraft, setSkipDraft] = useState('');
78
- const zones = useMemo(zoneList, []);
79
- const multiDay = meta.allDay ? datePart(meta.end) !== datePart(meta.start) : datePart(meta.end) !== datePart(meta.start);
80
- const [showEndDate, setShowEndDate] = useState(multiDay);
81
-
82
- const setRepeat = (patch: Partial<RepeatParts>) => {
83
- const next = { ...repeat, ...patch };
84
- onChange({ rrule: buildRepeat(next, meta.start) });
85
- };
86
-
87
- // moving the start keeps the duration; the end never lands before the start
88
- const setStart = (start: string) => {
89
- if (!start) return;
90
- const dur = Math.max(meta.allDay ? 0 : 15, toMin(meta.end || meta.start) - toMin(meta.start));
91
- const end = fromMin(toMin(start) + dur, !meta.allDay);
92
- const patch: Partial<EventMeta> = { start, end };
93
- // a weekly rule that pinned the old weekday follows the new one
94
- if (repeat.freq === 'WEEKLY' && repeat.byday.length === 1) {
95
- patch.rrule = buildRepeat({ ...repeat, byday: [WEEKDAY_CODES[weekdayOf(start).dow]] }, start);
96
- } else if (meta.rrule) patch.rrule = buildRepeat(repeat, start);
97
- onChange(patch);
98
- };
99
- const setEnd = (end: string) => {
100
- if (!end) return;
101
- onChange({ end: toMin(end) < toMin(meta.start) ? meta.start : end });
102
- };
103
- const toggleAllDay = (allDay: boolean) => {
104
- if (allDay) onChange({ allDay, start: datePart(meta.start), end: datePart(meta.end || meta.start) });
105
- else {
106
- const t = '10:00';
107
- onChange({ allDay, start: `${datePart(meta.start)}T${t}`, end: `${datePart(meta.end || meta.start)}T11:00` });
108
- }
109
- };
110
-
111
- const wd = weekdayOf(meta.start || new Date().toISOString().slice(0, 10));
112
- const summary = describeRepeat(meta.rrule, meta.start);
113
-
114
- return (
115
- <>
116
- <section className="pe-sb-section pe-ev">
117
- <h3 className="pe-sb-title">When</h3>
118
- <div className="pe-ev-row">
119
- <label className="pe-field">
120
- <span>{meta.allDay ? 'Starts' : 'Date'}</span>
121
- <input type="date" value={datePart(meta.start)} onChange={(e) => setStart(meta.allDay ? e.target.value : `${e.target.value}T${timePart(meta.start) || '10:00'}`)} />
122
- </label>
123
- {!meta.allDay && (
124
- <label className="pe-field pe-ev-time">
125
- <span>Starts</span>
126
- <input type="time" step={300} value={timePart(meta.start)} onChange={(e) => setStart(`${datePart(meta.start)}T${e.target.value}`)} />
127
- </label>
128
- )}
129
- </div>
130
- <div className="pe-ev-row">
131
- {(meta.allDay || showEndDate) && (
132
- <label className="pe-field">
133
- <span>{meta.allDay ? 'Ends' : 'End date'}</span>
134
- <input type="date" min={datePart(meta.start)} value={datePart(meta.end || meta.start)} onChange={(e) => setEnd(meta.allDay ? e.target.value : `${e.target.value}T${timePart(meta.end) || timePart(meta.start)}`)} />
135
- </label>
136
- )}
137
- {!meta.allDay && (
138
- <label className="pe-field pe-ev-time">
139
- <span>Ends</span>
140
- <input type="time" step={300} value={timePart(meta.end)} onChange={(e) => setEnd(`${datePart(meta.end || meta.start)}T${e.target.value}`)} />
141
- </label>
142
- )}
143
- </div>
144
- {!meta.allDay && !showEndDate && (
145
- <button type="button" className="pe-ev-link" onClick={() => setShowEndDate(true)}>Ends on another day</button>
146
- )}
147
- {eventId && datePart(meta.start) && (
148
- <WhereItLands date={datePart(meta.start)} eventId={eventId}
149
- onPick={(d) => setStart(meta.allDay ? d : `${d}T${timePart(meta.start) || '10:00'}`)} />
150
- )}
151
- <label className="pe-switch-row">
152
- <span><b>All day</b><small>No start or end time</small></span>
153
- <input type="checkbox" role="switch" className="pe-switch" checked={meta.allDay} onChange={(e) => toggleAllDay(e.target.checked)} />
154
- </label>
155
- <label className="pe-field pe-ev-tz">
156
- <span>Timezone</span>
157
- <input
158
- type="text"
159
- list="pe-ev-zones"
160
- placeholder={`${siteTimezone || 'UTC'} (site default)`}
161
- value={meta.timezone}
162
- spellCheck={false}
163
- onChange={(e) => onChange({ timezone: e.target.value.trim() })}
164
- />
165
- <datalist id="pe-ev-zones">{zones.map((z) => <option key={z} value={z} />)}</datalist>
166
- </label>
167
- </section>
168
-
169
- <section className="pe-sb-section pe-ev">
170
- <h3 className="pe-sb-title">Repeats</h3>
171
- <label className="pe-field">
172
- <span>Repeat {summary && <em>{summary}</em>}</span>
173
- <select
174
- value={repeat.freq}
175
- onChange={(e) => {
176
- const freq = e.target.value as RepeatParts['freq'];
177
- if (freq === 'custom') onChange({ rrule: meta.rrule || 'FREQ=WEEKLY' });
178
- else setRepeat({ freq, interval: 1 });
179
- }}
180
- >
181
- <option value="none">Does not repeat</option>
182
- <option value="DAILY">Daily</option>
183
- <option value="WEEKLY">Weekly</option>
184
- <option value="MONTHLY">Monthly</option>
185
- <option value="YEARLY">Yearly</option>
186
- <option value="custom">Custom rule…</option>
187
- </select>
188
- </label>
189
- {repeat.freq !== 'none' && repeat.freq !== 'custom' && (
190
- <div className="pe-ev-inline">
191
- <span>Every</span>
192
- <input type="number" min={1} max={99} value={repeat.interval} onChange={(e) => setRepeat({ interval: Math.max(1, Number(e.target.value) || 1) })} />
193
- <span>{{ DAILY: 'days', WEEKLY: 'weeks', MONTHLY: 'months', YEARLY: 'years' }[repeat.freq]}</span>
194
- </div>
195
- )}
196
- {repeat.freq === 'WEEKLY' && (
197
- <div className="pe-ev-days" role="group" aria-label="Days of the week">
198
- {WEEKDAY_CODES.map((code, i) => {
199
- const on = repeat.byday.includes(code);
200
- return (
201
- <button
202
- key={code}
203
- type="button"
204
- aria-pressed={on}
205
- title={DAY_NAMES[i]}
206
- onClick={() => {
207
- const next = on ? repeat.byday.filter((c) => c !== code) : [...repeat.byday, code];
208
- setRepeat({ byday: next.length ? next : [code] });
209
- }}
210
- >
211
- {DAY_LETTERS[i]}
212
- </button>
213
- );
214
- })}
215
- </div>
216
- )}
217
- {repeat.freq === 'MONTHLY' && (
218
- <div className="pe-ev-choices">
219
- <label><input type="radio" name="pe-monthly" checked={repeat.monthly === 'day'} onChange={() => setRepeat({ monthly: 'day' })} /> On day {wd.day}</label>
220
- <label><input type="radio" name="pe-monthly" checked={repeat.monthly === 'weekday'} onChange={() => setRepeat({ monthly: 'weekday' })} /> On the {ORD[Math.min(wd.nth, 5) - 1]} {DAY_NAMES[wd.dow]}</label>
221
- </div>
222
- )}
223
- {repeat.freq === 'custom' && (
224
- <label className="pe-field">
225
- <span>RRULE <em>RFC 5545</em></span>
226
- <input type="text" className="mono" spellCheck={false} value={meta.rrule} placeholder="FREQ=MONTHLY;BYDAY=2TU" onChange={(e) => onChange({ rrule: e.target.value.toUpperCase() })} />
227
- </label>
228
- )}
229
- {repeat.freq !== 'none' && repeat.freq !== 'custom' && (
230
- <div className="pe-ev-choices">
231
- <label><input type="radio" name="pe-ends" checked={repeat.ends === 'never'} onChange={() => setRepeat({ ends: 'never' })} /> Never ends</label>
232
- <label>
233
- <input type="radio" name="pe-ends" checked={repeat.ends === 'on'} onChange={() => setRepeat({ ends: 'on', until: repeat.until || datePart(fromMin(toMin(meta.start) + 60 * 24 * 90, false)) })} /> Ends on
234
- {repeat.ends === 'on' && <input type="date" min={datePart(meta.start)} value={repeat.until} onChange={(e) => setRepeat({ until: e.target.value })} />}
235
- </label>
236
- <label>
237
- <input type="radio" name="pe-ends" checked={repeat.ends === 'after'} onChange={() => setRepeat({ ends: 'after' })} /> After
238
- {repeat.ends === 'after' && <input type="number" min={1} max={999} value={repeat.count} onChange={(e) => setRepeat({ count: Math.max(1, Number(e.target.value) || 1) })} />}
239
- {repeat.ends === 'after' && ' times'}
240
- </label>
241
- </div>
242
- )}
243
- {meta.rrule && (
244
- <div className="pe-field">
245
- <span>Skip dates <em>holidays, one-off cancellations</em></span>
246
- {meta.exdates.length > 0 && (
247
- <div className="pe-tags pe-ev-skips">
248
- {meta.exdates.map((d) => (
249
- <span key={d} className="pe-tag">
250
- {datePart(d)}
251
- <button type="button" aria-label={`Stop skipping ${datePart(d)}`} onClick={() => onChange({ exdates: meta.exdates.filter((x) => x !== d) })}>✕</button>
252
- </span>
253
- ))}
254
- </div>
255
- )}
256
- <div className="pe-date">
257
- <input type="date" value={skipDraft} min={datePart(meta.start)} onChange={(e) => setSkipDraft(e.target.value)} />
258
- <button
259
- type="button"
260
- className="pe-btn sm"
261
- disabled={!skipDraft}
262
- onClick={() => {
263
- // an exdate is an occurrence START (same form as `start`)
264
- const value = meta.allDay ? skipDraft : `${skipDraft}T${timePart(meta.start)}`;
265
- if (!meta.exdates.includes(value)) onChange({ exdates: [...meta.exdates, value].sort() });
266
- setSkipDraft('');
267
- }}
268
- >
269
- Skip
270
- </button>
271
- </div>
272
- </div>
273
- )}
274
- </section>
275
-
276
- <section className="pe-sb-section pe-ev">
277
- <h3 className="pe-sb-title">Where</h3>
278
- <label className="pe-field">
279
- <span>Place</span>
280
- <input type="text" placeholder="Fellowship Hall, Zoom, the park…" value={meta.location.name} onChange={(e) => onChange({ location: { ...meta.location, name: e.target.value } })} />
281
- </label>
282
- <label className="pe-field">
283
- <span>Address</span>
284
- <input type="text" placeholder="1200 River Rd, Riverside" value={meta.location.address} onChange={(e) => onChange({ location: { ...meta.location, address: e.target.value } })} />
285
- </label>
286
- <label className="pe-field">
287
- <span>Map or meeting link</span>
288
- <input type="url" className="mono" placeholder="https://maps.app.goo.gl/… or a Zoom link" value={meta.location.url} onChange={(e) => onChange({ location: { ...meta.location, url: e.target.value.trim() } })} />
289
- </label>
290
- </section>
291
-
292
- <RoomHolds
293
- times={{ eventId: eventId ?? '', title, start: meta.start, end: meta.end, allDay: meta.allDay, timezone: meta.timezone, rrule: meta.rrule, exdates: meta.exdates }}
294
- hold={meta.roomHold}
295
- status={status}
296
- locationName={meta.location.name}
297
- onChange={(roomHold) => onChange({ roomHold })}
298
- onLocation={(name) => onChange({ location: { ...meta.location, name } })}
299
- />
300
-
301
- <section className="pe-sb-section pe-ev">
302
- <h3 className="pe-sb-title">Button</h3>
303
- <div className="pe-ev-row">
304
- <label className="pe-field">
305
- <span>Label</span>
306
- <input type="text" placeholder="Register, Get tickets…" maxLength={120} value={meta.cta.label} onChange={(e) => onChange({ cta: { ...meta.cta, label: e.target.value } })} />
307
- </label>
308
- <label className="pe-field pe-ev-time">
309
- <span>Style</span>
310
- <select value={meta.cta.style} onChange={(e) => onChange({ cta: { ...meta.cta, style: e.target.value as EventCta['style'] } })}>
311
- <option value="primary">Solid</option>
312
- <option value="outline">Outline</option>
313
- </select>
314
- </label>
315
- </div>
316
- <label className="pe-field">
317
- <span>Link</span>
318
- <input type="text" className="mono" placeholder="https://… or /page" value={meta.cta.href} onChange={(e) => onChange({ cta: { ...meta.cta, href: e.target.value.trim() } })} />
319
- </label>
320
- </section>
321
-
322
- <section className="pe-sb-section pe-ev">
323
- <h3 className="pe-sb-title">RSVP</h3>
324
- <label className="pe-switch-row">
325
- <span><b>Take RSVPs</b><small>A “Save my seat” form replaces the button; see who’s coming in Bookings</small></span>
326
- <input type="checkbox" role="switch" className="pe-switch" checked={meta.rsvp.enabled} onChange={(e) => onChange({ rsvp: { ...meta.rsvp, enabled: e.target.checked } })} />
327
- </label>
328
- {meta.rsvp.enabled && (
329
- <>
330
- <div className="pe-ev-row" style={{ marginTop: 10 }}>
331
- <label className="pe-field">
332
- <span>Seats <em>0 = no limit</em></span>
333
- <input type="number" min={0} value={meta.rsvp.capacity} onChange={(e) => onChange({ rsvp: { ...meta.rsvp, capacity: Math.max(0, Number(e.target.value) || 0) } })} />
334
- </label>
335
- <label className="pe-field">
336
- <span>Max per person</span>
337
- <input type="number" min={1} max={50} value={meta.rsvp.maxSeats} onChange={(e) => onChange({ rsvp: { ...meta.rsvp, maxSeats: Math.min(50, Math.max(1, Number(e.target.value) || 1)) } })} />
338
- </label>
339
- </div>
340
- <label className="pe-switch-row">
341
- <span><b>Confirm automatically</b><small>Off: RSVPs wait for your approval in Bookings</small></span>
342
- <input type="checkbox" role="switch" className="pe-switch" checked={meta.rsvp.autoConfirm} onChange={(e) => onChange({ rsvp: { ...meta.rsvp, autoConfirm: e.target.checked } })} />
343
- </label>
344
- </>
345
- )}
346
- </section>
347
-
348
- <section className="pe-sb-section pe-ev">
349
- <h3 className="pe-sb-title">Calendars</h3>
350
- {calendars.length === 0 ? (
351
- <p className="pe-ev-note">No calendars yet — the event shows on the main calendar. <a href="/admin/calendar/settings?new=1" target="_blank" rel="noopener">Create one</a> to group events.</p>
352
- ) : (
353
- <div className="pe-ev-cals">
354
- {calendars.map((c) => {
355
- const on = meta.calendarIds.includes(c.id);
356
- return (
357
- <label key={c.id} className={on ? 'is-on' : ''}>
358
- <input
359
- type="checkbox"
360
- checked={on}
361
- onChange={(e) => onChange({ calendarIds: e.target.checked ? [...meta.calendarIds, c.id] : meta.calendarIds.filter((x) => x !== c.id) })}
362
- />
363
- <i style={{ background: c.color }} aria-hidden="true" />
364
- <span>{c.name}</span>
365
- </label>
366
- );
367
- })}
368
- </div>
369
- )}
370
- </section>
371
-
372
- {fields.length > 0 && (
373
- <section className="pe-sb-section pe-ev">
374
- <h3 className="pe-sb-title">Details</h3>
375
- {fields.map((f) => {
376
- const value = meta.fields[f.key] ?? '';
377
- const set = (v: string) => onChange({ fields: { ...meta.fields, [f.key]: v } });
378
- return (
379
- <label key={f.key} className="pe-field">
380
- <span>{f.label}</span>
381
- {f.type === 'select' ? (
382
- <select value={value} onChange={(e) => set(e.target.value)}>
383
- <option value="">—</option>
384
- {f.options.map((o) => <option key={o} value={o}>{o}</option>)}
385
- </select>
386
- ) : f.type === 'textarea' ? (
387
- <textarea rows={3} value={value} onChange={(e) => set(e.target.value)} />
388
- ) : (
389
- <input type={f.type === 'number' ? 'number' : f.type === 'url' ? 'url' : 'text'} value={value} onChange={(e) => set(e.target.value)} />
390
- )}
391
- </label>
392
- );
393
- })}
394
- </section>
395
- )}
396
-
397
- <section className="pe-sb-section pe-ev">
398
- <label className="pe-switch-row">
399
- <span><b>Cancelled</b><small>Stays on the calendar, marked cancelled, so nobody wonders where it went</small></span>
400
- <input type="checkbox" role="switch" className="pe-switch" checked={meta.cancelled} onChange={(e) => onChange({ cancelled: e.target.checked })} />
401
- </label>
402
- </section>
403
- </>
404
- );
405
- }
406
-
407
- // "Sat, Oct 3 · 11:00 AM – 4:00 PM" for the byline under the title
33
+ // "Sat, Oct 3 · 11:00 AM – 4:00 PM" for the event settings summary
408
34
  export function eventWhenLabel(meta: Pick<EventMeta, 'start' | 'end' | 'allDay'>): string {
409
35
  if (!meta.start) return 'Set a date';
410
36
  const d = (s: string) => {
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useRef, useState } from 'react';
2
+ import { useFrameAway } from './frameNav';
2
3
 
3
4
  // Side-by-side live preview. Reloads on every autosave (tick), keeps the
4
5
  // reader's scroll position across reloads, briefly highlights the first
@@ -32,6 +33,7 @@ export function LivePane({ src, path, tick, focusText }: Props) {
32
33
  const frame = useRef<HTMLIFrameElement>(null);
33
34
  const prevTexts = useRef<string[] | null>(null);
34
35
  const scrollY = useRef(0);
36
+ const { away, nav, check, back } = useFrameAway(frame, src);
35
37
 
36
38
  // relative "updated" label ticks every 10s
37
39
  useEffect(() => {
@@ -73,6 +75,8 @@ export function LivePane({ src, path, tick, focusText }: Props) {
73
75
  }, [focusText]);
74
76
 
75
77
  const onLoad = () => {
78
+ // a link in the preview went elsewhere: leave that page alone
79
+ if (check()) return;
76
80
  setUpdatedAt(Date.now());
77
81
  const all = blocks();
78
82
  if (!frame.current?.contentWindow) return;
@@ -101,8 +105,17 @@ export function LivePane({ src, path, tick, focusText }: Props) {
101
105
  <aside className="pe-splitpane" aria-label="Live preview">
102
106
  <div className="pe-split-bar">
103
107
  <span className="pe-live" title={`Updated ${ago}`}><i />Live</span>
104
- <span className="pe-live-path">{path}</span>
105
- <small className="pe-live-ago">{ago}</small>
108
+ {away ? (
109
+ <>
110
+ <button type="button" className="pe-btn sm pe-live-back" onClick={back} title={`Back to ${path}`}>← Back to page</button>
111
+ <small className="pe-live-ago pe-live-away" title={away}>Viewing {away}</small>
112
+ </>
113
+ ) : (
114
+ <>
115
+ <span className="pe-live-path">{path}</span>
116
+ <small className="pe-live-ago">{ago}</small>
117
+ </>
118
+ )}
106
119
  <div className="pe-seg" role="group" aria-label="Preview width">
107
120
  {DEVICES.map((d) => (
108
121
  <button key={d.key} type="button" aria-pressed={device === d.key} title={d.label} aria-label={d.label} onClick={() => pick(d.key)}>
@@ -115,7 +128,7 @@ export function LivePane({ src, path, tick, focusText }: Props) {
115
128
  </a>
116
129
  </div>
117
130
  <div className={`pe-split-stage ${device}`}>
118
- <iframe ref={frame} key={tick} src={src} title="Live preview" onLoad={onLoad} />
131
+ <iframe ref={frame} key={`${tick}-${nav}`} src={src} title="Live preview" onLoad={onLoad} />
119
132
  </div>
120
133
  </aside>
121
134
  );