@acms-subir/core 0.3.5 → 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 (118) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/cli/remote.mjs +1 -1
  3. package/docs/theme-authoring.md +5 -2
  4. package/integration.mjs +38 -2
  5. package/package.json +1 -1
  6. package/src/actions/index.ts +148 -2
  7. package/src/components/DesignManager.tsx +34 -3
  8. package/src/components/EventPage.astro +25 -5
  9. package/src/components/calendar/BookingsHead.astro +4 -3
  10. package/src/components/calendar/RoomHolds.tsx +13 -8
  11. package/src/components/editor/EventPanel.tsx +9 -383
  12. package/src/components/editor/LivePane.tsx +16 -3
  13. package/src/components/editor/PostEditor.tsx +59 -46
  14. package/src/components/editor/PreviewOverlay.tsx +7 -1
  15. package/src/components/editor/event/EventSheet.tsx +136 -0
  16. package/src/components/editor/event/LocationField.tsx +266 -0
  17. package/src/components/editor/event/OrganizeCard.tsx +87 -0
  18. package/src/components/editor/event/RegistrationCard.tsx +114 -0
  19. package/src/components/editor/event/RegistrationFormEditor.tsx +142 -0
  20. package/src/components/editor/event/Section.tsx +27 -0
  21. package/src/components/editor/event/WhenCard.tsx +217 -0
  22. package/src/components/editor/event/dates.ts +28 -0
  23. package/src/components/editor/event/registration.ts +39 -0
  24. package/src/components/editor/extensions/form.tsx +60 -0
  25. package/src/components/editor/extensions/slash.tsx +5 -0
  26. package/src/components/editor/frameNav.ts +20 -0
  27. package/src/components/forms/Canvas.tsx +149 -0
  28. package/src/components/forms/FormBuildPage.tsx +155 -0
  29. package/src/components/forms/FormBuilder.tsx +283 -0
  30. package/src/components/forms/FormHeader.astro +31 -0
  31. package/src/components/forms/InsertMenu.tsx +77 -0
  32. package/src/components/forms/Inspector.tsx +232 -0
  33. package/src/components/forms/Outline.tsx +93 -0
  34. package/src/components/forms/ResponsesTable.tsx +123 -0
  35. package/src/components/forms/TypeIcon.tsx +24 -0
  36. package/src/components/forms/dnd.ts +3 -0
  37. package/src/components/forms/meta.ts +84 -0
  38. package/src/components/forms/useFormHistory.ts +41 -0
  39. package/src/components/import/ImportExport.tsx +1 -1
  40. package/src/components/site/SiteLogo.astro +23 -0
  41. package/src/layouts/Admin.astro +3 -2
  42. package/src/layouts/Site.astro +5 -4
  43. package/src/lib/backup/content.ts +10 -5
  44. package/src/lib/booking/service.ts +6 -2
  45. package/src/lib/calendar/agent-schema.ts +7 -1
  46. package/src/lib/calendar/agent.ts +28 -3
  47. package/src/lib/calendar/api.ts +2 -0
  48. package/src/lib/calendar/ics-write.ts +2 -0
  49. package/src/lib/calendar/ics.ts +4 -0
  50. package/src/lib/calendar/import.ts +3 -3
  51. package/src/lib/calendar/schedule.ts +6 -2
  52. package/src/lib/calendar/seo.ts +4 -1
  53. package/src/lib/content-service.ts +30 -4
  54. package/src/lib/design-service.ts +2 -2
  55. package/src/lib/designDiff.ts +2 -2
  56. package/src/lib/drafts.ts +11 -2
  57. package/src/lib/dynamicBlocks.ts +14 -3
  58. package/src/lib/editor/documents.ts +12 -3
  59. package/src/lib/editor/schemas.ts +4 -3
  60. package/src/lib/forms/events.ts +40 -0
  61. package/src/lib/forms/export.ts +62 -0
  62. package/src/lib/forms/fields.ts +50 -0
  63. package/src/lib/forms/http.ts +29 -0
  64. package/src/lib/forms/render.ts +11 -0
  65. package/src/lib/forms/schema.ts +56 -0
  66. package/src/lib/forms/service.ts +38 -0
  67. package/src/lib/forms/store.ts +78 -0
  68. package/src/lib/forms/validate.ts +71 -0
  69. package/src/lib/import/bundle.ts +1 -0
  70. package/src/lib/import/export.ts +1 -1
  71. package/src/lib/maps/config.ts +64 -0
  72. package/src/lib/maps/migration.ts +12 -0
  73. package/src/lib/maps/parse.ts +180 -0
  74. package/src/lib/maps/places.ts +109 -0
  75. package/src/lib/maps/providers/geoapify.ts +36 -0
  76. package/src/lib/maps/providers/google.ts +54 -0
  77. package/src/lib/maps/providers/http.ts +96 -0
  78. package/src/lib/maps/providers/index.ts +37 -0
  79. package/src/lib/maps/providers/locationiq.ts +35 -0
  80. package/src/lib/maps/providers/mapbox.ts +38 -0
  81. package/src/lib/maps/providers/osm.ts +59 -0
  82. package/src/lib/maps/providers/pelias.ts +51 -0
  83. package/src/lib/maps/service.ts +281 -0
  84. package/src/lib/maps/types.ts +35 -0
  85. package/src/lib/mobile/bookings.ts +1 -1
  86. package/src/lib/mobile/schemas.ts +3 -3
  87. package/src/lib/mobile/site.ts +1 -1
  88. package/src/lib/repo/migrations.ts +11 -0
  89. package/src/lib/repo/sql.ts +104 -14
  90. package/src/lib/repo/types.ts +22 -2
  91. package/src/lib/schema.ts +39 -0
  92. package/src/lib/site-chrome.ts +1 -0
  93. package/src/lib/umami.ts +8 -1
  94. package/src/pages/admin/calendar/bookings.astro +12 -7
  95. package/src/pages/admin/calendar/events/[id].astro +5 -2
  96. package/src/pages/admin/calendar/events/index.astro +3 -0
  97. package/src/pages/admin/calendar/resources/[id].astro +11 -0
  98. package/src/pages/admin/calendar/settings.astro +140 -3
  99. package/src/pages/admin/forms/[id]/share.astro +121 -0
  100. package/src/pages/admin/forms/[id]/submissions.astro +143 -0
  101. package/src/pages/admin/forms/[id].astro +27 -0
  102. package/src/pages/admin/forms/index.astro +198 -0
  103. package/src/pages/admin/integrations.astro +57 -0
  104. package/src/pages/api/agent/index.ts +1 -1
  105. package/src/pages/api/calendar/docs.ts +2 -0
  106. package/src/pages/api/calendar/events/[id].ts +1 -1
  107. package/src/pages/api/calendar/events/index.ts +1 -1
  108. package/src/pages/api/mcp.ts +1 -1
  109. package/src/pages/book/manage/[token].astro +4 -4
  110. package/src/pages/events/[slug].astro +24 -10
  111. package/src/pages/forms/[id].astro +27 -0
  112. package/src/styles/admin.css +2 -2
  113. package/src/styles/editor.css +275 -34
  114. package/src/styles/form-block.css +38 -0
  115. package/src/styles/forms-admin.css +184 -0
  116. package/src/styles/forms.css +281 -0
  117. package/src/styles/site.css +43 -0
  118. package/src/virtual.d.ts +2 -0
