@acms-subir/core 0.2.0 → 0.3.0

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 (230) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/cli/common.mjs +35 -0
  3. package/cli/legacy.mjs +317 -0
  4. package/cli/main.mjs +66 -0
  5. package/cli/palette.mjs +4 -0
  6. package/cli/preview.mjs +50 -0
  7. package/cli/remote.mjs +55 -0
  8. package/cli/themes.mjs +127 -0
  9. package/cli.mjs +2 -369
  10. package/deploy/Caddyfile +8 -0
  11. package/deploy/Dockerfile +34 -0
  12. package/deploy/compose.https.yml +21 -0
  13. package/deploy/compose.yml +46 -0
  14. package/docs/theme-authoring.md +103 -0
  15. package/email-runner.mjs +32 -0
  16. package/integration.mjs +182 -2
  17. package/ops-check.mjs +27 -0
  18. package/package.json +27 -5
  19. package/src/actions/index.ts +575 -47
  20. package/src/components/AiCleaner.tsx +291 -0
  21. package/src/components/AiSettings.tsx +575 -0
  22. package/src/components/CommandPalette.tsx +200 -0
  23. package/src/components/ContentList.astro +246 -0
  24. package/src/components/DesignAccess.tsx +182 -0
  25. package/src/components/DesignManager.tsx +418 -46
  26. package/src/components/EmailSettings.tsx +521 -0
  27. package/src/components/EventPage.astro +178 -0
  28. package/src/components/LegalPage.astro +27 -0
  29. package/src/components/LinksManager.tsx +114 -57
  30. package/src/components/NavManager.tsx +42 -43
  31. package/src/components/ThemeScript.astro +50 -0
  32. package/src/components/ThemeSections.astro +57 -0
  33. package/src/components/ThemeSectionsEditor.tsx +513 -0
  34. package/src/components/booking/BookingFlow.astro +150 -0
  35. package/src/components/booking/HoursEditor.astro +63 -0
  36. package/src/components/builder/PageBuilder.tsx +10 -2
  37. package/src/components/editor/EventPanel.tsx +401 -0
  38. package/src/components/editor/FormatDock.tsx +81 -0
  39. package/src/components/editor/LivePane.tsx +122 -0
  40. package/src/components/editor/MobileToolbar.tsx +93 -0
  41. package/src/components/editor/PostEditor.tsx +605 -223
  42. package/src/components/editor/PreviewOverlay.tsx +242 -43
  43. package/src/components/editor/extensions/calendar.tsx +51 -7
  44. package/src/components/editor/extensions/events.tsx +153 -0
  45. package/src/components/editor/extensions/slash.tsx +7 -1
  46. package/src/components/site/Pager.astro +24 -0
  47. package/src/components/site/PostCard.astro +64 -0
  48. package/src/components/site/TagLabel.astro +11 -0
  49. package/src/layouts/Admin.astro +289 -49
  50. package/src/layouts/Site.astro +324 -59
  51. package/src/lib/agent-api.ts +13 -0
  52. package/src/lib/agent-discovery.ts +40 -0
  53. package/src/lib/agent.ts +114 -0
  54. package/src/lib/ai-config.ts +58 -0
  55. package/src/lib/ai-schema.ts +22 -0
  56. package/src/lib/ai.ts +90 -0
  57. package/src/lib/api-keys.ts +65 -0
  58. package/src/lib/api.ts +52 -0
  59. package/src/lib/auth.ts +25 -5
  60. package/src/lib/backup/content.ts +36 -0
  61. package/src/lib/backup/unzip.ts +12 -1
  62. package/src/lib/backup/zip.ts +1 -1
  63. package/src/lib/booking/agent.ts +108 -0
  64. package/src/lib/booking/availability.ts +221 -0
  65. package/src/lib/booking/emails.ts +115 -0
  66. package/src/lib/booking/forms.ts +77 -0
  67. package/src/lib/booking/service.ts +277 -0
  68. package/src/lib/booking/view.ts +54 -0
  69. package/src/lib/calendar/agent-schema.ts +48 -0
  70. package/src/lib/calendar/agent.ts +136 -0
  71. package/src/lib/calendar/api.ts +81 -0
  72. package/src/lib/calendar/cards.ts +102 -0
  73. package/src/lib/calendar/config.ts +52 -0
  74. package/src/lib/calendar/data.ts +130 -0
  75. package/src/lib/calendar/events.ts +261 -0
  76. package/src/lib/calendar/feeds.ts +43 -0
  77. package/src/lib/calendar/hooks.ts +38 -0
  78. package/src/lib/calendar/http.ts +30 -0
  79. package/src/lib/calendar/ics-write.ts +120 -0
  80. package/src/lib/calendar/ics.ts +15 -5
  81. package/src/lib/calendar/import.ts +203 -0
  82. package/src/lib/calendar/render.ts +120 -37
  83. package/src/lib/calendar/rrule.ts +149 -0
  84. package/src/lib/calendar/seo.ts +48 -0
  85. package/src/lib/design-service.ts +58 -0
  86. package/src/lib/designDiff.ts +145 -0
  87. package/src/lib/designPreview.ts +110 -12
  88. package/src/lib/documents/cleanup.ts +31 -0
  89. package/src/lib/documents/extract.ts +154 -0
  90. package/src/lib/documents/vendor.d.ts +4 -0
  91. package/src/lib/drafts.ts +60 -6
  92. package/src/lib/dynamicBlocks.ts +40 -8
  93. package/src/lib/email/accounts.ts +55 -0
  94. package/src/lib/email/config.ts +86 -0
  95. package/src/lib/email/migration.ts +24 -0
  96. package/src/lib/email/providers.ts +89 -0
  97. package/src/lib/email/service.ts +78 -0
  98. package/src/lib/email/store.ts +167 -0
  99. package/src/lib/email/templates.ts +18 -0
  100. package/src/lib/email/types.ts +32 -0
  101. package/src/lib/email/webhooks.ts +87 -0
  102. package/src/lib/email.ts +15 -0
  103. package/src/lib/env.ts +1 -1
  104. package/src/lib/legal/index.ts +25 -0
  105. package/src/lib/mobile/bookings.ts +73 -0
  106. package/src/lib/mobile/calendar.ts +85 -0
  107. package/src/lib/mobile/content.ts +85 -0
  108. package/src/lib/mobile/http.ts +63 -0
  109. package/src/lib/mobile/route.ts +20 -0
  110. package/src/lib/ops.ts +8 -0
  111. package/src/lib/preview.ts +21 -1
  112. package/src/lib/repo/migrations.ts +217 -0
  113. package/src/lib/repo/sql.ts +853 -60
  114. package/src/lib/repo/types.ts +122 -5
  115. package/src/lib/scheduler.ts +25 -0
  116. package/src/lib/schema.ts +309 -0
  117. package/src/lib/search-i18n.ts +3 -0
  118. package/src/lib/socialIcons.ts +32 -0
  119. package/src/lib/tagColor.ts +11 -0
  120. package/src/lib/theme.ts +59 -40
  121. package/src/lib/themeTokens.ts +174 -0
  122. package/src/lib/umami.ts +108 -0
  123. package/src/lib/webhooks.ts +139 -0
  124. package/src/middleware.ts +36 -3
  125. package/src/pages/404.astro +23 -0
  126. package/src/pages/[...slug].astro +8 -5
  127. package/src/pages/account/[action].astro +53 -0
  128. package/src/pages/admin/ai.astro +12 -0
  129. package/src/pages/admin/c/[collection]/[id].astro +4 -1
  130. package/src/pages/admin/c/[collection]/index.astro +127 -92
  131. package/src/pages/admin/calendar/bookings.astro +400 -0
  132. package/src/pages/admin/calendar/calendars.astro +265 -0
  133. package/src/pages/admin/calendar/events/[id].astro +56 -0
  134. package/src/pages/admin/calendar/events/index.astro +408 -0
  135. package/src/pages/admin/calendar/index.ts +4 -0
  136. package/src/pages/admin/calendar/integrations.astro +221 -0
  137. package/src/pages/admin/calendar/resources/[id].astro +180 -0
  138. package/src/pages/admin/calendar/resources/index.astro +106 -0
  139. package/src/pages/admin/calendar/resources/link.astro +196 -0
  140. package/src/pages/admin/design.astro +10 -3
  141. package/src/pages/admin/email.astro +13 -0
  142. package/src/pages/admin/index.astro +232 -85
  143. package/src/pages/admin/links.astro +3 -3
  144. package/src/pages/admin/login.astro +14 -10
  145. package/src/pages/admin/navigation.astro +8 -2
  146. package/src/pages/admin/pages/[id].astro +4 -1
  147. package/src/pages/admin/pages/index.astro +41 -48
  148. package/src/pages/admin/settings.astro +418 -367
  149. package/src/pages/admin/tags.astro +4 -4
  150. package/src/pages/admin/users.astro +38 -28
  151. package/src/pages/api/agent/docs.ts +33 -0
  152. package/src/pages/api/agent/index.ts +21 -0
  153. package/src/pages/api/ai/clean.ts +13 -0
  154. package/src/pages/api/ai/draft.ts +14 -0
  155. package/src/pages/api/ai/settings.ts +53 -0
  156. package/src/pages/api/ai/test.ts +12 -0
  157. package/src/pages/api/backup.ts +8 -21
  158. package/src/pages/api/calendar/availability.ts +35 -0
  159. package/src/pages/api/calendar/bookings/[id].ts +49 -0
  160. package/src/pages/api/calendar/bookings/index.ts +73 -0
  161. package/src/pages/api/calendar/calendars.ts +35 -0
  162. package/src/pages/api/calendar/docs.ts +64 -0
  163. package/src/pages/api/calendar/events/[id].ts +63 -0
  164. package/src/pages/api/calendar/events/index.ts +112 -0
  165. package/src/pages/api/calendar/resources.ts +34 -0
  166. package/src/pages/api/design/capabilities.ts +10 -0
  167. package/src/pages/api/design/index.ts +58 -0
  168. package/src/pages/api/design/keys.ts +24 -0
  169. package/src/pages/api/design/media.ts +32 -0
  170. package/src/pages/api/email/run.ts +11 -0
  171. package/src/pages/api/email/settings.ts +61 -0
  172. package/src/pages/api/email/webhooks/[provider].ts +4 -0
  173. package/src/pages/api/mcp.ts +59 -0
  174. package/src/pages/api/media/upload.ts +2 -2
  175. package/src/pages/api/ops/status.ts +39 -0
  176. package/src/pages/api/restore.ts +27 -37
  177. package/src/pages/api/search.ts +18 -4
  178. package/src/pages/api/v1/availability.ts +7 -0
  179. package/src/pages/api/v1/bookings.ts +17 -0
  180. package/src/pages/api/v1/calendars.ts +7 -0
  181. package/src/pages/api/v1/content/[id].ts +7 -0
  182. package/src/pages/api/v1/content/index.ts +7 -0
  183. package/src/pages/api/v1/events.ts +7 -0
  184. package/src/pages/api/v1/resources.ts +7 -0
  185. package/src/pages/author/[slug].astro +43 -35
  186. package/src/pages/blog/[slug].astro +137 -66
  187. package/src/pages/blog/index.astro +95 -30
  188. package/src/pages/blog/tag/[slug].astro +45 -40
  189. package/src/pages/book/[resource]/[link].astro +70 -0
  190. package/src/pages/book/[resource]/index.astro +63 -0
  191. package/src/pages/book/[resource].ics.ts +38 -0
  192. package/src/pages/book/index.astro +47 -0
  193. package/src/pages/book/manage/[token].astro +108 -0
  194. package/src/pages/calendar/[slug].ics.ts +27 -0
  195. package/src/pages/calendar/feed.ics.ts +15 -0
  196. package/src/pages/calendar/index.astro +191 -0
  197. package/src/pages/embed/book/[resource]/[link].astro +33 -0
  198. package/src/pages/embed/calendar/[slug].astro +43 -0
  199. package/src/pages/events/[slug].astro +74 -0
  200. package/src/pages/events/[slug].ics.ts +25 -0
  201. package/src/pages/index.astro +6 -3
  202. package/src/pages/legal/privacy.astro +4 -0
  203. package/src/pages/legal/terms.astro +4 -0
  204. package/src/pages/links.astro +23 -3
  205. package/src/pages/llms.txt.ts +4 -0
  206. package/src/pages/preview/[type]/[id].astro +27 -6
  207. package/src/pages/search.astro +40 -20
  208. package/src/pages/sitemap.xml.ts +16 -1
  209. package/src/styles/admin.css +924 -105
  210. package/src/styles/cards.css +92 -74
  211. package/src/styles/editor.css +492 -114
  212. package/src/styles/email-settings.css +117 -0
  213. package/src/styles/sections.css +12 -0
  214. package/src/styles/site.css +938 -281
  215. package/src/styles/theme-editor.css +135 -0
  216. package/src/theme-sdk/contract.d.mts +619 -0
  217. package/src/theme-sdk/contract.mjs +80 -0
  218. package/src/theme-sdk/index.ts +15 -0
  219. package/src/theme-sdk/tokens.d.mts +148 -0
  220. package/src/theme-sdk/tokens.mjs +52 -0
  221. package/src/themes/editorial/templates/home.astro +4 -4
  222. package/src/themes/editorial/templates/post-minimal.astro +2 -2
  223. package/src/themes/editorial/theme.css +22 -37
  224. package/src/themes/editorial/theme.json +23 -3
  225. package/src/themes/field/theme.json +26 -9
  226. package/src/themes/ink/theme.json +30 -12
  227. package/src/themes/night/theme.css +4 -7
  228. package/src/themes/night/theme.json +27 -10
  229. package/src/virtual.d.ts +14 -0
  230. package/src/worker.ts +5 -1
