@acms-subir/core 0.1.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 (237) hide show
  1. package/CHANGELOG.md +106 -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 -315
  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 +193 -2
  17. package/ops-check.mjs +27 -0
  18. package/package.json +35 -12
  19. package/src/actions/index.ts +744 -38
  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 +455 -47
  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 +216 -97
  30. package/src/components/NavManager.tsx +83 -38
  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 +826 -271
  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/cards.tsx +17 -1
  45. package/src/components/editor/extensions/codeBlock.tsx +1 -1
  46. package/src/components/editor/extensions/events.tsx +153 -0
  47. package/src/components/editor/extensions/infoCards.tsx +16 -1
  48. package/src/components/editor/extensions/mediaCards.tsx +36 -2
  49. package/src/components/editor/extensions/slash.tsx +77 -9
  50. package/src/components/editor/extensions/toc.tsx +89 -0
  51. package/src/components/editor/extensions/toggle.tsx +70 -0
  52. package/src/components/site/Pager.astro +24 -0
  53. package/src/components/site/PostCard.astro +64 -0
  54. package/src/components/site/TagLabel.astro +11 -0
  55. package/src/layouts/Admin.astro +289 -49
  56. package/src/layouts/Site.astro +331 -57
  57. package/src/lib/agent-api.ts +13 -0
  58. package/src/lib/agent-discovery.ts +40 -0
  59. package/src/lib/agent.ts +114 -0
  60. package/src/lib/ai-config.ts +58 -0
  61. package/src/lib/ai-schema.ts +22 -0
  62. package/src/lib/ai.ts +90 -0
  63. package/src/lib/analytics.ts +121 -0
  64. package/src/lib/api-keys.ts +65 -0
  65. package/src/lib/api.ts +52 -0
  66. package/src/lib/auth.ts +25 -5
  67. package/src/lib/backup/content.ts +36 -0
  68. package/src/lib/backup/unzip.ts +12 -1
  69. package/src/lib/backup/zip.ts +1 -1
  70. package/src/lib/booking/agent.ts +108 -0
  71. package/src/lib/booking/availability.ts +221 -0
  72. package/src/lib/booking/emails.ts +115 -0
  73. package/src/lib/booking/forms.ts +77 -0
  74. package/src/lib/booking/service.ts +277 -0
  75. package/src/lib/booking/view.ts +54 -0
  76. package/src/lib/calendar/agent-schema.ts +48 -0
  77. package/src/lib/calendar/agent.ts +136 -0
  78. package/src/lib/calendar/api.ts +81 -0
  79. package/src/lib/calendar/cards.ts +102 -0
  80. package/src/lib/calendar/config.ts +52 -0
  81. package/src/lib/calendar/data.ts +130 -0
  82. package/src/lib/calendar/events.ts +261 -0
  83. package/src/lib/calendar/feeds.ts +43 -0
  84. package/src/lib/calendar/hooks.ts +38 -0
  85. package/src/lib/calendar/http.ts +30 -0
  86. package/src/lib/calendar/ics-write.ts +120 -0
  87. package/src/lib/calendar/ics.ts +15 -5
  88. package/src/lib/calendar/import.ts +203 -0
  89. package/src/lib/calendar/render.ts +120 -37
  90. package/src/lib/calendar/rrule.ts +149 -0
  91. package/src/lib/calendar/seo.ts +48 -0
  92. package/src/lib/design-service.ts +58 -0
  93. package/src/lib/designDiff.ts +145 -0
  94. package/src/lib/designPreview.ts +112 -0
  95. package/src/lib/documents/cleanup.ts +31 -0
  96. package/src/lib/documents/extract.ts +154 -0
  97. package/src/lib/documents/vendor.d.ts +4 -0
  98. package/src/lib/drafts.ts +131 -0
  99. package/src/lib/dynamicBlocks.ts +79 -7
  100. package/src/lib/email/accounts.ts +55 -0
  101. package/src/lib/email/config.ts +86 -0
  102. package/src/lib/email/migration.ts +24 -0
  103. package/src/lib/email/providers.ts +89 -0
  104. package/src/lib/email/service.ts +78 -0
  105. package/src/lib/email/store.ts +167 -0
  106. package/src/lib/email/templates.ts +18 -0
  107. package/src/lib/email/types.ts +32 -0
  108. package/src/lib/email/webhooks.ts +87 -0
  109. package/src/lib/email.ts +15 -0
  110. package/src/lib/env.ts +1 -1
  111. package/src/lib/legal/index.ts +25 -0
  112. package/src/lib/mobile/bookings.ts +73 -0
  113. package/src/lib/mobile/calendar.ts +85 -0
  114. package/src/lib/mobile/content.ts +85 -0
  115. package/src/lib/mobile/http.ts +63 -0
  116. package/src/lib/mobile/route.ts +20 -0
  117. package/src/lib/ops.ts +8 -0
  118. package/src/lib/preview.ts +21 -1
  119. package/src/lib/repo/migrations.ts +254 -0
  120. package/src/lib/repo/sql.ts +956 -46
  121. package/src/lib/repo/types.ts +140 -3
  122. package/src/lib/scheduler.ts +25 -0
  123. package/src/lib/schema.ts +362 -0
  124. package/src/lib/search-i18n.ts +3 -0
  125. package/src/lib/socialIcons.ts +32 -0
  126. package/src/lib/tagColor.ts +11 -0
  127. package/src/lib/theme.ts +59 -40
  128. package/src/lib/themeTokens.ts +174 -0
  129. package/src/lib/umami.ts +108 -0
  130. package/src/lib/webhooks.ts +139 -0
  131. package/src/middleware.ts +36 -3
  132. package/src/pages/404.astro +23 -0
  133. package/src/pages/[...slug].astro +10 -5
  134. package/src/pages/account/[action].astro +53 -0
  135. package/src/pages/admin/ai.astro +12 -0
  136. package/src/pages/admin/c/[collection]/[id].astro +14 -4
  137. package/src/pages/admin/c/[collection]/index.astro +127 -91
  138. package/src/pages/admin/calendar/bookings.astro +400 -0
  139. package/src/pages/admin/calendar/calendars.astro +265 -0
  140. package/src/pages/admin/calendar/events/[id].astro +56 -0
  141. package/src/pages/admin/calendar/events/index.astro +408 -0
  142. package/src/pages/admin/calendar/index.ts +4 -0
  143. package/src/pages/admin/calendar/integrations.astro +221 -0
  144. package/src/pages/admin/calendar/resources/[id].astro +180 -0
  145. package/src/pages/admin/calendar/resources/index.astro +106 -0
  146. package/src/pages/admin/calendar/resources/link.astro +196 -0
  147. package/src/pages/admin/design.astro +16 -4
  148. package/src/pages/admin/email.astro +13 -0
  149. package/src/pages/admin/index.astro +237 -87
  150. package/src/pages/admin/links.astro +3 -3
  151. package/src/pages/admin/login.astro +14 -10
  152. package/src/pages/admin/navigation.astro +8 -2
  153. package/src/pages/admin/pages/[id].astro +14 -3
  154. package/src/pages/admin/pages/index.astro +41 -47
  155. package/src/pages/admin/settings.astro +475 -276
  156. package/src/pages/admin/tags.astro +4 -4
  157. package/src/pages/admin/users.astro +38 -28
  158. package/src/pages/api/agent/docs.ts +33 -0
  159. package/src/pages/api/agent/index.ts +21 -0
  160. package/src/pages/api/ai/clean.ts +13 -0
  161. package/src/pages/api/ai/draft.ts +14 -0
  162. package/src/pages/api/ai/settings.ts +53 -0
  163. package/src/pages/api/ai/test.ts +12 -0
  164. package/src/pages/api/backup.ts +8 -21
  165. package/src/pages/api/calendar/availability.ts +35 -0
  166. package/src/pages/api/calendar/bookings/[id].ts +49 -0
  167. package/src/pages/api/calendar/bookings/index.ts +73 -0
  168. package/src/pages/api/calendar/calendars.ts +35 -0
  169. package/src/pages/api/calendar/docs.ts +64 -0
  170. package/src/pages/api/calendar/events/[id].ts +63 -0
  171. package/src/pages/api/calendar/events/index.ts +112 -0
  172. package/src/pages/api/calendar/resources.ts +34 -0
  173. package/src/pages/api/design/capabilities.ts +10 -0
  174. package/src/pages/api/design/index.ts +58 -0
  175. package/src/pages/api/design/keys.ts +24 -0
  176. package/src/pages/api/design/media.ts +32 -0
  177. package/src/pages/api/email/run.ts +11 -0
  178. package/src/pages/api/email/settings.ts +61 -0
  179. package/src/pages/api/email/webhooks/[provider].ts +4 -0
  180. package/src/pages/api/mcp.ts +59 -0
  181. package/src/pages/api/media/upload.ts +2 -2
  182. package/src/pages/api/ops/status.ts +39 -0
  183. package/src/pages/api/restore.ts +27 -37
  184. package/src/pages/api/search.ts +18 -4
  185. package/src/pages/api/v1/availability.ts +7 -0
  186. package/src/pages/api/v1/bookings.ts +17 -0
  187. package/src/pages/api/v1/calendars.ts +7 -0
  188. package/src/pages/api/v1/content/[id].ts +7 -0
  189. package/src/pages/api/v1/content/index.ts +7 -0
  190. package/src/pages/api/v1/events.ts +7 -0
  191. package/src/pages/api/v1/resources.ts +7 -0
  192. package/src/pages/author/[slug].astro +43 -35
  193. package/src/pages/blog/[slug].astro +140 -66
  194. package/src/pages/blog/index.astro +95 -30
  195. package/src/pages/blog/tag/[slug].astro +45 -40
  196. package/src/pages/book/[resource]/[link].astro +70 -0
  197. package/src/pages/book/[resource]/index.astro +63 -0
  198. package/src/pages/book/[resource].ics.ts +38 -0
  199. package/src/pages/book/index.astro +47 -0
  200. package/src/pages/book/manage/[token].astro +108 -0
  201. package/src/pages/calendar/[slug].ics.ts +27 -0
  202. package/src/pages/calendar/feed.ics.ts +15 -0
  203. package/src/pages/calendar/index.astro +191 -0
  204. package/src/pages/embed/book/[resource]/[link].astro +33 -0
  205. package/src/pages/embed/calendar/[slug].astro +43 -0
  206. package/src/pages/events/[slug].astro +74 -0
  207. package/src/pages/events/[slug].ics.ts +25 -0
  208. package/src/pages/index.astro +9 -3
  209. package/src/pages/legal/privacy.astro +4 -0
  210. package/src/pages/legal/terms.astro +4 -0
  211. package/src/pages/links.astro +33 -7
  212. package/src/pages/llms.txt.ts +4 -0
  213. package/src/pages/preview/[type]/[id].astro +34 -8
  214. package/src/pages/search.astro +40 -20
  215. package/src/pages/sitemap.xml.ts +16 -1
  216. package/src/styles/admin.css +928 -105
  217. package/src/styles/cards.css +101 -60
  218. package/src/styles/editor.css +532 -102
  219. package/src/styles/email-settings.css +117 -0
  220. package/src/styles/sections.css +12 -0
  221. package/src/styles/site.css +938 -278
  222. package/src/styles/theme-editor.css +135 -0
  223. package/src/theme-sdk/contract.d.mts +619 -0
  224. package/src/theme-sdk/contract.mjs +80 -0
  225. package/src/theme-sdk/index.ts +15 -0
  226. package/src/theme-sdk/tokens.d.mts +148 -0
  227. package/src/theme-sdk/tokens.mjs +52 -0
  228. package/src/themes/editorial/templates/home.astro +4 -4
  229. package/src/themes/editorial/templates/post-minimal.astro +2 -2
  230. package/src/themes/editorial/theme.css +22 -37
  231. package/src/themes/editorial/theme.json +23 -3
  232. package/src/themes/field/theme.json +26 -9
  233. package/src/themes/ink/theme.json +30 -12
  234. package/src/themes/night/theme.css +4 -7
  235. package/src/themes/night/theme.json +27 -10
  236. package/src/virtual.d.ts +14 -0
  237. package/src/worker.ts +5 -1