@@ -9,9 +9,14 @@ import { normalizeCalendarSrc } from '../../../lib/calendar/src';
9
9
  import { loadFeed } from '../../../lib/calendar/ics';
10
10
  import { siteApproval, APPROVAL_LABEL } from '../../../lib/booking/approval';
11
11
  import { siteOrigin } from '../../../lib/seo';
12
+ import { placeUsage, savePlace } from '../../../lib/maps/places';
13
+ import { parseMapLink } from '../../../lib/maps/parse';
14
+ import { ApiError } from '../../../lib/api';
15
+ import type { Place } from '../../../lib/schema';
12
16
 
13
17
  // Calendar settings — one page behind the ⚙ on Schedule and Bookings:
14
- // calendars (groups + colors), subscribed calendars (read-only, synced in),
18
+ // calendars (groups + colors), saved places (picked in an event's Where),
19
+ // subscribed calendars (read-only, synced in),
15
20
  // share links (out to Google/Apple/Outlook), booking approval, defaults.
16
21
  // Replaces the old Calendars page and the duplicate subscribe dialogs.
17
22
  if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
@@ -77,6 +82,34 @@ if (Astro.request.method === 'POST') {
77
82
  }
78
83
  return back(reset ? `Approval saved — ${reset} booking link${reset === 1 ? '' : 's'} now follow it.` : 'Approval saved.', '#bookings');
79
84
  }
85
+ if (op === 'place-save') {
86
+ // the map link field is parsed in the browser (maps.resolve, short links
87
+ // included); without JS, a full map link is still read here
88
+ const num = (k: string) => { const v = String(form.get(k) ?? '').trim(); return v === '' ? null : Number(v); };
89
+ let lat = num('lat');
90
+ let lng = num('lng');
91
+ const url = String(form.get('url') || '').trim();
92
+ let address = String(form.get('address') || '');
93
+ if ((lat === null || lng === null) && url) {
94
+ const parsed = parseMapLink(url);
95
+ if (parsed?.lat !== null && parsed?.lat !== undefined) { lat = parsed.lat; lng = parsed.lng; }
96
+ if (!address.trim() && parsed?.address) address = parsed.address;
97
+ }
98
+ try {
99
+ const { place, updatedEvents } = await savePlace(repo, {
100
+ id: id || undefined, name, address, url, lat: Number.isFinite(lat) ? lat : null, lng: Number.isFinite(lng) ? lng : null,
101
+ notes: String(form.get('notes') || ''), isDefault: form.has('isDefault'),
102
+ }, { locals: Astro.locals, origin });
103
+ return back(`Saved “${place.name}”${updatedEvents ? ` — ${updatedEvents} upcoming event${updatedEvents === 1 ? '' : 's'} updated` : ''}.`, '#places');
104
+ } catch (err) {
105
+ return back(err instanceof ApiError ? err.message : 'Couldn’t save that place.', '#places');
106
+ }
107
+ }
108
+ if (op === 'place-delete') {
109
+ const place = id ? await repo.getPlace(id) : null;
110
+ if (place) await repo.deletePlace(place.id);
111
+ return back(place ? `Deleted “${place.name}”. Events that used it keep their address.` : undefined, '#places');
112
+ }
80
113
  if (op === 'create' || op === 'subscribe') {
81
114
  if (!name) return back('Give the calendar a name.');
82
115
  let feedUrl = '';
@@ -122,7 +155,9 @@ if (Astro.request.method === 'POST') {
122
155
  }
123
156
  }
