@acms-subir/core 0.3.6 → 0.3.8

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 (116) hide show
  1. package/CHANGELOG.md +27 -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/EmailSettings.tsx +6 -0
  6. package/src/components/EventPage.astro +25 -5
  7. package/src/components/booking/DurationEditor.astro +156 -0
  8. package/src/components/calendar/BookingsHead.astro +4 -3
  9. package/src/components/calendar/RoomHolds.tsx +13 -8
  10. package/src/components/editor/EventPanel.tsx +9 -383
  11. package/src/components/editor/LivePane.tsx +16 -3
  12. package/src/components/editor/PostEditor.tsx +59 -46
  13. package/src/components/editor/PreviewOverlay.tsx +7 -1
  14. package/src/components/editor/event/EventSheet.tsx +136 -0
  15. package/src/components/editor/event/LocationField.tsx +266 -0
  16. package/src/components/editor/event/OrganizeCard.tsx +87 -0
  17. package/src/components/editor/event/RegistrationCard.tsx +114 -0
  18. package/src/components/editor/event/RegistrationFormEditor.tsx +142 -0
  19. package/src/components/editor/event/Section.tsx +27 -0
  20. package/src/components/editor/event/WhenCard.tsx +217 -0
  21. package/src/components/editor/event/dates.ts +28 -0
  22. package/src/components/editor/event/registration.ts +39 -0
  23. package/src/components/editor/extensions/form.tsx +60 -0
  24. package/src/components/editor/extensions/slash.tsx +5 -0
  25. package/src/components/editor/frameNav.ts +20 -0
  26. package/src/components/forms/Canvas.tsx +149 -0
  27. package/src/components/forms/FormBuildPage.tsx +155 -0
  28. package/src/components/forms/FormBuilder.tsx +283 -0
  29. package/src/components/forms/FormHeader.astro +31 -0
  30. package/src/components/forms/InsertMenu.tsx +77 -0
  31. package/src/components/forms/Inspector.tsx +232 -0
  32. package/src/components/forms/Outline.tsx +93 -0
  33. package/src/components/forms/ResponsesTable.tsx +123 -0
  34. package/src/components/forms/TypeIcon.tsx +24 -0
  35. package/src/components/forms/dnd.ts +3 -0
  36. package/src/components/forms/meta.ts +84 -0
  37. package/src/components/forms/useFormHistory.ts +41 -0
  38. package/src/layouts/Admin.astro +3 -2
  39. package/src/lib/backup/content.ts +10 -5
  40. package/src/lib/booking/forms.ts +67 -0
  41. package/src/lib/booking/service.ts +6 -2
  42. package/src/lib/calendar/agent-schema.ts +7 -1
  43. package/src/lib/calendar/agent.ts +28 -3
  44. package/src/lib/calendar/api.ts +2 -0
  45. package/src/lib/calendar/ics-write.ts +2 -0
  46. package/src/lib/calendar/ics.ts +4 -0
  47. package/src/lib/calendar/import.ts +3 -3
  48. package/src/lib/calendar/schedule.ts +6 -2
  49. package/src/lib/calendar/seo.ts +4 -1
  50. package/src/lib/content-service.ts +30 -4
  51. package/src/lib/drafts.ts +9 -1
  52. package/src/lib/dynamicBlocks.ts +14 -3
  53. package/src/lib/editor/documents.ts +12 -3
  54. package/src/lib/editor/schemas.ts +4 -3
  55. package/src/lib/email/readiness.ts +14 -0
  56. package/src/lib/email/service.ts +8 -3
  57. package/src/lib/email/templates.ts +8 -2
  58. package/src/lib/email/types.ts +1 -0
  59. package/src/lib/forms/events.ts +40 -0
  60. package/src/lib/forms/export.ts +62 -0
  61. package/src/lib/forms/fields.ts +50 -0
  62. package/src/lib/forms/http.ts +29 -0
  63. package/src/lib/forms/render.ts +11 -0
  64. package/src/lib/forms/schema.ts +56 -0
  65. package/src/lib/forms/service.ts +38 -0
  66. package/src/lib/forms/store.ts +78 -0
  67. package/src/lib/forms/validate.ts +71 -0
  68. package/src/lib/maps/config.ts +64 -0
  69. package/src/lib/maps/migration.ts +12 -0
  70. package/src/lib/maps/parse.ts +180 -0
  71. package/src/lib/maps/places.ts +109 -0
  72. package/src/lib/maps/providers/geoapify.ts +36 -0
  73. package/src/lib/maps/providers/google.ts +54 -0
  74. package/src/lib/maps/providers/http.ts +96 -0
  75. package/src/lib/maps/providers/index.ts +37 -0
  76. package/src/lib/maps/providers/locationiq.ts +35 -0
  77. package/src/lib/maps/providers/mapbox.ts +38 -0
  78. package/src/lib/maps/providers/osm.ts +59 -0
  79. package/src/lib/maps/providers/pelias.ts +51 -0
  80. package/src/lib/maps/service.ts +281 -0
  81. package/src/lib/maps/types.ts +35 -0
  82. package/src/lib/mobile/bookings.ts +1 -1
  83. package/src/lib/mobile/schemas.ts +2 -2
  84. package/src/lib/repo/migrations.ts +8 -0
  85. package/src/lib/repo/sql.ts +99 -10
  86. package/src/lib/repo/types.ts +21 -1
  87. package/src/lib/schema.ts +38 -0
  88. package/src/lib/umami.ts +8 -1
  89. package/src/pages/admin/calendar/bookings.astro +23 -12
  90. package/src/pages/admin/calendar/events/[id].astro +5 -2
  91. package/src/pages/admin/calendar/events/index.astro +3 -0
  92. package/src/pages/admin/calendar/resources/[id].astro +11 -0
  93. package/src/pages/admin/calendar/resources/link.astro +95 -49
  94. package/src/pages/admin/calendar/settings.astro +140 -3
  95. package/src/pages/admin/forms/[id]/share.astro +121 -0
  96. package/src/pages/admin/forms/[id]/submissions.astro +143 -0
  97. package/src/pages/admin/forms/[id].astro +27 -0
  98. package/src/pages/admin/forms/index.astro +198 -0
  99. package/src/pages/admin/integrations.astro +57 -0
  100. package/src/pages/admin/settings.astro +1 -1
  101. package/src/pages/api/agent/index.ts +1 -1
  102. package/src/pages/api/calendar/docs.ts +2 -0
  103. package/src/pages/api/calendar/events/[id].ts +1 -1
  104. package/src/pages/api/calendar/events/index.ts +1 -1
  105. package/src/pages/api/email/settings.ts +4 -2
  106. package/src/pages/api/mcp.ts +1 -1
  107. package/src/pages/book/manage/[token].astro +4 -4
  108. package/src/pages/events/[slug].astro +24 -10
  109. package/src/pages/forms/[id].astro +27 -0
  110. package/src/styles/admin.css +2 -2
  111. package/src/styles/editor.css +275 -34
  112. package/src/styles/form-block.css +38 -0
  113. package/src/styles/forms-admin.css +184 -0
  114. package/src/styles/forms.css +281 -0
  115. package/src/styles/site.css +43 -0
  116. package/src/virtual.d.ts +2 -0