@@ -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
+ }
@@ -0,0 +1,81 @@
1
+ import { useEffect, useState } from 'react';
2
+ import type { Editor } from '@tiptap/react';
3
+
4
+ // Desktop formatting dock, floating at the bottom of the editor sheet. It
5
+ // shows the block type + active marks at the caret, fades out while you type
6
+ // and comes back when the mouse moves. Touch devices use MobileToolbar.
7
+
8
+ interface Props {
9
+ editor: Editor;
10
+ words: number;
11
+ onLink: () => void;
12
+ onShortcuts: () => void;
13
+ }
14
+
15
+ const I = {
16
+ plus: 'M12 5v14M5 12h14',
17
+ link: 'M10 14a4 4 0 0 0 6 0l3-3a4 4 0 0 0-6-6l-1.5 1.5M14 10a4 4 0 0 0-6 0l-3 3a4 4 0 0 0 6 6l1.5-1.5',
18
+ quote: 'M7 7h4v4c0 3-2 5-4 6M14 7h4v4c0 3-2 5-4 6',
19
+ list: 'M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01',
20
+ };
21
+ const Svg = ({ d }: { d: string }) => (
22
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d={d} /></svg>
23
+ );
24
+
25
+ export function FormatDock({ editor, words, onLink, onShortcuts }: Props) {
26
+ const [touch] = useState(() => typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches);
27
+ const [typing, setTyping] = useState(false);
28
+ const [, rerender] = useState(0);
29
+
30
+ useEffect(() => {
31
+ if (touch) return;
32
+ const onTx = () => rerender((n) => n + 1);
33
+ const onType = () => setTyping(true);
34
+ const onMove = () => setTyping(false);
35
+ editor.on('transaction', onTx);
36
+ editor.on('update', onType);
37
+ window.addEventListener('mousemove', onMove, { passive: true });
38
+ return () => {
39
+ editor.off('transaction', onTx);
40
+ editor.off('update', onType);
41
+ window.removeEventListener('mousemove', onMove);
42
+ };
43
+ }, [editor, touch]);
44
+
45
+ if (touch) return null;
46
+
47
+ const c = () => editor.chain().focus();
48
+ const btn = (key: string, label: string, active: boolean, run: () => void, child: React.ReactNode) => (
49
+ <button
50
+ key={key}
51
+ type="button"
52
+ title={label}
53
+ aria-label={label}
54
+ aria-pressed={active}
55
+ onMouseDown={(e) => e.preventDefault()}
56
+ onClick={run}
57
+ >
58
+ {child}
59
+ </button>
60
+ );
61
+ const isPara = editor.isActive('paragraph');
62
+ const mins = Math.max(1, Math.round(words / 220));
63
+
64
+ return (
65
+ <div className={`pe-dock ${typing ? 'is-typing' : ''}`} role="toolbar" aria-label="Formatting">
66
+ {btn('p', 'Paragraph', isPara, () => c().setParagraph().run(), <span className="pe-dock-t">¶</span>)}
67
+ {btn('h2', 'Heading 2', editor.isActive('heading', { level: 2 }), () => c().toggleHeading({ level: 2 }).run(), <span className="pe-dock-t">H2</span>)}
68
+ {btn('h3', 'Heading 3', editor.isActive('heading', { level: 3 }), () => c().toggleHeading({ level: 3 }).run(), <span className="pe-dock-t">H3</span>)}
69
+ <span className="pe-dock-sep" />
70
+ {btn('bold', 'Bold (⌘B)', editor.isActive('bold'), () => c().toggleBold().run(), <b>B</b>)}
71
+ {btn('italic', 'Italic (⌘I)', editor.isActive('italic'), () => c().toggleItalic().run(), <i>I</i>)}
72
+ {btn('link', 'Link (⌘K)', editor.isActive('link'), onLink, <Svg d={I.link} />)}
73
+ {btn('quote', 'Quote', editor.isActive('blockquote'), () => c().toggleBlockquote().run(), <Svg d={I.quote} />)}
74
+ {btn('list', 'Bulleted list', editor.isActive('bulletList'), () => c().toggleBulletList().run(), <Svg d={I.list} />)}
75
+ <span className="pe-dock-sep" />
76
+ {btn('insert', 'Insert a card (/)', false, () => c().insertContent('/').run(), <Svg d={I.plus} />)}
77
+ <span className="pe-dock-count">{words.toLocaleString()} {words === 1 ? 'word' : 'words'} · {mins} min</span>
78
+ <button type="button" className="pe-dock-kbd" title="Keyboard shortcuts (⌘/)" onClick={onShortcuts}>⌘/</button>
79
+ </div>
80
+ );
81
+ }