124
157
 
125
- const [calendars, events, links] = await Promise.all([repo.listCalendars(), repo.listEvents(), bookingsEnabled() ? repo.listBookingLinks() : Promise.resolve([])]);
158
+ const [calendars, events, links, places] = await Promise.all([repo.listCalendars(), repo.listEvents(), bookingsEnabled() ? repo.listBookingLinks() : Promise.resolve([]), repo.listPlaces()]);
159
+ const placeCounts = placeUsage(events, settings);
160
+ const placeLine = (p: Place) => [p.address, `${placeCounts.get(p.id) ?? 0} upcoming event${placeCounts.get(p.id) === 1 ? '' : 's'}`].filter(Boolean).join(' · ');
126
161
  const natives = calendars.filter((c) => c.kind === 'native');
127
162
  const feeds = calendars.filter((c) => c.kind === 'feed');
128
163
  const counts = new Map<string, number>();
@@ -148,9 +183,10 @@ const APPROVAL_NOTE: Record<ApprovalMode, string> = {
148
183
  const I = {
149
184
  up: 'M6 15l6-6 6 6', down: 'M6 9l6 6 6-6',
150
185
  ext: 'M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5',
186
+ pin: 'M12 22s7-7 7-12a7 7 0 0 0-14 0c0 5 7 12 7 12zM12 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z',
151
187
  };
152
188
  const sections = [
153
- ['calendars', 'Calendars'], ['subscribed', 'Subscribed calendars'], ['share', 'Share links'],
189
+ ['calendars', 'Calendars'], ['places', 'Places'], ['subscribed', 'Subscribed calendars'], ['share', 'Share links'],
154
190
  ...(bookingsEnabled() ? [['bookings', 'Bookings']] : []), ['defaults', 'Defaults'],
155
191
  ] as const;
156
192
  ---
@@ -204,6 +240,28 @@ const sections = [
204
240
  ))}
205
241
  </section>
206
242
 
243
+ <!-- Places -->
244
+ <section class="cs-sect" id="places">
245
+ <header>
246
+ <div><h2>Places</h2><p>Where your events usually happen. Pick one in an event’s Where field to fill in the address and map. Changing a place updates its upcoming events.</p></div>
247
+ <button class="btn" type="button" data-modal="place-new">+ New place</button>
248
+ </header>
249
+ {places.length === 0 ? (
250
+ <p class="cs-empty">No saved places yet. Add your building, a park or a partner venue once instead of typing it into every event.</p>
251
+ ) : places.map((p) => (
252
+ <div class="cs-row" id={`place-${p.id}`}>
253
+ <svg class="cs-pin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={I.pin} /></svg>
254
+ <div class="cs-main">
255
+ <b>{p.name}{p.isDefault && <span class="cs-default">Default</span>}</b>
256
+ <small>{placeLine(p)}{p.lat === null ? ' · no pin' : ''}</small>
257
+ </div>
258
+ <div class="cs-actions">
259
+ <button class="btn sm" type="button" data-modal={`place-edit-${p.id}`}>Edit</button>
260
+ </div>
261
+ </div>
262
+ ))}
263
+ </section>
264
+
207
265
  <!-- Subscribed -->
208
266
  <section class="cs-sect" id="subscribed">
209
267
  <header>
@@ -347,6 +405,37 @@ const sections = [
347
405
  </Modal>
348
406
  ))}
349
407
 