@@ -16,6 +16,7 @@ import { PostListCard } from './extensions/postList';
16
16
  import { ToggleCard } from './extensions/toggle';
17
17
  import { TocCard } from './extensions/toc';
18
18
  import { CalendarCard } from './extensions/calendar';
19
+ import { FormCard } from './extensions/form';
19
20
  import { EventCard } from './extensions/events';
20
21
  import { InfoCards } from './extensions/infoCards';
21
22
  import { SlashMenu, type SlashSnippet } from './extensions/slash';
@@ -30,7 +31,10 @@ import { CONTENT, collectionUrl } from '../../lib/content';
30
31
  import { I18N, localeHomeUrl } from '../../lib/i18n';
31
32
  import { mergeEventDraft, mergePageDraft, mergePostDraft } from '../../lib/drafts';
32
33
  import { CALENDAR, eventUrl, type EventFieldConfig } from '../../lib/calendar/config';
33
- import { EventPanel, eventWhenLabel, type CalendarOption, type EventMeta } from './EventPanel';
34
+ import type { CalendarOption, EventMeta } from './EventPanel';
35
+ import { EventSheet } from './event/EventSheet';
36
+ import { focusEventCard } from './event/Section';
37
+ import type { MapsStatus } from '../../lib/maps/types';
34
38
  import { fetchRoomStatus, NO_HOLD, RoomConflictDialog } from '../calendar/RoomHolds';