@@ -0,0 +1,150 @@
1
+ ---
2
+ // The booking flow for one link (mockup D + E): length chips (range links),
3
+ // a month of open days, start times that fit, then the questions. Every step
4
+ // is a plain link or form (?date=…&len=…&start=…), so it works without
5
+ // JavaScript and each state is shareable. Used by the page and the embed.
6
+ import type { Booking, BookingLink, Resource, Settings } from '../../lib/schema';
7
+ import { bookingLinkUrl, resourceUrl } from '../../lib/calendar/config';
8
+ import { describeLength, describeLink, linkLengths, openDays, slotsForDay, ymd, type AvailabilityContext } from '../../lib/booking/availability';
9
+ import { addMonths, fmt, monthCells, slotLabel } from '../../lib/booking/view';
10
+
11
+ interface Props {
12
+ resource: Resource;
13
+ link: BookingLink;
14
+ ctx: AvailabilityContext;
15
+ settings: Settings;
16
+ url: URL;
17
+ error?: string;
18
+ values?: Record<string, string>;
19
+ embed?: boolean;
20
+ reschedule?: Booking | null;
21
+ }
22
+ const { resource, link, ctx, settings, url, error = '', values = {}, embed = false, reschedule = null } = Astro.props;
23
+ const tz = ctx.resource.timezone || settings.timezone || 'UTC';
24
+ const f = fmt(settings, tz);
25
+ const today = ymd(ctx.now, tz);
26
+ const lengths = linkLengths(link);
27
+ const lenParam = Number(url.searchParams.get('len'));
28
+ const len = link.mode === 'range' ? (lengths.includes(lenParam) ? lenParam : lengths[Math.floor(lengths.length / 2)]) : undefined;
29
+ const month = /^\d{4}-\d{2}$/.test(url.searchParams.get('m') ?? '') ? url.searchParams.get('m')! : today.slice(0, 7);
30
+ const horizon = Math.min(link.maxAdvance + 1, 400);
31
+ const open = new Set(openDays(ctx, today, horizon, len));
32
+ const firstOpen = [...open].sort()[0] ?? '';
33
+ const dateParam = url.searchParams.get('date') ?? '';
34
+ const date = open.has(dateParam) ? dateParam : '';
35
+ const shownMonth = date ? date.slice(0, 7) : month;
36
+ const cells = monthCells(shownMonth, open, today);
37
+ const slots = date ? slotsForDay(ctx, date, link.mode === 'day' ? undefined : len) : [];
38
+ const startParam = Number(url.searchParams.get('start'));
39
+ const chosen = slots.find((s) => s.start === startParam && (link.mode !== 'day' || s.minutes === Number(url.searchParams.get('len') || s.minutes)));
40
+ const minMonth = today.slice(0, 7);
41
+ const maxMonth = ymd(ctx.now + link.maxAdvance * 86_400_000, tz).slice(0, 7);
42
+ const base = embed ? `/embed/book/${resource.slug}/${link.slug}` : bookingLinkUrl(resource.slug, link.slug);
43
+ const q = (patch: Record<string, string | number | undefined>) => {
44
+ const p = new URLSearchParams();
45
+ for (const k of ['len', 'm', 'date', 'start', 'reschedule']) {
46
+ const v = k in patch ? patch[k] : url.searchParams.get(k) ?? undefined;
47
+ if (v !== undefined && v !== '' && v !== null) p.set(k, String(v));
48
+ }
49
+ const s = p.toString();
50
+ return s ? `${base}?${s}` : base;
51
+ };
52
+ const rules = [
53
+ describeLink(link),
54
+ link.autoConfirm ? 'Confirmed instantly' : 'Needs approval',
55
+ link.bufferBefore || link.bufferAfter ? `Includes ${[link.bufferBefore && `${link.bufferBefore} min setup`, link.bufferAfter && `${link.bufferAfter} min cleanup`].filter(Boolean).join(' + ')}` : '',
56
+ ].filter(Boolean);
57
+ const val = (k: string) => values[k] ?? '';
58
+ ---
59
+
60
+ <div class={`pc-bk${embed ? ' is-embed' : ''}`}>
61
+ <aside class="pc-bk-info">
62
+ {!embed && <a class="pc-bk-back" href={resourceUrl(resource.slug)}>‹ {resource.name}</a>}
63
+ {embed && <span class="pc-bk-kicker">{resource.name}</span>}
64
+ <h1 class="pc-bk-title">{link.name}</h1>
65
+ {link.description && <p class="pc-bk-desc">{link.description}</p>}
66
+ <ul class="pc-bk-rules">{rules.map((r) => <li>{r}</li>)}</ul>
67
+ {link.mode === 'range' && lengths.length > 1 && (
68
+ <div class="pc-bk-lens" role="group" aria-label="How long">
69
+ <span class="pc-bk-label">How long?</span>
70
+ <div>{lengths.map((l) => <a class={l === len ? 'is-on' : ''} href={q({ len: l, start: undefined })} aria-current={l === len ? 'true' : undefined}>{describeLength(l)}</a>)}</div>
71
+ </div>
72
+ )}
73
+ <p class="pc-bk-tz">Times in {f.zone}</p>
74
+ {reschedule && <p class="pc-bk-note">Moving your booking from {f.dayLong.format(reschedule.start)}, {f.time.format(reschedule.start)}.</p>}
75
+ </aside>
76
+
77
+ {!chosen ? (
78
+ <>
79
+ <section class="pc-bk-cal" aria-label="Pick a day">
80
+ {error && <p class="pc-bk-error" role="alert">{error}</p>}
81
+ <div class="pc-bk-calhead">
82
+ <b>{f.month.format(Date.UTC(Number(shownMonth.slice(0, 4)), Number(shownMonth.slice(5, 7)) - 1, 15))}</b>
83
+ <span>
84
+ {shownMonth > minMonth ? <a href={q({ m: addMonths(shownMonth, -1), date: undefined, start: undefined })} aria-label="Previous month">‹</a> : <span aria-hidden="true">‹</span>}
85
+ {shownMonth < maxMonth ? <a href={q({ m: addMonths(shownMonth, 1), date: undefined, start: undefined })} aria-label="Next month">›</a> : <span aria-hidden="true">›</span>}
86
+ </span>
87
+ </div>
88
+ <div class="pc-bk-grid">
89
+ {['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((d) => <span class="dow">{d}</span>)}
90
+ {cells.map((c) => !c.inMonth ? <span class="out" /> : c.open ? (
91
+ <a class={`day open${c.date === date ? ' is-on' : ''}${c.date === today ? ' today' : ''}`} href={q({ date: c.date, m: c.date.slice(0, 7), start: undefined })} aria-label={c.date}>{c.day}</a>
92
+ ) : <span class={`day${c.date === today ? ' today' : ''}`}>{c.day}</span>)}
93
+ </div>
94
+ {!open.size && <p class="pc-bk-empty">No openings in the next {horizon} days.</p>}
95
+ {open.size > 0 && !date && <p class="pc-bk-hint">Pick a highlighted day.</p>}
96
+ </section>
97
+ <section class="pc-bk-slots" aria-label="Pick a time">
98
+ {date ? (
99
+ <>
100
+ <b class="pc-bk-slotday">{f.dayLong.format(slots[0]?.start ?? ctx.now)}</b>
101
+ {slots.length ? (
102
+ <div class="pc-bk-slotlist">{slots.map((s) => <a href={q({ start: s.start, len: link.mode === 'day' ? s.minutes : len })} target={embed ? '_top' : undefined}>{slotLabel(s, link, f)}</a>)}</div>
103
+ ) : <p class="pc-bk-empty">Fully booked — try another day.</p>}
104
+ </>
105
+ ) : <p class="pc-bk-hint">{firstOpen ? <>Next opening: <a href={q({ date: firstOpen, m: firstOpen.slice(0, 7) })}>{f.dayLong.format(Date.UTC(Number(firstOpen.slice(0, 4)), Number(firstOpen.slice(5, 7)) - 1, Number(firstOpen.slice(8, 10)), 12))}</a></> : ''}</p>}
106
+ </section>
107
+ </>
108
+ ) : (
109
+ <section class="pc-bk-form">
110
+ <div class="pc-bk-summary">
111
+ <b>{resource.name} · {link.name}</b>
112
+ <span>{f.dayLong.format(chosen.start)} · {slotLabel(chosen, link, f)}</span>
113
+ <a href={q({ start: undefined })}>Change</a>
114
+ </div>
115
+ {error && <p class="pc-bk-error" role="alert">{error}</p>}
116
+ <form method="post">
117
+ <input type="hidden" name="start" value={chosen.start} />
118
+ <input type="hidden" name="end" value={chosen.end} />
119
+ {reschedule ? (
120
+ <input type="hidden" name="reschedule" value={reschedule.manageToken} />
121
+ ) : (
122
+ <>
123
+ <label class="pc-bk-field"><span>Your name</span><input name="name" required autocomplete="name" value={val('name')} /></label>
124
+ <label class="pc-bk-field"><span>Email</span><input name="email" type="email" required autocomplete="email" value={val('email')} /></label>
125
+ {link.askPhone && <label class="pc-bk-field"><span>Phone</span><input name="phone" type="tel" autocomplete="tel" value={val('phone')} /></label>}
126
+ {link.questions.map((qn) => (
127
+ qn.type === 'checkbox' ? (
128
+ <label class="pc-bk-check"><input type="checkbox" name={`q_${qn.id}`} value="yes" required={qn.required} checked={val(`q_${qn.id}`) === 'yes'} /> <span>{qn.label}</span></label>
129
+ ) : (
130
+ <label class="pc-bk-field">
131
+ <span>{qn.label}{!qn.required && <em> optional</em>}</span>
132
+ {qn.type === 'textarea' ? <textarea name={`q_${qn.id}`} rows="3" required={qn.required}>{val(`q_${qn.id}`)}</textarea>
133
+ : qn.type === 'select' ? (
134
+ <select name={`q_${qn.id}`} required={qn.required}>
135
+ <option value="">Choose…</option>
136
+ {qn.options.map((o) => <option value={o} selected={val(`q_${qn.id}`) === o}>{o}</option>)}
137
+ </select>
138
+ ) : <input name={`q_${qn.id}`} type={qn.type === 'phone' ? 'tel' : qn.type} required={qn.required} value={val(`q_${qn.id}`)} />}
139
+ {qn.help && <small>{qn.help}</small>}
140
+ </label>
141
+ )
142
+ ))}
143
+ </>
144
+ )}
145
+ <button class="pc-bk-submit" type="submit">{reschedule ? 'Move my booking' : link.autoConfirm ? 'Confirm booking' : 'Request booking'}</button>
146
+ {!link.autoConfirm && !reschedule && <p class="pc-bk-fine">We’ll email you once it’s approved. Nothing is final until then.</p>}
147
+ </form>
148
+ </section>
149
+ )}
150
+ </div>
@@ -0,0 +1,63 @@
1
+ ---
2
+ // Weekday rows (Calendly / Cal.com pattern): a switch, start–end, an optional
3
+ // second range, and "copy to all" — plus a small read-only week preview.
4
+ import type { WeeklyHours } from '../../lib/schema';
5
+ import { DAY_LABELS, DAY_ORDER } from '../../lib/booking/forms';
6
+ interface Props { hours: WeeklyHours; prefix?: string; bounds?: WeeklyHours | null; note?: string; form?: string }
7
+ const { hours, prefix = 'hours', bounds = null, note = '', form } = Astro.props;
8
+ const pct = (t: string) => ((Number(t.slice(0, 2)) * 60 + Number(t.slice(3))) / 1440) * 100;
9
+ ---
10
+
11
+ <div class="hours" data-hours={prefix}>
12
+ {DAY_ORDER.map((d) => {
13
+ const r = hours[d] ?? [];
14
+ const on = r.length > 0;
15
+ return (
16
+ <div class={`hours-row${on ? '' : ' is-off'}`}>
17
+ <label class="hours-day"><input type="checkbox" role="switch" class="sw" name={`${prefix}_${d}_on`} checked={on} form={form} /> {DAY_LABELS[d]}</label>
18
+ <div class="hours-ranges">
19
+ <span class="hours-range"><input type="time" form={form} name={`${prefix}_${d}_s1`} value={r[0]?.start ?? '09:00'} step="900" /> – <input type="time" form={form} name={`${prefix}_${d}_e1`} value={r[0]?.end ?? '17:00'} step="900" /></span>
20
+ <span class={`hours-range hours-second${r[1] ? '' : ' is-empty'}`}><input type="time" form={form} name={`${prefix}_${d}_s2`} value={r[1]?.start ?? ''} step="900" /> – <input type="time" form={form} name={`${prefix}_${d}_e2`} value={r[1]?.end ?? ''} step="900" /></span>
21
+ <span class="hours-closed">Closed</span>
22
+ </div>
23
+ <span class="hours-tools">
24
+ <button type="button" class="icon-btn" data-add title="Add a second range" aria-label="Add a second range">+</button>
25
+ <button type="button" class="icon-btn" data-copy={d} title={`Copy ${DAY_LABELS[d]} to every open day`} aria-label="Copy to every open day">⧉</button>
26
+ </span>
27
+ </div>
28
+ );
29
+ })}
30
+ <div class="hours-week" aria-hidden="true">
31
+ {DAY_ORDER.map((d) => (
32
+ <div><small>{DAY_LABELS[d]}</small><i>
33
+ {(bounds?.[d] ?? []).map((r) => <u style={`top:${pct(r.start)}%;bottom:${100 - pct(r.end === '00:00' ? '24:00' : r.end)}%`} />)}
34
+ {(hours[d] ?? []).map((r) => <b style={`top:${pct(r.start)}%;bottom:${100 - pct(r.end === '00:00' ? '24:00' : r.end)}%`} />)}
35
+ </i></div>
36
+ ))}
37
+ </div>
38
+ {note && <p class="hint">{note}</p>}
39
+ </div>
40
+
41
+ <script>
42
+ document.querySelectorAll<HTMLElement>('.hours').forEach((root) => {
43
+ const rows = () => [...root.querySelectorAll<HTMLElement>('.hours-row')];
44
+ root.addEventListener('change', (e) => {
45
+ const t = e.target as HTMLInputElement;
46
+ if (t.classList.contains('sw')) t.closest('.hours-row')?.classList.toggle('is-off', !t.checked);
47
+ });
48
+ root.addEventListener('click', (e) => {
49
+ const b = (e.target as HTMLElement).closest('button');
50
+ if (!b) return;
51
+ const row = b.closest<HTMLElement>('.hours-row')!;
52
+ if (b.hasAttribute('data-add')) row.querySelector('.hours-second')?.classList.remove('is-empty');
53
+ if (b.hasAttribute('data-copy')) {
54
+ const src = [...row.querySelectorAll<HTMLInputElement>('input[type=time]')].map((i) => i.value);
55
+ rows().forEach((r) => {
56
+ if (r === row || r.classList.contains('is-off')) return;
57
+ r.querySelectorAll<HTMLInputElement>('input[type=time]').forEach((i, k) => (i.value = src[k]));
58
+ if (src[2]) r.querySelector('.hours-second')?.classList.remove('is-empty');
59
+ });
60
+ }
61
+ });
62
+ });
63
+ </script>
@@ -23,6 +23,7 @@ interface Props {
23
23
  previewPath: string;
24
24
  sharePath: string;
25
25
  unsplashEnabled: boolean;
26
+ site?: { title: string; url: string };
26
27
  }
27
28
 
28
29
  interface Meta {
@@ -42,7 +43,7 @@ const DEVICES = [
42
43
 
43
44
  const uuid = () => crypto.randomUUID();
44
45
 
45
- export default function PageBuilder({ page, previewPath, sharePath, unsplashEnabled }: Props) {
46
+ export default function PageBuilder({ page, previewPath, sharePath, unsplashEnabled, site = { title: '', url: '' } }: Props) {
46
47
  const [blocks, setBlocks] = useState<WidgetInstance[]>(
47
48
  page.blocks.map((b) => ({ ...b, config: parseConfig(b.type as WidgetType, b.config) as Record<string, unknown> }))
48
49
  );
@@ -333,7 +334,14 @@ export default function PageBuilder({ page, previewPath, sharePath, unsplashEnab
333
334
  setPickerFor(null);
334
335
  }}
335
336
  />
336
- <PreviewOverlay open={previewOpen} url={previewPath} shareUrl={sharePath} onClose={() => setPreviewOpen(false)} />
337
+ <PreviewOverlay open={previewOpen} url={previewPath} shareUrl={sharePath}
338
+ site={site} isPost={false}
339
+ meta={{ ...meta, excerpt: '', cover: '', coverAlt: '', path: meta.isHome ? '/' : `/${meta.slug}`, firstParagraph: '',
340
+ links: blocks.filter(b => b.type === 'button' || (b.type === 'hero' && b.config.ctaHref)).length,
341
+ imagesMissingAlt: blocks.filter(b => b.type === 'image' && !b.config.alt).length }}
342
+ onChange={({ seoTitle, seoDesc }) => updateMeta({ ...(seoTitle !== undefined ? { seoTitle } : {}), ...(seoDesc !== undefined ? { seoDesc } : {}) })}
343
+ onPickCover={() => { const b = blocks.find(b => b.type === 'image'); if (b) setPickerFor({ blockId: b.id, key: 'src' }); }}
344
+ onClose={() => setPreviewOpen(false)} />
337
345
  </div>
338
346
  );
339
347
  }
@@ -0,0 +1,401 @@
1
+ import { useMemo, useState } from 'react';
2
+ import type { EventCta, EventLocation } from '../../lib/schema';
3
+ import type { EventFieldConfig } from '../../lib/calendar/config';
4
+ import { buildRepeat, describeRepeat, parseRepeat, WEEKDAY_CODES, weekdayOf, type RepeatParts } from '../../lib/calendar/rrule';
5
+
6
+ // The event half of the editor sidebar: When · Repeats · Where · Button ·
7
+ // Calendars · custom fields. Times are wall-clock strings in the event's
8
+ // zone ('YYYY-MM-DDTHH:mm', or 'YYYY-MM-DD' for all-day with an inclusive
9
+ // end day). The server re-validates everything on save.
10
+
11
+ export interface EventMeta {
12
+ start: string;
13
+ end: string;
14
+ allDay: boolean;
15
+ timezone: string;
16
+ rrule: string;
17
+ exdates: string[];
18
+ cancelled: boolean;
19
+ location: EventLocation;
20
+ cta: EventCta;
21
+ calendarIds: string[];
22
+ fields: Record<string, string>;
23
+ rsvp: { enabled: boolean; capacity: number; autoConfirm: boolean; maxSeats: number };
24
+ }
25
+
26
+ export interface CalendarOption {
27
+ id: string;
28
+ name: string;
29
+ color: string;
30
+ }
31
+
32
+ interface Props {
33
+ meta: EventMeta;
34
+ onChange: (patch: Partial<EventMeta>) => void;
35
+ calendars: CalendarOption[];
36
+ fields: EventFieldConfig[];
37
+ siteTimezone: string;
38
+ }
39
+
40
+ const DAY_LETTERS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
41
+ const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
42
+ const ORD = ['first', 'second', 'third', 'fourth', 'last'];
43
+
44
+ const datePart = (s: string) => s.slice(0, 10);
45
+ const timePart = (s: string) => (s.length > 10 ? s.slice(11, 16) : '');
46
+ const toMin = (s: string) => {
47
+ const [y, m, d] = s.slice(0, 10).split('-').map(Number);
48
+ const [hh, mm] = s.length > 10 ? s.slice(11, 16).split(':').map(Number) : [0, 0];
49
+ return Date.UTC(y, m - 1, d, hh, mm) / 60000;
50
+ };
51
+ const fromMin = (min: number, withTime: boolean) => {
52
+ const d = new Date(min * 60000);
53
+ const p = (n: number) => String(n).padStart(2, '0');
54
+ const date = `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
55
+ return withTime ? `${date}T${p(d.getUTCHours())}:${p(d.getUTCMinutes())}` : date;
56
+ };
57
+
58
+ // every IANA zone the browser knows, for the timezone datalist
59
+ function zoneList(): string[] {
60
+ try {
61
+ return (Intl as unknown as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf?.('timeZone') ?? [];
62
+ } catch {
63
+ return [];
64
+ }
65
+ }
66
+
67
+ export function EventPanel({ meta, onChange, calendars, fields, siteTimezone }: Props) {
68
+ const repeat = useMemo(() => parseRepeat(meta.rrule, meta.start), [meta.rrule, meta.start]);
69
+ const [skipDraft, setSkipDraft] = useState('');
70
+ const zones = useMemo(zoneList, []);
71
+ const multiDay = meta.allDay ? datePart(meta.end) !== datePart(meta.start) : datePart(meta.end) !== datePart(meta.start);
72
+ const [showEndDate, setShowEndDate] = useState(multiDay);
73
+
74
+ const setRepeat = (patch: Partial<RepeatParts>) => {
75
+ const next = { ...repeat, ...patch };
76
+ onChange({ rrule: buildRepeat(next, meta.start) });
77
+ };
78
+
79
+ // moving the start keeps the duration; the end never lands before the start
80
+ const setStart = (start: string) => {
81
+ if (!start) return;
82
+ const dur = Math.max(meta.allDay ? 0 : 15, toMin(meta.end || meta.start) - toMin(meta.start));
83
+ const end = fromMin(toMin(start) + dur, !meta.allDay);
84
+ const patch: Partial<EventMeta> = { start, end };
85
+ // a weekly rule that pinned the old weekday follows the new one
86
+ if (repeat.freq === 'WEEKLY' && repeat.byday.length === 1) {
87
+ patch.rrule = buildRepeat({ ...repeat, byday: [WEEKDAY_CODES[weekdayOf(start).dow]] }, start);
88
+ } else if (meta.rrule) patch.rrule = buildRepeat(repeat, start);
89
+ onChange(patch);
90
+ };
91
+ const setEnd = (end: string) => {
92
+ if (!end) return;
93
+ onChange({ end: toMin(end) < toMin(meta.start) ? meta.start : end });
94
+ };
95
+ const toggleAllDay = (allDay: boolean) => {
96
+ if (allDay) onChange({ allDay, start: datePart(meta.start), end: datePart(meta.end || meta.start) });
97
+ else {
98
+ const t = '10:00';
99
+ onChange({ allDay, start: `${datePart(meta.start)}T${t}`, end: `${datePart(meta.end || meta.start)}T11:00` });
100
+ }
101
+ };
102
+
103
+ const wd = weekdayOf(meta.start || new Date().toISOString().slice(0, 10));
104
+ const summary = describeRepeat(meta.rrule, meta.start);
105
+
106
+ return (
107
+ <>
108
+ <section className="pe-sb-section pe-ev">
109
+ <h3 className="pe-sb-title">When</h3>
110
+ <div className="pe-ev-row">
111
+ <label className="pe-field">
112
+ <span>{meta.allDay ? 'Starts' : 'Date'}</span>
113
+ <input type="date" value={datePart(meta.start)} onChange={(e) => setStart(meta.allDay ? e.target.value : `${e.target.value}T${timePart(meta.start) || '10:00'}`)} />
114
+ </label>
115
+ {!meta.allDay && (
116
+ <label className="pe-field pe-ev-time">
117
+ <span>Starts</span>
118
+ <input type="time" step={300} value={timePart(meta.start)} onChange={(e) => setStart(`${datePart(meta.start)}T${e.target.value}`)} />
119
+ </label>
120
+ )}
121
+ </div>
122
+ <div className="pe-ev-row">
123
+ {(meta.allDay || showEndDate) && (
124
+ <label className="pe-field">
125
+ <span>{meta.allDay ? 'Ends' : 'End date'}</span>
126
+ <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)}`)} />
127
+ </label>
128
+ )}
129
+ {!meta.allDay && (
130
+ <label className="pe-field pe-ev-time">
131
+ <span>Ends</span>
132
+ <input type="time" step={300} value={timePart(meta.end)} onChange={(e) => setEnd(`${datePart(meta.end || meta.start)}T${e.target.value}`)} />
133
+ </label>
134
+ )}
135
+ </div>
136
+ {!meta.allDay && !showEndDate && (
137
+ <button type="button" className="pe-ev-link" onClick={() => setShowEndDate(true)}>Ends on another day</button>
138
+ )}
139
+ <label className="pe-switch-row">
140
+ <span><b>All day</b><small>No start or end time</small></span>
141
+ <input type="checkbox" role="switch" className="pe-switch" checked={meta.allDay} onChange={(e) => toggleAllDay(e.target.checked)} />
142
+ </label>
143
+ <label className="pe-field pe-ev-tz">
144
+ <span>Timezone</span>
145
+ <input
146
+ type="text"
147
+ list="pe-ev-zones"
148
+ placeholder={`${siteTimezone || 'UTC'} (site default)`}
149
+ value={meta.timezone}
150
+ spellCheck={false}
151
+ onChange={(e) => onChange({ timezone: e.target.value.trim() })}
152
+ />
153
+ <datalist id="pe-ev-zones">{zones.map((z) => <option key={z} value={z} />)}</datalist>
154
+ </label>
155
+ </section>
156
+
157
+ <section className="pe-sb-section pe-ev">
158
+ <h3 className="pe-sb-title">Repeats</h3>
159
+ <label className="pe-field">
160
+ <span>Repeat {summary && <em>{summary}</em>}</span>
161
+ <select
162
+ value={repeat.freq}
163
+ onChange={(e) => {
164
+ const freq = e.target.value as RepeatParts['freq'];
165
+ if (freq === 'custom') onChange({ rrule: meta.rrule || 'FREQ=WEEKLY' });
166
+ else setRepeat({ freq, interval: 1 });
167
+ }}
168
+ >
169
+ <option value="none">Does not repeat</option>
170
+ <option value="DAILY">Daily</option>
171
+ <option value="WEEKLY">Weekly</option>
172
+ <option value="MONTHLY">Monthly</option>
173
+ <option value="YEARLY">Yearly</option>
174
+ <option value="custom">Custom rule…</option>
175
+ </select>
176
+ </label>
177
+ {repeat.freq !== 'none' && repeat.freq !== 'custom' && (
178
+ <div className="pe-ev-inline">
179
+ <span>Every</span>
180
+ <input type="number" min={1} max={99} value={repeat.interval} onChange={(e) => setRepeat({ interval: Math.max(1, Number(e.target.value) || 1) })} />
181
+ <span>{{ DAILY: 'days', WEEKLY: 'weeks', MONTHLY: 'months', YEARLY: 'years' }[repeat.freq]}</span>
182
+ </div>
183
+ )}
184
+ {repeat.freq === 'WEEKLY' && (
185
+ <div className="pe-ev-days" role="group" aria-label="Days of the week">
186
+ {WEEKDAY_CODES.map((code, i) => {
187
+ const on = repeat.byday.includes(code);
188
+ return (
189
+ <button
190
+ key={code}
191
+ type="button"
192
+ aria-pressed={on}
193
+ title={DAY_NAMES[i]}
194
+ onClick={() => {
195
+ const next = on ? repeat.byday.filter((c) => c !== code) : [...repeat.byday, code];
196
+ setRepeat({ byday: next.length ? next : [code] });
197
+ }}
198
+ >
199
+ {DAY_LETTERS[i]}
200
+ </button>
201
+ );
202
+ })}
203
+ </div>
204
+ )}
205
+ {repeat.freq === 'MONTHLY' && (
206
+ <div className="pe-ev-choices">
207
+ <label><input type="radio" name="pe-monthly" checked={repeat.monthly === 'day'} onChange={() => setRepeat({ monthly: 'day' })} /> On day {wd.day}</label>
208
+ <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>
209
+ </div>
210
+ )}
211
+ {repeat.freq === 'custom' && (
212
+ <label className="pe-field">
213
+ <span>RRULE <em>RFC 5545</em></span>
214
+ <input type="text" className="mono" spellCheck={false} value={meta.rrule} placeholder="FREQ=MONTHLY;BYDAY=2TU" onChange={(e) => onChange({ rrule: e.target.value.toUpperCase() })} />
215
+ </label>
216
+ )}
217
+ {repeat.freq !== 'none' && repeat.freq !== 'custom' && (
218
+ <div className="pe-ev-choices">
219
+ <label><input type="radio" name="pe-ends" checked={repeat.ends === 'never'} onChange={() => setRepeat({ ends: 'never' })} /> Never ends</label>
220
+ <label>
221
+ <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
222
+ {repeat.ends === 'on' && <input type="date" min={datePart(meta.start)} value={repeat.until} onChange={(e) => setRepeat({ until: e.target.value })} />}
223
+ </label>
224
+ <label>
225
+ <input type="radio" name="pe-ends" checked={repeat.ends === 'after'} onChange={() => setRepeat({ ends: 'after' })} /> After
226
+ {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) })} />}
227
+ {repeat.ends === 'after' && ' times'}
228
+ </label>
229
+ </div>
230
+ )}
231
+ {meta.rrule && (
232
+ <div className="pe-field">
233
+ <span>Skip dates <em>holidays, one-off cancellations</em></span>
234
+ {meta.exdates.length > 0 && (
235
+ <div className="pe-tags pe-ev-skips">
236
+ {meta.exdates.map((d) => (
237
+ <span key={d} className="pe-tag">
238
+ {datePart(d)}
239
+ <button type="button" aria-label={`Stop skipping ${datePart(d)}`} onClick={() => onChange({ exdates: meta.exdates.filter((x) => x !== d) })}>✕</button>
240
+ </span>
241
+ ))}
242
+ </div>
243
+ )}
244
+ <div className="pe-date">
245
+ <input type="date" value={skipDraft} min={datePart(meta.start)} onChange={(e) => setSkipDraft(e.target.value)} />
246
+ <button
247
+ type="button"
248
+ className="pe-btn sm"
249
+ disabled={!skipDraft}
250
+ onClick={() => {
251
+ // an exdate is an occurrence START (same form as `start`)
252
+ const value = meta.allDay ? skipDraft : `${skipDraft}T${timePart(meta.start)}`;
253
+ if (!meta.exdates.includes(value)) onChange({ exdates: [...meta.exdates, value].sort() });
254
+ setSkipDraft('');
255
+ }}
256
+ >
257
+ Skip
258
+ </button>
259
+ </div>
260
+ </div>
261
+ )}
262
+ </section>
263
+
264
+ <section className="pe-sb-section pe-ev">
265
+ <h3 className="pe-sb-title">Where</h3>
266
+ <label className="pe-field">
267
+ <span>Place</span>
268
+ <input type="text" placeholder="Fellowship Hall, Zoom, the park…" value={meta.location.name} onChange={(e) => onChange({ location: { ...meta.location, name: e.target.value } })} />
269
+ </label>
270
+ <label className="pe-field">
271
+ <span>Address</span>
272
+ <input type="text" placeholder="1200 River Rd, Riverside" value={meta.location.address} onChange={(e) => onChange({ location: { ...meta.location, address: e.target.value } })} />
273
+ </label>
274
+ <label className="pe-field">
275
+ <span>Map or meeting link</span>
276
+ <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() } })} />
277
+ </label>
278
+ </section>
279
+
280
+ <section className="pe-sb-section pe-ev">
281
+ <h3 className="pe-sb-title">Button</h3>
282
+ <div className="pe-ev-row">
283
+ <label className="pe-field">
284
+ <span>Label</span>
285
+ <input type="text" placeholder="Register, Get tickets…" maxLength={120} value={meta.cta.label} onChange={(e) => onChange({ cta: { ...meta.cta, label: e.target.value } })} />
286
+ </label>
287
+ <label className="pe-field pe-ev-time">
288
+ <span>Style</span>
289
+ <select value={meta.cta.style} onChange={(e) => onChange({ cta: { ...meta.cta, style: e.target.value as EventCta['style'] } })}>
290
+ <option value="primary">Solid</option>
291
+ <option value="outline">Outline</option>
292
+ </select>
293
+ </label>
294
+ </div>
295
+ <label className="pe-field">
296
+ <span>Link</span>
297
+ <input type="text" className="mono" placeholder="https://… or /page" value={meta.cta.href} onChange={(e) => onChange({ cta: { ...meta.cta, href: e.target.value.trim() } })} />
298
+ </label>
299
+ </section>
300
+
301
+ <section className="pe-sb-section pe-ev">
302
+ <h3 className="pe-sb-title">RSVP</h3>
303
+ <label className="pe-switch-row">
304
+ <span><b>Take RSVPs</b><small>A “Save my seat” form replaces the button; see who’s coming in Bookings</small></span>
305
+ <input type="checkbox" role="switch" className="pe-switch" checked={meta.rsvp.enabled} onChange={(e) => onChange({ rsvp: { ...meta.rsvp, enabled: e.target.checked } })} />
306
+ </label>
307
+ {meta.rsvp.enabled && (
308
+ <>
309
+ <div className="pe-ev-row" style={{ marginTop: 10 }}>
310
+ <label className="pe-field">
311
+ <span>Seats <em>0 = no limit</em></span>
312
+ <input type="number" min={0} value={meta.rsvp.capacity} onChange={(e) => onChange({ rsvp: { ...meta.rsvp, capacity: Math.max(0, Number(e.target.value) || 0) } })} />
313
+ </label>
314
+ <label className="pe-field">
315
+ <span>Max per person</span>
316
+ <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)) } })} />
317
+ </label>
318
+ </div>
319
+ <label className="pe-switch-row">
320
+ <span><b>Confirm automatically</b><small>Off: RSVPs wait for your approval in Bookings</small></span>
321
+ <input type="checkbox" role="switch" className="pe-switch" checked={meta.rsvp.autoConfirm} onChange={(e) => onChange({ rsvp: { ...meta.rsvp, autoConfirm: e.target.checked } })} />
322
+ </label>
323
+ </>
324
+ )}
325
+ </section>
326
+
327
+ <section className="pe-sb-section pe-ev">
328
+ <h3 className="pe-sb-title">Calendars</h3>
329
+ {calendars.length === 0 ? (
330
+ <p className="pe-ev-note">No calendars yet — the event shows on the main calendar. <a href="/admin/calendar/calendars" target="_blank" rel="noopener">Create one</a> to group events.</p>
331
+ ) : (
332
+ <div className="pe-ev-cals">
333
+ {calendars.map((c) => {
334
+ const on = meta.calendarIds.includes(c.id);
335
+ return (
336
+ <label key={c.id} className={on ? 'is-on' : ''}>
337
+ <input
338
+ type="checkbox"
339
+ checked={on}
340
+ onChange={(e) => onChange({ calendarIds: e.target.checked ? [...meta.calendarIds, c.id] : meta.calendarIds.filter((x) => x !== c.id) })}
341
+ />
342
+ <i style={{ background: c.color }} aria-hidden="true" />
343
+ <span>{c.name}</span>
344
+ </label>
345
+ );
346
+ })}
347
+ </div>
348
+ )}
349
+ </section>
350
+
351
+ {fields.length > 0 && (
352
+ <section className="pe-sb-section pe-ev">
353
+ <h3 className="pe-sb-title">Details</h3>
354
+ {fields.map((f) => {
355
+ const value = meta.fields[f.key] ?? '';
356
+ const set = (v: string) => onChange({ fields: { ...meta.fields, [f.key]: v } });
357
+ return (
358
+ <label key={f.key} className="pe-field">
359
+ <span>{f.label}</span>
360
+ {f.type === 'select' ? (
361
+ <select value={value} onChange={(e) => set(e.target.value)}>
362
+ <option value="">—</option>
363
+ {f.options.map((o) => <option key={o} value={o}>{o}</option>)}
364
+ </select>
365
+ ) : f.type === 'textarea' ? (
366
+ <textarea rows={3} value={value} onChange={(e) => set(e.target.value)} />
367
+ ) : (
368
+ <input type={f.type === 'number' ? 'number' : f.type === 'url' ? 'url' : 'text'} value={value} onChange={(e) => set(e.target.value)} />
369
+ )}
370
+ </label>
371
+ );
372
+ })}
373
+ </section>
374
+ )}
375
+
376
+ <section className="pe-sb-section pe-ev">
377
+ <label className="pe-switch-row">
378
+ <span><b>Cancelled</b><small>Stays on the calendar, marked cancelled, so nobody wonders where it went</small></span>
379
+ <input type="checkbox" role="switch" className="pe-switch" checked={meta.cancelled} onChange={(e) => onChange({ cancelled: e.target.checked })} />
380
+ </label>
381
+ </section>
382
+ </>
383
+ );
384
+ }
385
+
386
+ // "Sat, Oct 3 · 11:00 AM – 4:00 PM" for the byline under the title
387
+ export function eventWhenLabel(meta: Pick<EventMeta, 'start' | 'end' | 'allDay'>): string {
388
+ if (!meta.start) return 'Set a date';
389
+ const d = (s: string) => {
390
+ const [y, m, day] = s.slice(0, 10).split('-').map(Number);
391
+ return new Date(Date.UTC(y, m - 1, day)).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' });
392
+ };
393
+ const t = (s: string) => {
394
+ if (s.length <= 10) return '';
395
+ const [hh, mm] = s.slice(11, 16).split(':').map(Number);
396
+ return new Date(Date.UTC(2000, 0, 1, hh, mm)).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit', timeZone: 'UTC' });
397
+ };
398
+ const sameDay = meta.start.slice(0, 10) === meta.end.slice(0, 10);
399
+ if (meta.allDay) return sameDay || !meta.end ? `${d(meta.start)} · All day` : `${d(meta.start)} – ${d(meta.end)}`;
400
+ return sameDay || !meta.end ? `${d(meta.start)} · ${t(meta.start)} – ${t(meta.end)}` : `${d(meta.start)} ${t(meta.start)} – ${d(meta.end)} ${t(meta.end)}`;
401
+ }