408
+ {[...places, null].map((p: Place | null) => (
409
+ <Modal id={p ? `place-edit-${p.id}` : 'place-new'} title={p ? `Edit ${p.name}` : 'New place'}>
410
+ <form method="post" data-place-form>
411
+ <input type="hidden" name="op" value="place-save" />
412
+ {p && <input type="hidden" name="id" value={p.id} />}
413
+ <input type="hidden" name="lat" value={p?.lat ?? ''} data-lat />
414
+ <input type="hidden" name="lng" value={p?.lng ?? ''} data-lng />
415
+ <label class="field"><span>Map link <em class="hint">paste from Google Maps, Apple Maps or OpenStreetMap — or a map embed code</em></span>
416
+ <input class="mono" type="text" name="url" value={p?.url ?? ''} placeholder="https://maps.app.goo.gl/…" data-maplink autocomplete="off" />
417
+ </label>
418
+ <p class="cs-pinline" data-pinline>
419
+ <span data-pintext>{p?.lat != null && p?.lng != null ? `Pin set at ${p.lat.toFixed(5)}, ${p.lng.toFixed(5)}` : 'No pin yet — paste a map link to set one.'}</span>
420
+ <button class="btn sm ghost" type="button" data-clearpin hidden={p?.lat == null}>Remove pin</button>
421
+ </p>
422
+ <label class="field"><span>Name</span><input type="text" name="name" value={p?.name ?? ''} placeholder="Main building" required data-name /></label>
423
+ <label class="field"><span>Address</span><input type="text" name="address" value={p?.address ?? ''} placeholder="Street, city" data-address /></label>
424
+ <label class="field"><span>Notes <em class="hint">for editors only</em></span><textarea name="notes" rows="2" placeholder="Use the side door after 6 PM">{p?.notes ?? ''}</textarea></label>
425
+ <label class="check-row"><input type="checkbox" name="isDefault" checked={p?.isDefault ?? false} /> New events start here</label>
426
+ {p?.mapImage && (
427
+ <figure class="cs-map"><img src={p.mapImage} alt={`Map of ${p.name}`} width="1200" height="500" loading="lazy" /></figure>
428
+ )}
429
+ <div class="modal-foot">
430
+ {p && <button class="btn danger ghost" type="submit" name="op" value="place-delete" formnovalidate data-confirm={`Delete “${p.name}”? Events that used it keep their address.`}>Delete</button>}
431
+ <span class="spacer" />
432
+ <button class="btn ghost" type="button" data-close>Cancel</button>
433
+ <button class="btn primary" type="submit">{p ? 'Save' : 'Add place'}</button>
434
+ </div>
435
+ </form>
436
+ </Modal>
437
+ ))}
438
+
350
439
  <Modal id="cal-new" title="New calendar" open={Astro.url.searchParams.has('new')}>
351
440
  <form method="post">
352
441
  <input type="hidden" name="op" value="create" />