35
39
 
36
40
  // The Ghost-style writing surface, shared by posts AND pages: distraction-free
@@ -53,6 +57,8 @@ interface Props {
53
57
  calendars?: CalendarOption[];
54
58
  eventFields?: EventFieldConfig[];
55
59
  siteTimezone?: string;
60
+ // the site's maps provider (address suggestions, static map images)
61
+ maps?: MapsStatus;
56
62
  initialHTML: string;
57
63
  unsplashEnabled: boolean;
58
64
  previewPath: string;
@@ -146,14 +152,17 @@ type TrField = 'title' | 'excerpt' | 'bodyHtml' | 'seoTitle' | 'seoDesc' | 'loca
146
152
  const emptyTr = () => ({ title: '', excerpt: '', bodyHtml: '', seoTitle: '', seoDesc: '', slug: '', locationName: '', ctaLabel: '', meta: {} as Record<string, { state: 'ai' | 'reviewed'; src: string }> });
147
153
  const TR_STATE: Record<string, string> = { missing: 'not translated', ai: 'AI draft', reviewed: 'reviewed', outdated: 'English changed' };
148
154
 
155
+ const NO_LOCATION: EventMeta['location'] = { name: '', address: '', url: '', lat: null, lng: null, placeId: '', showMap: true, mapImage: '', mapImageDark: '' };
156
+ const NO_MAPS: MapsStatus = { enabled: false, provider: '', label: '', search: false, staticMaps: false };
157
+
149
158
  const NO_EVENT: EventMeta = {
150
159
  start: '', end: '', allDay: false, timezone: '', rrule: '', exdates: [], cancelled: false,
151
- location: { name: '', address: '', url: '' }, cta: { label: '', href: '', style: 'primary' }, calendarIds: [], fields: {},
160
+ location: NO_LOCATION, cta: { label: '', href: '', style: 'primary' }, calendarIds: [], fields: {},
152
161
  rsvp: { enabled: false, capacity: 0, autoConfirm: true, maxSeats: 4 },
153
162
  roomHold: NO_HOLD,
154
163
  };
155
164
 
156
- export default function PostEditor({ kind = 'post', post, page, event, calendars = [], eventFields = [], siteTimezone = '', initialHTML, unsplashEnabled, previewPath, sharePath, livePath: livePathProp, templates = [], site = { title: '', url: '' }, languages = [], mainLanguage, translationDrafts = false }: Props) {
165
+ export default function PostEditor({ kind = 'post', post, page, event, calendars = [], eventFields = [], siteTimezone = '', maps = NO_MAPS, initialHTML, unsplashEnabled, previewPath, sharePath, livePath: livePathProp, templates = [], site = { title: '', url: '' }, languages = [], mainLanguage, translationDrafts = false }: Props) {
157
166
  const doc = (kind === 'page' ? page : kind === 'event' ? event : post)!;
158
167
  // posts and events share the feature image, excerpt, tags and "featured"
159
168
  const postLike = kind !== 'page';
@@ -177,7 +186,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
177
186
  status: event!.status, featured: event!.featured, isHome: false, locale: '', authorId: null, authorIds: [],
178
187
  template: event!.template, publishedAt: event!.publishedAt, scheduledAt: event!.scheduledAt,
179
188
  start: event!.start, end: event!.end, allDay: event!.allDay, timezone: event!.timezone, rrule: event!.rrule,
180
- exdates: event!.exdates, cancelled: event!.cancelled, location: event!.location, cta: event!.cta,
189
+ exdates: event!.exdates, cancelled: event!.cancelled, location: { ...NO_LOCATION, ...event!.location }, cta: event!.cta,
181
190
  calendarIds: event!.calendarIds, fields: event!.fields, rsvp: event!.rsvp, roomHold: event!.roomHold,
182
191
  }
183
192
  : {
@@ -196,6 +205,8 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
196
205
  : kind === 'page' ? CONTENT.posts : (CONTENT.collections[meta.collection] ?? CONTENT.posts);
197
206
  const listHref = kind === 'page' ? '/admin/pages' : kind === 'event' ? '/admin/calendar' : `/admin/c/${meta.collection}`;
198
207
  const noun = kind === 'page' ? 'page' : col.singular.toLowerCase();
208
+ // events keep their settings on the page; the sidebar is just URL & SEO
209
+ const sidebarLabel = kind === 'page' ? 'Page settings' : kind === 'event' ? 'URL & SEO' : `${col.singular} settings`;
199
210
 
200
211
  const [authors, setAuthors] = useState<AuthorOption[]>([]);
201
212
  const [tagSuggestions, setTagSuggestions] = useState<string[]>([]);
@@ -211,8 +222,8 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
211
222
  const [leaveBusy, setLeaveBusy] = useState('');
212
223
  const [saveState, setSaveState] = useState<SaveState>('saved');
213
224
  const saveStateRef = useRef<SaveState>('saved');
214
- // a fresh event opens with its settings showing: the date is the point
215
- const [sidebarOpen, setSidebarOpen] = useState(() => kind === 'event' && event!.createdAt === event!.updatedAt);
225
+ // events show their settings on the page; the sidebar is URL & SEO only
226
+ const [sidebarOpen, setSidebarOpen] = useState(false);
216
227
  const [publishOpen, setPublishOpen] = useState(false);
217
228
  const [previewOpen, setPreviewOpen] = useState(false);
218
229
  const [split, setSplit] = useState(() => {
@@ -295,6 +306,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
295
306
  TocCard,
296
307
  CalendarCard,
297
308
  EventCard,
309
+ FormCard,
298
310
  InfoCards,
299
311
  FileCard,
300
312
  BookmarkCard,
@@ -417,6 +429,15 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
417
429
  if (r.error) {
418
430
  setSave('error');
419
431
  setSaveError(r.error.message);
432
+ // point at the section the problem is in, unless the person is
433
+ // typing in the settings already (autosave would yank the caret)
434
+ const msg = r.error.message.toLowerCase();
435
+ const typing = document.activeElement?.closest('.pe-evs');
436
+ if (!typing) {
437
+ if (/\b(button|cta|rsvp|seats?)\b/.test(msg)) focusEventCard('ev-registration');
438
+ else if (/\b(location|map|place|meeting)\b/.test(msg)) focusEventCard('ev-where');
439
+ else if (/\b(rrule|repeats?|dates?|times?|ends?|zone|timezone)\b|\bstart (date|time)|\bstarts\b/.test(msg)) focusEventCard('ev-when');
440
+ }
420
441
  return;
421
442
  }
422
443
  setSaveError('');
@@ -424,7 +445,12 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
424
445
  const ev = mergeEventDraft(r.data.event);
425
446
  setPending(Boolean(r.data.event.draft));
426
447
  setMetaState((prev) => {
427
- const next = { ...prev, slug: ev.slug, start: ev.start, end: ev.end, publishedAt: ev.publishedAt };
448
+ // map images are made server-side for the saved pin; take them only
449
+ // if the pin hasn't moved since this save started
450
+ const loc = { ...NO_LOCATION, ...ev.location };
451
+ const samePin = loc.lat === prev.location.lat && loc.lng === prev.location.lng;
452
+ const location = samePin ? { ...prev.location, mapImage: loc.mapImage, mapImageDark: loc.mapImageDark } : prev.location;
453
+ const next = { ...prev, slug: ev.slug, start: ev.start, end: ev.end, publishedAt: ev.publishedAt, location };
428
454
  metaRef.current = next;
429
455
  return next;
430
456
  });
@@ -619,7 +645,8 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
619
645
  }, []);
620
646
 
621
647
  // Authors load up front (the byline under the title names the primary one);
622
- // the tag registry only matters once the settings sidebar opens.
648
+ // the tag registry only matters once tags are on screen (sidebar, or the
649
+ // event sheet's Organize card).
623
650
  useEffect(() => {
624
651
  if (kind !== 'post') return;
625
652
  actions.users.list({}).then((r) => {
@@ -628,7 +655,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
628
655
  }, [kind]);
629
656
  const tagsLoaded = useRef(false);
630
657
  useEffect(() => {
631
- if (!postLike || !sidebarOpen || tagsLoaded.current) return;
658
+ if (!postLike || (!sidebarOpen && kind !== 'event') || tagsLoaded.current) return;
632
659
  tagsLoaded.current = true;
633
660
  actions.tags.list({}).then((r) => {
634
661
  if (!r.error) setTagSuggestions(r.data.tags.map((t) => t.name));
@@ -1098,8 +1125,8 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
1098
1125
  <button
1099
1126
  type="button"
1100
1127
  className={`pe-btn ghost sm pe-icon-btn pe-sidebar-toggle ${sidebarOpen ? 'is-active' : ''}`}
1101
- title={kind === 'page' ? 'Page settings' : `${col.singular} settings`}
1102
- aria-label={kind === 'page' ? 'Page settings' : `${col.singular} settings`}
1128
+ title={sidebarLabel}
1129
+ aria-label={sidebarLabel}
1103
1130
  onClick={() => setSidebarOpen((v) => !v)}
1104
1131
  >
1105
1132
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
@@ -1112,7 +1139,24 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
1112
1139
  <div className="pe-body">
1113
1140
  <section className="pe-editor-pane">
1114
1141
  <main className="pe-main">
1115
- <div className="pe-column">
1142
+ {kind === 'event' && (
1143
+ <div className="pe-evs-wrap">
1144
+ <EventSheet
1145
+ meta={meta}
1146
+ onChange={(patch) => setMeta(patch)}
1147
+ calendars={calendars}
1148
+ fields={eventFields}
1149
+ siteTimezone={siteTimezone}
1150
+ maps={maps}
1151
+ eventId={docId}
1152
+ title={meta.title}
1153
+ status={meta.status}
1154
+ tags={<TagInput tags={meta.tags} suggestions={tagSuggestions} onChange={(tags) => setMeta({ tags })} />}
1155
+ />
1156
+ </div>
1157
+ )}
1158
+ <div className={`pe-column ${kind === 'event' ? 'pe-ev-column' : ''}`}>
1159
+ {kind === 'event' && <div className="pe-evs-divider" id="ev-page"><span>Event page</span></div>}
1116
1160
  {postLike && (meta.cover ? (
1117
1161
  <figure className="pe-feature">
1118
1162
  <img src={meta.cover} alt={meta.coverAlt} />
@@ -1213,24 +1257,6 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
1213
1257
  />
1214
1258
  )}
1215
1259
 
1216
- {kind === 'event' && (
1217
- <div className="pe-byline pe-ev-byline">
1218
- <button type="button" className={`pe-ev-when ${meta.cancelled ? 'is-cancelled' : ''}`} title="Edit date & time" onClick={() => setSidebarOpen(true)}>
1219
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="4.5" width="18" height="16" rx="2" /><path d="M3 9.5h18M8 2.5v4M16 2.5v4" /></svg>
1220
- {meta.cancelled && <b>Cancelled · </b>}{eventWhenLabel(meta)}
1221
- </button>
1222
- {meta.rrule && <span className="pe-byline-meta">Repeats</span>}
1223
- {meta.location.name ? (
1224
- <button type="button" className="pe-byline-tag" title="Edit location" onClick={() => setSidebarOpen(true)}>{meta.location.name}</button>
1225
- ) : (
1226
- <button type="button" className="pe-byline-add" onClick={() => setSidebarOpen(true)}>+ Location</button>
1227
- )}
1228
- {calendars.filter((c) => meta.calendarIds.includes(c.id)).map((c) => (
1229
- <button key={c.id} type="button" className="pe-byline-tag pe-ev-cal" onClick={() => setSidebarOpen(true)}><i style={{ background: c.color }} aria-hidden="true" />{c.name}</button>
1230
- ))}
1231
- </div>
1232
- )}
1233
-
1234
1260
  {kind === 'post' && (
1235
1261
  <div className="pe-byline">
1236
1262
  {primaryAuthor && <span className="pe-byline-author"><i aria-hidden="true">{primaryAuthor.slice(0, 1).toUpperCase()}</i>{primaryAuthor}</span>}
@@ -1428,9 +1454,9 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
1428
1454
  </div>
1429
1455
 
1430
1456
  {sidebarOpen && (
1431
- <aside className="pe-sidebar" aria-label={kind === 'page' ? 'Page settings' : `${col.singular} settings`}>
1457
+ <aside className="pe-sidebar" aria-label={sidebarLabel}>
1432
1458
  <div className="pe-sidebar-head">
1433
- <span>{kind === 'page' ? 'Page settings' : `${col.singular} settings`}</span>
1459
+ <span>{sidebarLabel}</span>
1434
1460
  <button type="button" className="pe-btn ghost sm pe-icon-btn" aria-label="Close settings" onClick={() => setSidebarOpen(false)}>
1435
1461
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M6 6l12 12M18 6 6 18" /></svg>
1436
1462
  </button>
@@ -1467,7 +1493,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
1467
1493
  </div>
1468
1494
  )}
1469
1495
 
1470
- {postLike && (
1496
+ {postLike && kind !== 'event' && (
1471
1497
  <label className="pe-field">
1472
1498
  <span>Tags</span>
1473
1499
  <TagInput tags={meta.tags} suggestions={tagSuggestions} onChange={(tags) => setMeta({ tags })} />
@@ -1561,19 +1587,6 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
1561
1587
  )}
1562
1588
  </section>
1563
1589
 
1564
- {kind === 'event' && (
1565
- <EventPanel
1566
- eventId={docId}
1567
- meta={meta}
1568
- onChange={(patch) => setMeta(patch)}
1569
- calendars={calendars}
1570
- fields={eventFields}
1571
- siteTimezone={siteTimezone}
1572
- title={meta.title}
1573
- status={meta.status}
1574
- />
1575
- )}
1576
-
1577
1590
  {postLike && (
1578
1591
  <section className="pe-sb-section">
1579
1592
  <h3 className="pe-sb-title">Feature image &amp; excerpt</h3>
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useRef, useState } from 'react';
2
+ import { useFrameAway } from './frameNav';
2
3
 
3
4
  // Full-screen draft preview. Three views of the same draft:
4
5
  // Page — live iframe of the public route at desktop / tablet / phone width
@@ -58,6 +59,8 @@ export function PreviewOverlay({ open, url, shareUrl, meta, site, isPost, startV
58
59
  const [copied, setCopied] = useState(false);
59
60
  const [shareOpen, setShareOpen] = useState(false);
60
61
  const [refreshKey, setRefreshKey] = useState(0);
62
+ const pvFrame = useRef<HTMLIFrameElement>(null);
63
+ const pvNav = useFrameAway(pvFrame, url);
61
64
  const titleRef = useRef<HTMLInputElement>(null);
62
65
  const descRef = useRef<HTMLTextAreaElement>(null);
63
66
  const altRef = useRef<HTMLInputElement>(null);
@@ -203,6 +206,7 @@ export function PreviewOverlay({ open, url, shareUrl, meta, site, isPost, startV
203
206
  </div>
204
207
  )}
205
208
  </div>
209
+ {view === 'page' && pvNav.away && <button type="button" className="pe-btn sm" onClick={pvNav.back} title={`Viewing ${pvNav.away}`}>← Back to page</button>}
206
210
  <a className="pe-btn ghost sm pe-hide-sm" href={url} target="_blank" rel="noopener">Open ↗</a>
207
211
  </div>
208
212
  </div>
@@ -210,8 +214,10 @@ export function PreviewOverlay({ open, url, shareUrl, meta, site, isPost, startV
210
214
  <div className={`pe-preview-stage view-${view}`}>
211
215
  {view === 'page' && (
212
216
  <iframe
213
- key={refreshKey}
217
+ ref={pvFrame}
218
+ key={`${refreshKey}-${pvNav.nav}`}
214
219
  src={url}
220
+ onLoad={pvNav.check}
215
221
  title="Preview"
216
222
  style={{ width: device.width }}
217
223
  className={device.key !== 'desktop' ? 'is-framed' : ''}
@@ -0,0 +1,136 @@
1
+ import { useEffect, useRef, useState, type ReactNode } from 'react';
2
+ import { actions } from 'astro:actions';
3
+ import type { Place } from '../../../lib/schema';
4
+ import type { EventFieldConfig } from '../../../lib/calendar/config';
5
+ import type { MapsStatus } from '../../../lib/maps/types';
6
+ import { describeRepeat } from '../../../lib/calendar/rrule';
7
+ import RoomHolds from '../../calendar/RoomHolds';
8
+ import { eventWhenLabel, type CalendarOption, type EventMeta } from '../EventPanel';
9
+ import { Section } from './Section';
10
+ import { LocationField } from './LocationField';
11
+ import { OrganizeCard, StatusSection } from './OrganizeCard';
12
+ import { RegistrationCard } from './RegistrationCard';
13
+ import { WhenCard } from './WhenCard';
14
+ import { registrationMode } from './registration';
15
+
16
+ // Every event setting, on the page above the event's own content, as one
17
+ // settings column: When · Where · CTA · Organize · Status, with a
18
+ // sticky index. Wide panes put the index at the side; narrower panes turn it
19
+ // into a jump bar and stack each section's label over its controls
20
+ // (container queries on the editor pane, so Split mode counts).
21
+
22
+ interface Props {
23
+ meta: EventMeta;
24
+ onChange: (patch: Partial<EventMeta>) => void;
25
+ calendars: CalendarOption[];
26
+ fields: EventFieldConfig[];
27
+ siteTimezone: string;
28
+ maps: MapsStatus;
29
+ eventId: string;
30
+ title: string;
31
+ status: string;
32
+ tags?: ReactNode;
33
+ }
34
+
35
+ const SECTIONS = [
36
+ { id: 'ev-when', label: 'When' },
37
+ { id: 'ev-where', label: 'Where' },
38
+ { id: 'ev-registration', label: 'CTA' },
39
+ { id: 'ev-organize', label: 'Organize' },
40
+ { id: 'ev-status', label: 'Status' },
41
+ ] as const;
42
+
43
+ const jump = (id: string) => document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
44
+
45
+ export function EventSheet({ meta, onChange, calendars, fields, siteTimezone, maps, eventId, title, status, tags }: Props) {
46
+ const [places, setPlaces] = useState<Place[]>([]);
47
+ const [current, setCurrent] = useState<string>(SECTIONS[0].id);
48
+ const root = useRef<HTMLDivElement>(null);
49
+ useEffect(() => {
50
+ let live = true;
51
+ actions.places.list().then((r) => { if (live && r.data) setPlaces(r.data.places); }).catch(() => {});
52
+ return () => { live = false; };
53
+ }, []);
54
+
55
+ // the index follows the section nearest the top of the editor pane
56
+ useEffect(() => {
57
+ const el = root.current;
58
+ if (!el) return;
59
+ const scroller = el.closest('.pe-main');
60
+ // the current section is the last one whose top has passed a line 30%
61
+ // down the pane; observing every section re-checks as they move
62
+ const pick = () => {
63
+ const box = scroller?.getBoundingClientRect() ?? { top: 0, height: window.innerHeight };
64
+ const line = box.top + Math.min(box.height * 0.3, 240);
65
+ let id: string = SECTIONS[0].id;
66
+ for (const s of SECTIONS) { const n = document.getElementById(s.id); if (n && n.getBoundingClientRect().top <= line) id = s.id; }
67
+ setCurrent(id);
68
+ };
69
+ const io = new IntersectionObserver(pick, { root: scroller, threshold: [0, 0.25, 0.5, 0.75, 1] });
70
+ for (const s of SECTIONS) { const n = document.getElementById(s.id); if (n) io.observe(n); }
71
+ // tall sections can cross the line between thresholds
72
+ let raf = 0;
73
+ const onScroll = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(pick); };
74
+ scroller?.addEventListener('scroll', onScroll, { passive: true });
75
+ return () => { io.disconnect(); cancelAnimationFrame(raf); scroller?.removeEventListener('scroll', onScroll); };
76
+ }, []);
77
+
78
+ const mode = registrationMode(meta);
79
+ const when = eventWhenLabel(meta);
80
+ const repeats = describeRepeat(meta.rrule, meta.start);
81
+ const regLabel = mode === 'rsvp' ? `Form${meta.rsvp.capacity ? ` · ${meta.rsvp.capacity}` : ''}` : mode === 'link' ? meta.cta.label || 'Link' : 'None';
82
+ const cals = calendars.filter((c) => meta.calendarIds.includes(c.id));
83
+ const values: Record<string, string> = {
84
+ 'ev-when': when.split(' · ')[0],
85
+ 'ev-where': meta.location.name || 'Not set',
86
+ 'ev-registration': regLabel,
87
+ 'ev-organize': cals.length ? `${cals[0].name}${cals.length > 1 ? ` +${cals.length - 1}` : ''}` : 'Main calendar',
88
+ 'ev-status': meta.cancelled ? 'Cancelled' : 'Going ahead',
89
+ };
90
+ const summary = [when, repeats, meta.location.name, mode === 'rsvp' ? 'Built-in form' : mode === 'link' ? meta.cta.label || 'CTA link' : ''].filter(Boolean);
91
+
92
+ return (
93
+ <div className="pe-evs" aria-label="Event settings" role="group" ref={root}>
94
+ <nav className="pe-evs-index" aria-label="Event settings sections">
95
+ {SECTIONS.map((s) => (
96
+ <button key={s.id} type="button" className={current === s.id ? 'is-current' : ''} aria-current={current === s.id ? 'true' : undefined} onClick={() => jump(s.id)}>
97
+ <b>{s.label}</b>
98
+ <small className={s.id === 'ev-status' && meta.cancelled ? 'is-cancelled' : ''}>{values[s.id]}</small>
99
+ </button>
100
+ ))}
101
+ <button type="button" className="pe-evs-index-page" onClick={() => jump('ev-page')}>Event page ↓</button>
102
+ </nav>
103
+ <div className="pe-evs-col">
104
+ <p className={`pe-evs-sum ${meta.cancelled ? 'is-cancelled' : ''}`}>
105
+ <b>{title || 'Untitled event'}</b>
106
+ <span>{meta.cancelled && <em>Cancelled · </em>}{summary.join(' · ')}</span>
107
+ </p>
108
+ <WhenCard meta={meta} onChange={onChange} siteTimezone={siteTimezone} eventId={eventId} />
109
+ <Section
110
+ id="ev-where"
111
+ title="Where"
112
+ help={<>Pick a saved place, paste a map link, or type an address. <a href="/admin/calendar/settings#places" target="_blank" rel="noopener">Manage places ↗</a></>}
113
+ >
114
+ <LocationField
115
+ location={meta.location}
116
+ onChange={(location) => onChange({ location })}
117
+ maps={maps}
118
+ places={places}
119
+ onPlaceSaved={(p) => setPlaces((list) => [...list.filter((x) => x.id !== p.id), p])}
120
+ />
121
+ <RoomHolds
122
+ times={{ eventId, title, start: meta.start, end: meta.end, allDay: meta.allDay, timezone: meta.timezone, rrule: meta.rrule, exdates: meta.exdates }}
123
+ hold={meta.roomHold}
124
+ status={status}
125
+ locationName={meta.location.name}
126
+ onChange={(roomHold) => onChange({ roomHold })}
127
+ onLocation={(name) => onChange({ location: { ...meta.location, name, placeId: '' } })}
128
+ />
129
+ </Section>
130
+ <RegistrationCard meta={meta} onChange={onChange} eventId={eventId} title={title} />
131
+ <OrganizeCard meta={meta} onChange={onChange} calendars={calendars} fields={fields} tags={tags} />
132
+ <StatusSection meta={meta} onChange={onChange} />
133
+ </div>
134
+ </div>
135
+ );
136
+ }