@@ -424,6 +513,14 @@ const sections = [
424
513
  .cs-field { display: grid; grid-template-columns: 160px auto; align-items: center; gap: 10px; font-size: 13px; color: var(--text-2); justify-content: start; }
425
514
  .cs-foot { display: flex; align-items: center; gap: 12px; justify-content: space-between; flex-wrap: wrap; border-top: 1px solid var(--line); padding-top: 12px; }
426
515
  .cs-foot small { color: var(--muted); font-size: 12px; }
516
+ .cs-pin { width: 14px; height: 14px; color: var(--muted); }
517
+ .cs-default { display: inline-flex; align-items: center; gap: 5px; margin-left: 8px; font-size: 11.5px; font-weight: 600; color: var(--text-2); vertical-align: 1px; }
518
+ .cs-default::before { content: ''; width: 6px; height: 6px; border-radius: 2px; background: var(--accent); }
519
+ .cs-pinline { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin: -4px 0 12px; font-size: 12px; color: var(--muted); }
520
+ .cs-pinline.is-busy span { opacity: 0.6; }
521
+ .cs-pinline.is-error span { color: var(--red); }
522
+ .cs-map { margin: 0 0 4px; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
523
+ .cs-map img { display: block; width: 100%; height: auto; }
427
524
  [hidden] { display: none !important; }
428
525
  @media (max-width: 860px) {
429
526
  .cs { grid-template-columns: 1fr; }
@@ -437,6 +534,8 @@ const sections = [
437
534
  </style>
438
535
 
439
536
  <script>
537
+ import { actions } from 'astro:actions';
538
+
440
539
  document.addEventListener('click', async (e) => {
441
540
  const b = e.target instanceof Element ? e.target.closest<HTMLButtonElement>('[data-copy]') : null;
442
541
  if (b) {
@@ -448,6 +547,44 @@ const sections = [
448
547
  const c = e.target instanceof Element ? e.target.closest<HTMLElement>('[data-confirm]') : null;
449
548
  if (c && !confirm(c.dataset.confirm)) e.preventDefault();
450
549
  });
550
+ // places: a pasted map link fills the pin (and name/address when empty).
551
+ // maps.resolve expands short links server-side; nothing needs a maps key.
552
+ for (const f of document.querySelectorAll<HTMLFormElement>('form[data-place-form]')) {
553
+ const link = f.querySelector<HTMLInputElement>('[data-maplink]')!;
554
+ const lat = f.querySelector<HTMLInputElement>('[data-lat]')!;
555
+ const lng = f.querySelector<HTMLInputElement>('[data-lng]')!;
556
+ const line = f.querySelector<HTMLElement>('[data-pinline]')!;
557
+ const text = f.querySelector<HTMLElement>('[data-pintext]')!;
558
+ const clear = f.querySelector<HTMLButtonElement>('[data-clearpin]')!;
559
+ const name = f.querySelector<HTMLInputElement>('[data-name]')!;
560
+ const address = f.querySelector<HTMLInputElement>('[data-address]')!;
561
+ const show = (msg: string, state = '') => { text.textContent = msg; line.className = `cs-pinline${state ? ` is-${state}` : ''}`; clear.hidden = !lat.value; };
562
+ let last = link.value.trim();
563
+ const resolve = async () => {
564
+ const raw = link.value.trim();
565
+ if (raw === last) return;
566
+ last = raw;
567
+ if (!raw) return;
568
+ show('Reading the link…', 'busy');
569
+ const { data, error } = await actions.maps.resolve({ url: raw });
570
+ if (link.value.trim() !== raw) return;
571
+ const loc = data?.location;
572
+ if (error || !loc) { show('That link didn’t include a place we could read. You can still type the address.', 'error'); return; }
573
+ if (loc.kind === 'meeting') { show('That’s an online meeting link — saved places are for physical places.', 'error'); return; }
574
+ // an embed's src isn't a link people can open: keep the field tidy
575
+ if (loc.url !== raw) { link.value = loc.url; last = loc.url; }
576
+ if (!name.value.trim() && loc.name) name.value = loc.name;
577
+ if (!address.value.trim() && loc.address) address.value = loc.address;
578
+ if (loc.lat !== null && loc.lng !== null) {
579
+ lat.value = String(loc.lat); lng.value = String(loc.lng);
580
+ show(`Pin set at ${loc.lat.toFixed(5)}, ${loc.lng.toFixed(5)}`);
581
+ } else show('No pin in that link — the address was filled in.');
582
+ };
583
+ link.addEventListener('paste', () => setTimeout(resolve, 0));
584
+ link.addEventListener('change', resolve);
585
+ clear.addEventListener('click', () => { lat.value = ''; lng.value = ''; show('Pin removed — it’s gone when you save.'); });
586
+ }
587
+
451
588
  // approval: rules only for "unless…", the deadline only when bookings can wait
452
589
  const form = document.querySelector<HTMLFormElement>('[data-approval]');
453
590
  form?.addEventListener('change', () => {
@@ -0,0 +1,121 @@
1
+ ---
2
+ import Admin from '../../../../layouts/Admin.astro';
3
+ import FormHeader from '../../../../components/forms/FormHeader.astro';
4
+ import { getRepo } from '../../../../lib/repo';
5
+ import { readForm } from '../../../../lib/forms/http';
6
+ import { escapeForm } from '../../../../lib/forms/fields';
7
+ import { siteOrigin } from '../../../../lib/seo';
8
+ import { CONTENT } from '../../../../lib/content';
9
+ import '../../../../styles/forms-admin.css';
10
+
11
+ const repo = await getRepo(Astro.locals);
12
+ let form = await repo.forms.get(Astro.params.id || '');
13
+ if (!form || form.kind !== 'form') return new Response(null, { status: 404 });
14
+ Astro.response.headers.set('Cache-Control', 'private, no-store');
15
+
16
+ let error = '', notice = '';
17
+ if (Astro.request.method === 'POST') {
18
+ try {
19
+ const data = await readForm(Astro.request);
20
+ const op = String(data.get('op') || '');
21
+ if (op !== 'publish' && op !== 'archive') throw new Error('Unknown action.');
22
+ // Same store operation as the Build tab: publish promotes pending edits.
23
+ await repo.forms.save(form.id, Number(data.get('revision')), form.name, form.draft ?? form.definition, op);
24
+ form = (await repo.forms.get(form.id))!;
25
+ notice = op === 'publish' ? 'Published. The form is accepting responses.' : 'Archived. The form no longer accepts responses; existing responses are kept.';
26
+ } catch (e) { error = e instanceof Error ? e.message : 'Could not update the form.'; }
27
+ }
28
+
29
+ const [settings, stats, used] = await Promise.all([repo.getSettings(), repo.forms.submissionStats(form.id, 0), repo.forms.usedOn(form.id)]);
30
+ const origin = siteOrigin(settings, Astro.url);
31
+ const link = `${origin}/forms/${form.id}`;
32
+ const title = escapeForm(form.definition.title || form.name);
33
+ const embed = `<iframe src="${link}?embed=1" title="${title}" loading="lazy" style="width:100%;min-height:560px;border:0"></iframe>`;
34
+ const live = form.status === 'published';
35
+ const state = live ? (form.draft ? ['Accepting responses', 'green', 'Visitors see the last published version. Publish to apply your changes.'] : ['Accepting responses', 'green', 'Anyone with the link, or on a page with this form, can respond.'])
36
+ : form.status === 'archived' ? ['Not accepting responses', 'muted', 'Archived. The link and embedded copies are hidden. Responses are kept.']
37
+ : ['Not accepting responses', 'amber', 'Publish the form to open the link and show it on pages.'];
38
+ const editHref = (u: (typeof used)[number]) => u.kind === 'page' ? `/admin/pages/${u.id}` : u.kind === 'event' ? `/admin/calendar/events/${u.id}` : `/admin/c/${u.collection}/${u.id}`;
39
+ const kindLabel = (u: (typeof used)[number]) => u.kind === 'page' ? 'Page' : u.kind === 'event' ? 'Event' : (CONTENT.collections[u.collection]?.singular ?? 'Post');
40
+ const copyIcon = 'M9 9h10v10H9zM5 15V5h10';
41
+ ---
42
+ <Admin title={`${form.name} · Share`} section="forms">
43
+ <FormHeader form={form} active="share" responses={stats.total}>
44
+ {live && <a slot="actions" class="btn" href={`/forms/${form.id}`} target="_blank" rel="noopener">Open form ↗</a>}
45
+ </FormHeader>
46
+ {error && <p class="notice warn" role="alert">{error}</p>}
47
+ {notice && <p class="notice" role="status">{notice}</p>}
48
+
49
+ <div class="fs">
50
+ <section class="fs-sec">
51
+ <div class="fs-label"><b>Responses</b><small>Publishing opens the form; archiving closes it.</small></div>
52
+ <div class="fs-body">
53
+ <div class="fs-state">
54
+ <div>
55
+ <p class={`fst fst-${state[1]} fs-state-title`}><i aria-hidden="true" />{state[0]}</p>
56
+ <p class="hint">{state[2]}</p>
57
+ </div>
58
+ <form method="post" class="fs-state-actions">
59
+ <input type="hidden" name="revision" value={form.revision} />
60
+ {(!live || form.draft) && <button class="btn primary" name="op" value="publish">{form.draft ? 'Publish changes' : form.status === 'archived' ? 'Reopen and publish' : 'Publish'}</button>}
61
+ {live && <button class="btn" name="op" value="archive">Stop accepting responses</button>}
62
+ </form>
63
+ </div>
64
+ </div>
65
+ </section>
66
+
67
+ <section class="fs-sec">
68
+ <div class="fs-label"><b>Public link</b><small>A page with just this form.</small></div>
69
+ <div class="fs-body">
70
+ <div class="fs-copy">
71
+ <input type="text" class="mono" readonly value={link} aria-label="Public link" id="fs-link" />
72
+ <button class="btn" type="button" data-copy="fs-link"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={copyIcon} /></svg><span>Copy</span></button>
73
+ </div>
74
+ {!live && <p class="hint">The link opens once the form is published.</p>}
75
+ </div>
76
+ </section>
77
+
78
+ <section class="fs-sec">
79
+ <div class="fs-label"><b>Embed</b><small>Paste into another website. On this site, add a Form block to a page instead.</small></div>
80
+ <div class="fs-body">
81
+ <textarea class="code fs-embed" readonly rows="3" id="fs-embed" aria-label="Embed code">{embed}</textarea>
82
+ <div class="fs-row"><button class="btn" type="button" data-copy="fs-embed"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={copyIcon} /></svg><span>Copy code</span></button></div>
83
+ </div>
84
+ </section>
85
+
86
+ <section class="fs-sec">
87
+ <div class="fs-label"><b>Used on</b><small>Pages, posts and events with a Form block for this form.</small></div>
88
+ <div class="fs-body">
89
+ {used.length ? (
90
+ <ul class="fs-used">
91
+ {used.map(u => (
92
+ <li>
93
+ <a href={editHref(u)}><b>{u.title || 'Untitled'}</b><small>{kindLabel(u)} · <span class={`status ${u.status}`}>{u.status[0].toUpperCase() + u.status.slice(1)}</span></small></a>
94
+ <a class="btn sm ghost" href={editHref(u)}>Edit</a>
95
+ </li>
96
+ ))}
97
+ </ul>
98
+ ) : <p class="hint fs-none">Not on any page yet. In the editor, type <kbd>/</kbd> and choose Form.</p>}
99
+ </div>
100
+ </section>
101
+
102
+ <section class="fs-sec">
103
+ <div class="fs-label"><b>Notifications</b></div>
104
+ <div class="fs-body"><p class="hint">Email alerts and webhooks for new responses are coming later. For now, check Responses or export a CSV.</p></div>
105
+ </section>
106
+ </div>
107
+ </Admin>
108
+
109
+ <script>
110
+ document.addEventListener('click', async (e) => {
111
+ const b = e.target instanceof Element ? e.target.closest<HTMLButtonElement>('[data-copy]') : null;
112
+ if (!b) return;
113
+ const el = document.getElementById(b.dataset.copy!) as HTMLInputElement | HTMLTextAreaElement | null;
114
+ if (!el) return;
115
+ const label = b.querySelector('span')!;
116
+ const was = label.textContent;
117
+ try { await navigator.clipboard.writeText(el.value); label.textContent = 'Copied'; }
118
+ catch { el.select(); label.textContent = 'Press ⌘C'; }
119
+ setTimeout(() => (label.textContent = was), 1600);
120
+ });
121
+ </script>
@@ -0,0 +1,143 @@
1
+ ---
2
+ import Admin from '../../../../layouts/Admin.astro';
3
+ import FormHeader from '../../../../components/forms/FormHeader.astro';
4
+ import { ResponsesTable, type ResponseRow } from '../../../../components/forms/ResponsesTable';
5
+ import { getRepo } from '../../../../lib/repo';
6
+ import { readForm } from '../../../../lib/forms/http';
7
+ import { csv, displayAnswers, formVersion } from '../../../../lib/forms/service';
8
+ import { answerFields, relativeTime, versionLabeler, wideRows } from '../../../../lib/forms/export';
9
+ import type { FormSubmission } from '../../../../lib/forms/schema';
10
+ import '../../../../styles/forms-admin.css';
11
+
12
+ const repo = await getRepo(Astro.locals);
13
+ const form = await repo.forms.get(Astro.params.id || '');
14
+ if (!form || form.kind !== 'form') return new Response(null, { status: 404 });
15
+ Astro.response.headers.set('Cache-Control', 'private, no-store');
16
+
17
+ const DAY = 86_400_000;
18
+ const range = (['7', '30'] as const).find(r => r === Astro.url.searchParams.get('range')) ?? '';
19
+ const q = (Astro.url.searchParams.get('q') ?? '').trim().slice(0, 100);
20
+ const since = range ? Date.now() - Number(range) * DAY : undefined;
21
+ const [versions, current] = await Promise.all([repo.forms.versions(form.id), formVersion(form.definition)]);
22
+ const version = versionLabeler(versions, current);
23
+ const download = (rows: FormSubmission[]) => {
24
+ const slug = form.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || 'form';
25
+ return new Response(csv(wideRows(form.definition.fields, rows, version)), { headers: { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="${slug}-responses.csv"`, 'Cache-Control': 'private, no-store' } });
26
+ };
27
+
28
+ let error = '';
29
+ if (Astro.request.method === 'POST') {
30
+ try {
31
+ const data = await readForm(Astro.request);
32
+ const op = String(data.get('op') || 'delete');
33
+ const ids = data.getAll('ids').map(String).filter(Boolean).slice(0, 500);
34
+ if (op === 'export') return download(await repo.forms.searchSubmissions(form.id, { ids, limit: 500 }));
35
+ if (op === 'bulkDelete') await repo.forms.deleteSubmissions(form.id, ids);
36
+ else if (op === 'delete') await repo.forms.deleteSubmission(form.id, String(data.get('id') || ''));
37
+ else throw new Error('Unknown action.');
38
+ const n = op === 'bulkDelete' ? ids.length : 1;
39
+ const back = new URL(Astro.url); back.searchParams.set('notice', `${n} response${n === 1 ? '' : 's'} deleted.`);
40
+ return Astro.redirect(back.pathname + back.search, 303);
41
+ } catch (e) { error = e instanceof Error ? e.message : 'Could not update responses.'; }
42
+ }
43
+
44
+ const page = Math.max(0, Math.floor(Number(Astro.url.searchParams.get('page')) || 0));
45
+ if (Astro.url.searchParams.has('export')) {
46
+ const rows = await repo.forms.searchSubmissions(form.id, { since, q, limit: 10001 });
47
+ if (rows.length > 10000) return new Response('Export is limited to 10,000 responses. Use a content backup for the complete archive.', { status: 413 });
48
+ return download(rows);
49
+ }
50
+ const [rows, stats, settings] = await Promise.all([
51
+ repo.forms.searchSubmissions(form.id, { since, q, limit: 101, offset: page * 100 }),
52
+ repo.forms.submissionStats(form.id, Date.now() - 7 * DAY),
53
+ repo.getSettings(),
54
+ ]);
55
+ const tz = settings.timezone || undefined;
56
+ const notice = Astro.url.searchParams.get('notice')?.slice(0, 120) ?? '';
57
+ const columns = answerFields(form.definition.fields).map(f => ({ id: f.id, label: f.label }));
58
+ const currentIds = new Set(columns.map(c => c.id));
59
+ const fmt = (ts: number, o: Intl.DateTimeFormatOptions) => { try { return new Date(ts).toLocaleString('en-US', { ...o, timeZone: tz }); } catch { return new Date(ts).toLocaleString('en-US', o); } };
60
+ const shortWhen = (ts: number) => {
61
+ const day = (t: number) => fmt(t, { year: 'numeric', month: 'numeric', day: 'numeric' });
62
+ const time = fmt(ts, { hour: 'numeric', minute: '2-digit' });
63
+ if (day(ts) === day(Date.now())) return `Today ${time}`;
64
+ if (day(ts) === day(Date.now() - DAY)) return `Yesterday ${time}`;
65
+ return fmt(ts, { month: 'short', day: 'numeric', ...(new Date(ts).getFullYear() !== new Date().getFullYear() ? { year: 'numeric' } : {}) });
66
+ };
67
+ // displayAnswers owns per-type formatting; call it on subsets of the snapshot.
68
+ const show = (r: FormSubmission, keep: (id: string) => boolean) => {
69
+ const fields = answerFields(r.response.definition.fields).filter(f => keep(f.id));
70
+ return fields.length ? Object.entries(displayAnswers({ ...r.response, definition: { ...r.response.definition, fields } })) : [];
71
+ };
72
+ const data: ResponseRow[] = rows.slice(0, 100).map(r => ({
73
+ id: r.id,
74
+ when: shortWhen(r.createdAt),
75
+ full: fmt(r.createdAt, { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' }),
76
+ version: version(r.response.version),
77
+ cells: columns.map(c => r.response.definition.fields.some(f => f.id === c.id) ? (show(r, id => id === c.id)[0]?.[1] ?? '') : ''),
78
+ answers: show(r, id => currentIds.has(id)),
79
+ older: show(r, id => !currentIds.has(id)),
80
+ }));
81
+ const keep = (patch: Record<string, string | null>) => {
82
+ const u = new URL(Astro.url); u.searchParams.delete('notice');
83
+ for (const [k, v] of Object.entries(patch)) v === null ? u.searchParams.delete(k) : u.searchParams.set(k, v);
84
+ return u.pathname + u.search;
85
+ };
86
+ const filtered = !!(q || range);
87
+ ---
88
+ <Admin title={`${form.name} · Responses`} section="forms" wide>
89
+ <FormHeader form={form} active="responses" responses={stats.total}>
90
+ <a slot="actions" class="btn" href={keep({ export: '1', page: null })} download>Export CSV</a>
91
+ </FormHeader>
92
+ {error && <p class="notice warn" role="alert">{error}</p>}
93
+ {notice && <p class="notice" role="status">{notice}</p>}
94
+
95
+ {!stats.total ? (
96
+ <div class="empty-state">
97
+ <b>No responses yet</b>
98
+ <p>{form.status === 'published' ? 'Share the form or add it to a page. New responses show up here.' : 'Publish the form so people can respond.'}</p>
99
+ <a class="btn primary" href={form.status === 'published' ? `/admin/forms/${form.id}/share` : `/admin/forms/${form.id}`}>{form.status === 'published' ? 'Share form' : 'Open builder'}</a>
100
+ </div>
101
+ ) : (
102
+ <>
103
+ <div class="kpis fr-kpis">
104
+ <div class="kpi"><small>Responses</small><b>{stats.total.toLocaleString('en-US')}</b></div>
105
+ <div class="kpi"><small>Last 7 days</small><b>{stats.recent.toLocaleString('en-US')}</b></div>
106
+ <div class="kpi"><small>Latest</small><b>{stats.last ? relativeTime(stats.last, Date.now(), tz) : '—'}</b></div>
107
+ <div class="kpi"><small>Current version</small><b>{version(current)}</b></div>
108
+ </div>
109
+
110
+ <form method="get" class="fr-tools" role="search">
111
+ <label class="fr-search">
112
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" /></svg>
113
+ <input type="search" name="q" value={q} placeholder="Search responses" aria-label="Search responses" maxlength="100" />
114
+ </label>
115
+ <select name="range" aria-label="Date range" data-autosubmit>
116
+ <option value="" selected={!range}>All time</option>
117
+ <option value="7" selected={range === '7'}>Last 7 days</option>
118
+ <option value="30" selected={range === '30'}>Last 30 days</option>
119
+ </select>
120
+ <noscript><button class="btn" type="submit">Apply</button></noscript>
121
+ {filtered && <a class="btn ghost sm" href={keep({ q: null, range: null, page: null })}>Clear</a>}
122
+ <span class="fr-count">{filtered ? `${rows.length > 100 ? '100+' : data.length} matching` : `${stats.total.toLocaleString('en-US')} total`}</span>
123
+ </form>
124
+
125
+ {data.length ? (
126
+ <ResponsesTable client:load columns={columns} rows={data} formName={form.name} />
127
+ ) : (
128
+ <p class="fr-none hint">No responses match. <a href={keep({ q: null, range: null, page: null })}>Clear filters</a></p>
129
+ )}
130
+ {(page > 0 || rows.length > 100) && (
131
+ <nav class="fr-pager" aria-label="Pages">
132
+ {page > 0 ? <a class="btn sm" href={keep({ page: page > 1 ? String(page - 1) : null })}>‹ Newer</a> : <span />}
133
+ <span class="hint">Page {page + 1}</span>
134
+ {rows.length > 100 ? <a class="btn sm" href={keep({ page: String(page + 1) })}>Older ›</a> : <span />}
135
+ </nav>
136
+ )}
137
+ </>
138
+ )}
139
+ </Admin>
140
+
141
+ <script>
142
+ document.querySelector<HTMLSelectElement>('[data-autosubmit]')?.addEventListener('change', (e) => (e.target as HTMLSelectElement).form?.requestSubmit());
143
+ </script>
@@ -0,0 +1,27 @@
1
+ ---
2
+ import Admin from '../../../layouts/Admin.astro';
3
+ import FormHeader from '../../../components/forms/FormHeader.astro';
4
+ import FormBuildPage from '../../../components/forms/FormBuildPage';
5
+ import { getRepo } from '../../../lib/repo';
6
+ import '../../../styles/forms.css';
7
+ // Build tab. Edits autosave as a draft through the `forms.save` action;
8
+ // "Publish changes" makes them live. See components/forms/FormBuildPage.tsx.
9
+ const repo = await getRepo(Astro.locals);
10
+ const form = await repo.forms.get(Astro.params.id || '');
11
+ if (!form) return new Response(null, { status: 404 });
12
+ Astro.response.headers.set('Cache-Control', 'private, no-store');
13
+ const responses = form.kind === 'form' && form.purpose === 'general' ? (await repo.forms.submissions(form.id, 1)).length : 0;
14
+ ---
15
+ <Admin title={form.name} section="forms" wide>
16
+ <FormHeader form={form} active="build">
17
+ <div slot="actions" id="fb-actions" class="fb-actions-slot"></div>
18
+ </FormHeader>
19
+ {form.status === 'archived' && <p class="notice warn">This form is archived and doesn’t accept responses. You can still edit it.</p>}
20
+ <noscript><p class="notice warn">Enable JavaScript to edit this form.</p></noscript>
21
+ <FormBuildPage
22
+ client:load
23
+ form={{ id: form.id, name: form.name, kind: form.kind, purpose: form.purpose, status: form.status, revision: form.revision, hasDraft: Boolean(form.draft) }}
24
+ definition={form.draft ?? form.definition}
25
+ responses={responses}
26
+ />
27
+ </Admin>