@acms-subir/core 0.2.0 → 0.3.1

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 (254) hide show
  1. package/CHANGELOG.md +87 -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/live-video.md +91 -0
  15. package/docs/theme-authoring.md +103 -0
  16. package/email-runner.mjs +32 -0
  17. package/integration.mjs +189 -2
  18. package/ops-check.mjs +27 -0
  19. package/package.json +27 -5
  20. package/src/actions/index.ts +578 -49
  21. package/src/components/AiCleaner.tsx +291 -0
  22. package/src/components/AiSettings.tsx +575 -0
  23. package/src/components/CommandPalette.tsx +200 -0
  24. package/src/components/ContentList.astro +246 -0
  25. package/src/components/DesignAccess.tsx +182 -0
  26. package/src/components/DesignManager.tsx +418 -46
  27. package/src/components/EmailSettings.tsx +521 -0
  28. package/src/components/EventPage.astro +178 -0
  29. package/src/components/LegalPage.astro +27 -0
  30. package/src/components/LinksManager.tsx +119 -58
  31. package/src/components/LiveCard.astro +174 -0
  32. package/src/components/LiveManager.tsx +910 -0
  33. package/src/components/NavManager.tsx +42 -43
  34. package/src/components/ThemeScript.astro +50 -0
  35. package/src/components/ThemeSections.astro +58 -0
  36. package/src/components/ThemeSectionsEditor.tsx +513 -0
  37. package/src/components/WidgetRenderer.astro +3 -0
  38. package/src/components/booking/BookingFlow.astro +150 -0
  39. package/src/components/booking/HoursEditor.astro +63 -0
  40. package/src/components/builder/PageBuilder.tsx +10 -2
  41. package/src/components/editor/EventPanel.tsx +401 -0
  42. package/src/components/editor/FormatDock.tsx +81 -0
  43. package/src/components/editor/LivePane.tsx +122 -0
  44. package/src/components/editor/MobileToolbar.tsx +93 -0
  45. package/src/components/editor/PostEditor.tsx +605 -223
  46. package/src/components/editor/PreviewOverlay.tsx +242 -43
  47. package/src/components/editor/extensions/calendar.tsx +51 -7
  48. package/src/components/editor/extensions/events.tsx +153 -0
  49. package/src/components/editor/extensions/slash.tsx +7 -1
  50. package/src/components/site/Pager.astro +24 -0
  51. package/src/components/site/PostCard.astro +64 -0
  52. package/src/components/site/TagLabel.astro +11 -0
  53. package/src/layouts/Admin.astro +289 -49
  54. package/src/layouts/Site.astro +352 -59
  55. package/src/lib/agent-api.ts +13 -0
  56. package/src/lib/agent-discovery.ts +40 -0
  57. package/src/lib/agent.ts +114 -0
  58. package/src/lib/ai-config.ts +58 -0
  59. package/src/lib/ai-schema.ts +22 -0
  60. package/src/lib/ai.ts +90 -0
  61. package/src/lib/api-keys.ts +65 -0
  62. package/src/lib/api.ts +56 -0
  63. package/src/lib/auth.ts +25 -5
  64. package/src/lib/backup/content.ts +36 -0
  65. package/src/lib/backup/unzip.ts +12 -1
  66. package/src/lib/backup/zip.ts +1 -1
  67. package/src/lib/booking/agent.ts +108 -0
  68. package/src/lib/booking/availability.ts +221 -0
  69. package/src/lib/booking/emails.ts +115 -0
  70. package/src/lib/booking/forms.ts +77 -0
  71. package/src/lib/booking/service.ts +277 -0
  72. package/src/lib/booking/view.ts +54 -0
  73. package/src/lib/calendar/agent-schema.ts +48 -0
  74. package/src/lib/calendar/agent.ts +136 -0
  75. package/src/lib/calendar/api.ts +81 -0
  76. package/src/lib/calendar/cards.ts +102 -0
  77. package/src/lib/calendar/config.ts +52 -0
  78. package/src/lib/calendar/data.ts +130 -0
  79. package/src/lib/calendar/events.ts +261 -0
  80. package/src/lib/calendar/feeds.ts +43 -0
  81. package/src/lib/calendar/hooks.ts +38 -0
  82. package/src/lib/calendar/http.ts +30 -0
  83. package/src/lib/calendar/ics-write.ts +120 -0
  84. package/src/lib/calendar/ics.ts +15 -5
  85. package/src/lib/calendar/import.ts +203 -0
  86. package/src/lib/calendar/render.ts +120 -37
  87. package/src/lib/calendar/rrule.ts +149 -0
  88. package/src/lib/calendar/seo.ts +48 -0
  89. package/src/lib/design-service.ts +58 -0
  90. package/src/lib/designDiff.ts +145 -0
  91. package/src/lib/designPreview.ts +110 -12
  92. package/src/lib/documents/cleanup.ts +31 -0
  93. package/src/lib/documents/extract.ts +154 -0
  94. package/src/lib/documents/vendor.d.ts +4 -0
  95. package/src/lib/drafts.ts +60 -6
  96. package/src/lib/dynamicBlocks.ts +40 -8
  97. package/src/lib/email/accounts.ts +55 -0
  98. package/src/lib/email/config.ts +86 -0
  99. package/src/lib/email/migration.ts +24 -0
  100. package/src/lib/email/providers.ts +89 -0
  101. package/src/lib/email/service.ts +78 -0
  102. package/src/lib/email/store.ts +167 -0
  103. package/src/lib/email/templates.ts +18 -0
  104. package/src/lib/email/types.ts +32 -0
  105. package/src/lib/email/webhooks.ts +87 -0
  106. package/src/lib/email.ts +15 -0
  107. package/src/lib/env.ts +1 -1
  108. package/src/lib/legal/index.ts +25 -0
  109. package/src/lib/live/client.ts +88 -0
  110. package/src/lib/live/crypto.ts +28 -0
  111. package/src/lib/live/migration.ts +15 -0
  112. package/src/lib/live/providers.ts +152 -0
  113. package/src/lib/live/service.ts +170 -0
  114. package/src/lib/live/signals.ts +89 -0
  115. package/src/lib/live/store.ts +49 -0
  116. package/src/lib/live/types.ts +52 -0
  117. package/src/lib/live/view.ts +78 -0
  118. package/src/lib/mobile/bookings.ts +73 -0
  119. package/src/lib/mobile/calendar.ts +85 -0
  120. package/src/lib/mobile/content.ts +85 -0
  121. package/src/lib/mobile/http.ts +63 -0
  122. package/src/lib/mobile/route.ts +20 -0
  123. package/src/lib/ops.ts +8 -0
  124. package/src/lib/preview.ts +21 -1
  125. package/src/lib/repo/migrations.ts +221 -0
  126. package/src/lib/repo/sql.ts +907 -68
  127. package/src/lib/repo/types.ts +125 -5
  128. package/src/lib/scheduler.ts +27 -0
  129. package/src/lib/schema.ts +316 -1
  130. package/src/lib/search-i18n.ts +3 -0
  131. package/src/lib/socialIcons.ts +32 -0
  132. package/src/lib/tagColor.ts +11 -0
  133. package/src/lib/theme.ts +59 -40
  134. package/src/lib/themeTokens.ts +183 -0
  135. package/src/lib/umami.ts +108 -0
  136. package/src/lib/webhook-sql.ts +68 -0
  137. package/src/lib/webhooks.ts +147 -0
  138. package/src/lib/widgets.ts +1 -0
  139. package/src/middleware.ts +45 -3
  140. package/src/pages/404.astro +23 -0
  141. package/src/pages/[...slug].astro +14 -5
  142. package/src/pages/account/[action].astro +53 -0
  143. package/src/pages/admin/ai.astro +12 -0
  144. package/src/pages/admin/c/[collection]/[id].astro +4 -1
  145. package/src/pages/admin/c/[collection]/index.astro +127 -92
  146. package/src/pages/admin/calendar/bookings.astro +400 -0
  147. package/src/pages/admin/calendar/calendars.astro +265 -0
  148. package/src/pages/admin/calendar/events/[id].astro +56 -0
  149. package/src/pages/admin/calendar/events/index.astro +408 -0
  150. package/src/pages/admin/calendar/index.ts +4 -0
  151. package/src/pages/admin/calendar/integrations.astro +4 -0
  152. package/src/pages/admin/calendar/resources/[id].astro +180 -0
  153. package/src/pages/admin/calendar/resources/index.astro +106 -0
  154. package/src/pages/admin/calendar/resources/link.astro +196 -0
  155. package/src/pages/admin/design.astro +10 -3
  156. package/src/pages/admin/email.astro +13 -0
  157. package/src/pages/admin/index.astro +232 -85
  158. package/src/pages/admin/integrations.astro +237 -0
  159. package/src/pages/admin/links.astro +3 -3
  160. package/src/pages/admin/live.astro +12 -0
  161. package/src/pages/admin/login.astro +14 -10
  162. package/src/pages/admin/navigation.astro +8 -2
  163. package/src/pages/admin/pages/[id].astro +4 -1
  164. package/src/pages/admin/pages/index.astro +41 -48
  165. package/src/pages/admin/settings.astro +418 -367
  166. package/src/pages/admin/tags.astro +4 -4
  167. package/src/pages/admin/users.astro +38 -28
  168. package/src/pages/api/agent/docs.ts +33 -0
  169. package/src/pages/api/agent/index.ts +21 -0
  170. package/src/pages/api/ai/clean.ts +13 -0
  171. package/src/pages/api/ai/draft.ts +14 -0
  172. package/src/pages/api/ai/settings.ts +53 -0
  173. package/src/pages/api/ai/test.ts +12 -0
  174. package/src/pages/api/backup.ts +8 -21
  175. package/src/pages/api/calendar/availability.ts +35 -0
  176. package/src/pages/api/calendar/bookings/[id].ts +49 -0
  177. package/src/pages/api/calendar/bookings/index.ts +73 -0
  178. package/src/pages/api/calendar/calendars.ts +35 -0
  179. package/src/pages/api/calendar/docs.ts +66 -0
  180. package/src/pages/api/calendar/events/[id].ts +63 -0
  181. package/src/pages/api/calendar/events/index.ts +112 -0
  182. package/src/pages/api/calendar/resources.ts +34 -0
  183. package/src/pages/api/design/capabilities.ts +10 -0
  184. package/src/pages/api/design/index.ts +58 -0
  185. package/src/pages/api/design/keys.ts +24 -0
  186. package/src/pages/api/design/media.ts +32 -0
  187. package/src/pages/api/email/run.ts +11 -0
  188. package/src/pages/api/email/settings.ts +61 -0
  189. package/src/pages/api/email/webhooks/[provider].ts +4 -0
  190. package/src/pages/api/live/oauth/youtube/callback.ts +19 -0
  191. package/src/pages/api/live/settings.ts +45 -0
  192. package/src/pages/api/live/status.ts +7 -0
  193. package/src/pages/api/live/thumb.ts +48 -0
  194. package/src/pages/api/mcp.ts +59 -0
  195. package/src/pages/api/media/upload.ts +2 -2
  196. package/src/pages/api/ops/status.ts +39 -0
  197. package/src/pages/api/restore.ts +27 -37
  198. package/src/pages/api/search.ts +18 -4
  199. package/src/pages/api/v1/availability.ts +7 -0
  200. package/src/pages/api/v1/bookings.ts +17 -0
  201. package/src/pages/api/v1/calendars.ts +7 -0
  202. package/src/pages/api/v1/content/[id].ts +7 -0
  203. package/src/pages/api/v1/content/index.ts +7 -0
  204. package/src/pages/api/v1/events.ts +7 -0
  205. package/src/pages/api/v1/resources.ts +7 -0
  206. package/src/pages/api/webhooks/docs.ts +61 -0
  207. package/src/pages/author/[slug].astro +43 -35
  208. package/src/pages/blog/[slug].astro +137 -66
  209. package/src/pages/blog/index.astro +95 -30
  210. package/src/pages/blog/tag/[slug].astro +45 -40
  211. package/src/pages/book/[resource]/[link].astro +70 -0
  212. package/src/pages/book/[resource]/index.astro +63 -0
  213. package/src/pages/book/[resource].ics.ts +38 -0
  214. package/src/pages/book/index.astro +47 -0
  215. package/src/pages/book/manage/[token].astro +108 -0
  216. package/src/pages/calendar/[slug].ics.ts +27 -0
  217. package/src/pages/calendar/feed.ics.ts +15 -0
  218. package/src/pages/calendar/index.astro +191 -0
  219. package/src/pages/embed/book/[resource]/[link].astro +33 -0
  220. package/src/pages/embed/calendar/[slug].astro +43 -0
  221. package/src/pages/events/[slug].astro +74 -0
  222. package/src/pages/events/[slug].ics.ts +25 -0
  223. package/src/pages/index.astro +12 -3
  224. package/src/pages/legal/privacy.astro +4 -0
  225. package/src/pages/legal/terms.astro +4 -0
  226. package/src/pages/links.astro +29 -3
  227. package/src/pages/llms.txt.ts +4 -0
  228. package/src/pages/preview/[type]/[id].astro +27 -6
  229. package/src/pages/search.astro +40 -20
  230. package/src/pages/sitemap.xml.ts +16 -1
  231. package/src/styles/admin.css +924 -105
  232. package/src/styles/cards.css +92 -74
  233. package/src/styles/editor.css +492 -114
  234. package/src/styles/email-settings.css +117 -0
  235. package/src/styles/live-admin.css +126 -0
  236. package/src/styles/live.css +199 -0
  237. package/src/styles/sections.css +12 -0
  238. package/src/styles/site.css +938 -281
  239. package/src/styles/theme-editor.css +135 -0
  240. package/src/theme-sdk/contract.d.mts +619 -0
  241. package/src/theme-sdk/contract.mjs +81 -0
  242. package/src/theme-sdk/index.ts +15 -0
  243. package/src/theme-sdk/tokens.d.mts +148 -0
  244. package/src/theme-sdk/tokens.mjs +55 -0
  245. package/src/themes/editorial/templates/home.astro +4 -4
  246. package/src/themes/editorial/templates/post-minimal.astro +2 -2
  247. package/src/themes/editorial/theme.css +27 -37
  248. package/src/themes/editorial/theme.json +25 -3
  249. package/src/themes/field/theme.json +27 -9
  250. package/src/themes/ink/theme.json +30 -12
  251. package/src/themes/night/theme.css +4 -7
  252. package/src/themes/night/theme.json +28 -10
  253. package/src/virtual.d.ts +14 -0
  254. package/src/worker.ts +5 -1
@@ -0,0 +1,178 @@
1
+ ---
2
+ // The built-in event page (feature image, date block, place, button, add to
3
+ // calendar, share, related events). Themes replace it with
4
+ // templates/event.astro, which receives the same props. Every element
5
+ // carries a stable .pc-ev-* class for CSS overrides.
6
+ import type { CalEvent, Calendar, Settings } from '../lib/schema';
7
+ import { CALENDAR, eventIcsUrl, eventUrl, calendarPageUrl } from '../lib/calendar/config';
8
+ import { addToCalendarLinks, eventTimeZone, formatOccurrence, safeColor, type Occurrence } from '../lib/calendar/events';
9
+ import { describeRepeat } from '../lib/calendar/rrule';
10
+ import { eventCardHtml, safeHref } from '../lib/calendar/cards';
11
+ import { siteLocale } from '../lib/dates';
12
+
13
+ interface Props {
14
+ event: CalEvent;
15
+ occurrence: Occurrence;
16
+ settings: Settings;
17
+ calendars: Calendar[];
18
+ html: string;
19
+ upcoming: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'>[];
20
+ origin: string;
21
+ preview?: boolean;
22
+ // RSVP state when the event takes RSVPs (null otherwise)
23
+ rsvp?: { going: number; left: number | null; maxSeats: number; error: string; open: boolean } | null;
24
+ }
25
+ const { event: e, occurrence: o, settings, calendars, html, upcoming, origin, preview = false, rsvp = null } = Astro.props;
26
+ const tz = eventTimeZone(e, settings);
27
+ const locale = siteLocale(settings);
28
+ const f = formatOccurrence(o, tz, locale);
29
+ const pageUrl = origin + eventUrl(e.slug);
30
+ const links = addToCalendarLinks(e, o, { pageUrl, icsUrl: eventIcsUrl(e.slug), tz });
31
+ const repeats = describeRepeat(e.rrule, e.start, (ymd) => {
32
+ const [y, m, d] = ymd.split('-').map(Number);
33
+ return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString(locale, { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
34
+ });
35
+ const ctaHref = safeHref(e.cta.href);
36
+ const mapHref = e.location.url
37
+ ? safeHref(e.location.url)
38
+ : e.location.address
39
+ ? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent([e.location.name, e.location.address].filter(Boolean).join(', '))}`
40
+ : '';
41
+ const fieldRows = CALENDAR.eventFields.filter((fd) => e.fields[fd.key]).map((fd) => ({ label: fd.label, value: e.fields[fd.key], type: fd.type }));
42
+ const visibleTags = e.tags.filter((t) => !t.startsWith('#'));
43
+ const I = {
44
+ 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',
45
+ clock: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2',
46
+ repeat: 'M17 2l4 4-4 4M3 11V9a3 3 0 0 1 3-3h15M7 22l-4-4 4-4M21 13v2a3 3 0 0 1-3 3H3',
47
+ cal: 'M3 5.5h18v15H3zM3 10h18M8 3v4M16 3v4',
48
+ share: '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',
49
+ info: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 11v5M12 8h.01',
50
+ video: 'M3 7h12v10H3zM15 10l6-3v10l-6-3',
51
+ };
52
+ const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.test(e.location.url);
53
+ ---
54
+
55
+ <article class={`pc-ev-page${e.cancelled ? ' is-cancelled' : ''}`}>
56
+ {preview && <p class="pc-ev-preview">Preview — only you can see this {e.status === 'published' ? 'version' : 'draft'}.</p>}
57
+ {e.cover && (
58
+ <figure class="pc-ev-hero"><img src={e.cover} alt={e.coverAlt} /></figure>
59
+ )}
60
+ <div class="pc-ev-layout">
61
+ <div class="pc-ev-main">
62
+ {(calendars.length > 0 || visibleTags.length > 0) && (
63
+ <div class="pc-ev-chips">
64
+ {calendars.map((c) => (
65
+ <a class="pc-ev-chip" href={calendarPageUrl(c.slug)}><i style={`background:${safeColor(c.color)}`} aria-hidden="true" />{c.name}</a>
66
+ ))}
67
+ {visibleTags.map((t) => <span class="pc-ev-chip is-tag">{t}</span>)}
68
+ </div>
69
+ )}
70
+ <h1 class="pc-ev-title">{e.title}</h1>
71
+ {e.cancelled && <p class="pc-ev-cancelled"><b>This event is cancelled.</b> It stays listed so nobody is left wondering.</p>}
72
+ {e.excerpt && <p class="pc-ev-lede">{e.excerpt}</p>}
73
+ <div class="pc-ev-body post-content" set:html={html} />
74
+ </div>
75
+
76
+ <aside class="pc-ev-side" aria-label="Event details">
77
+ <div class="pc-ev-date">
78
+ <span class="pc-ev-tile" aria-hidden="true"><b>{f.month}</b><span>{f.day}</span></span>
79
+ <div>
80
+ <b>{f.date}</b>
81
+ <span>{f.time}</span>
82
+ </div>
83
+ </div>
84
+ {(e.location.name || e.location.address || e.location.url) && (
85
+ <div class="pc-ev-fact">
86
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={online && !e.location.address ? I.video : I.pin} /></svg>
87
+ <div>
88
+ {e.location.name && <b>{e.location.name}</b>}
89
+ {(e.location.address || mapHref) && (
90
+ <span>
91
+ {e.location.address}
92
+ {mapHref && <>{e.location.address && ' · '}<a href={mapHref} target="_blank" rel="noopener">{online ? 'Join online' : 'Map'}</a></>}
93
+ </span>
94
+ )}
95
+ </div>
96
+ </div>
97
+ )}
98
+ {repeats && (
99
+ <div class="pc-ev-fact">
100
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={I.repeat} /></svg>
101
+ <div><b>{repeats}</b><span>Showing {f.date}</span></div>
102
+ </div>
103
+ )}
104
+ {fieldRows.map((r) => (
105
+ <div class="pc-ev-fact">
106
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={I.info} /></svg>
107
+ <div><span>{r.label}</span><b>{r.type === 'url' && safeHref(r.value) ? <a href={safeHref(r.value)} target="_blank" rel="noopener">{r.value.replace(/^https?:\/\//, '')}</a> : r.value}</b></div>
108
+ </div>
109
+ ))}
110
+ {rsvp ? (
111
+ <div class="pc-ev-rsvp">
112
+ <p class="pc-ev-going"><b>{rsvp.going}</b> going{rsvp.left !== null && <> · <b>{rsvp.left}</b> {rsvp.left === 1 ? 'spot' : 'spots'} left</>}</p>
113
+ {rsvp.open && (rsvp.left === null || rsvp.left > 0) ? (
114
+ <details class="pc-ev-rsvpform" open={Boolean(rsvp.error)}>
115
+ <summary class="pc-ev-cta">Save my seat</summary>
116
+ <form method="post">
117
+ {rsvp.error && <p class="pc-bk-error">{rsvp.error}</p>}
118
+ <label class="pc-bk-field"><span>Your name</span><input name="name" required autocomplete="name" /></label>
119
+ <label class="pc-bk-field"><span>Email</span><input name="email" type="email" required autocomplete="email" /></label>
120
+ {rsvp.maxSeats > 1 && (
121
+ <label class="pc-bk-field"><span>Seats</span>
122
+ <select name="seats">{Array.from({ length: Math.min(rsvp.maxSeats, rsvp.left ?? rsvp.maxSeats) }, (_, i) => <option value={i + 1}>{i + 1}</option>)}</select>
123
+ </label>
124
+ )}
125
+ <button class="pc-bk-submit" type="submit">{e.rsvp.autoConfirm ? 'Confirm my seat' : 'Request a seat'}</button>
126
+ </form>
127
+ </details>
128
+ ) : <p class="pc-ev-full">{rsvp.open ? 'This event is full.' : 'RSVPs are closed.'}</p>}
129
+ </div>
130
+ ) : e.cta.label && ctaHref && !e.cancelled && (
131
+ <a class={`pc-ev-cta${e.cta.style === 'outline' ? ' is-outline' : ''}`} href={ctaHref}>{e.cta.label}</a>
132
+ )}
133
+ <details class="pc-ev-add">
134
+ <summary>
135
+ <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={I.cal} /></svg>
136
+ Add to calendar
137
+ </summary>
138
+ <div class="pc-ev-add-menu">
139
+ <a href={links.google} target="_blank" rel="noopener">Google Calendar</a>
140
+ <a href={links.ics}>Apple Calendar</a>
141
+ <a href={links.outlook} target="_blank" rel="noopener">Outlook</a>
142
+ <a href={links.ics} download={`${e.slug}.ics`}>Download .ics</a>
143
+ </div>
144
+ </details>
145
+ <button type="button" class="pc-ev-share" data-url={pageUrl} data-title={e.title}>
146
+ <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={I.share} /></svg>
147
+ <span>Share</span>
148
+ </button>
149
+ </aside>
150
+ </div>
151
+
152
+ {upcoming.length > 0 && (
153
+ <section class="pc-ev-more">
154
+ <h2>More events</h2>
155
+ <div class="pc-ev-cards" data-layout="grid" set:html={upcoming.map((u) => eventCardHtml(u, settings, { showImage: true })).join('')} />
156
+ <a class="pc-ev-all" href={CALENDAR.base}>See the full calendar →</a>
157
+ </section>
158
+ )}
159
+ </article>
160
+
161
+ <script>
162
+ // share sheet where available, else copy the link
163
+ document.querySelectorAll<HTMLButtonElement>('.pc-ev-share').forEach((b) => b.addEventListener('click', async () => {
164
+ const url = b.dataset.url ?? location.href;
165
+ if (navigator.share) {
166
+ try { await navigator.share({ title: b.dataset.title, url }); return; } catch { /* dismissed */ }
167
+ }
168
+ await navigator.clipboard.writeText(url);
169
+ const label = b.querySelector('span');
170
+ if (label) { label.textContent = 'Link copied'; setTimeout(() => (label.textContent = 'Share'), 1400); }
171
+ }));
172
+ // one open menu; outside click closes
173
+ document.addEventListener('click', (ev) => {
174
+ document.querySelectorAll<HTMLDetailsElement>('details.pc-ev-add[open]').forEach((d) => {
175
+ if (!(ev.target instanceof Node && d.contains(ev.target))) d.open = false;
176
+ });
177
+ });
178
+ </script>
@@ -0,0 +1,27 @@
1
+ ---
2
+ import Site from '../layouts/Site.astro';
3
+ import WidgetRenderer from './WidgetRenderer.astro';
4
+ import { getRepo } from '../lib/repo';
5
+ import { defaultPolicy, LEGAL } from '../lib/legal';
6
+ import { expandDynamicBlocks } from '../lib/dynamicBlocks';
7
+ interface Props { policy: 'privacy' | 'terms' }
8
+ const { policy } = Astro.props;
9
+ const repo = await getRepo(Astro.locals);
10
+ const [settings, page] = await Promise.all([repo.getSettings(), repo.getPageBySlug(policy)]);
11
+ const custom = page?.status === 'published' ? page : null;
12
+ const standard = defaultPolicy(policy, settings);
13
+ const html = custom?.bodyHtml ? await expandDynamicBlocks(custom.bodyHtml, repo) : '';
14
+ ---
15
+ <Site title={custom?.title || standard.title} noindex={!custom && !standard.complete}>
16
+ {custom ? (
17
+ html ? <article class="page-body" set:html={html} /> : <WidgetRenderer pageId={custom.id} blocks={custom.blocks} />
18
+ ) : (
19
+ <article class="page-body">
20
+ <h1>{standard.title}</h1>
21
+ {LEGAL.updatedAt && <p>Last updated: <time datetime={LEGAL.updatedAt}>{LEGAL.updatedAt}</time></p>}
22
+ {!standard.complete && <p><strong>Standard notice:</strong> The site operator has not yet completed its identity and contact details. This page describes the standard site features and needs the operator’s site-specific information.</p>}
23
+ {standard.sections.map(section => <section><h2>{section.title}</h2><p>{section.text}</p></section>)}
24
+ {LEGAL.contactEmail && <p><a href={`mailto:${LEGAL.contactEmail}`}>Contact the site operator</a></p>}
25
+ </article>
26
+ )}
27
+ </Site>
@@ -61,6 +61,7 @@ const SITES = [
61
61
  ];
62
62
 
63
63
  const TILE_ICONS: Record<string, string> = {
64
+ live: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M3 5h12v14H3zM15 10l6-4v12l-6-4"/></svg>',
64
65
  site: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M12 3l4 2.3v4.4L12 12 8 9.7V5.3L12 3zM6 13l4 2.3v4.4L6 22l-4-2.3v-4.4L6 13zM18 13l4 2.3v4.4L18 22l-4-2.3v-4.4L18 13z"/></svg>',
65
66
  link: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M10 14a5 5 0 007.07 0l2.83-2.83a5 5 0 00-7.07-7.07L11 5.93M14 10a5 5 0 00-7.07 0L4.1 12.83a5 5 0 007.07 7.07L13 18.07"/></svg>',
66
67
  email: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/></svg>',
@@ -87,6 +88,7 @@ const TILE_GROUPS: { label: string; tiles: Tile[] }[] = [
87
88
  {
88
89
  label: 'Content & layout',
89
90
  tiles: [
91
+ { key: 'live', kind: 'live', name: 'Live video', desc: 'Your shared live broadcast' },
90
92
  { key: 'heading', kind: 'heading', name: 'Heading', desc: 'Group your links' },
91
93
  { key: 'text', kind: 'text', name: 'Text', desc: 'Non-clickable copy' },
92
94
  { key: 'spacer', kind: 'spacer', name: 'Spacer', desc: 'Blank vertical space' },
@@ -96,6 +98,7 @@ const TILE_GROUPS: { label: string; tiles: Tile[] }[] = [
96
98
  ];
97
99
 
98
100
  const NEW_ITEM: Record<Kind, Partial<Link>> = {
101
+ live: { kind: 'live', label: 'Live video', url: '' },
99
102
  link: { kind: 'link', label: 'New link', url: '', icon: '' },
100
103
  email: { kind: 'email', label: 'E-mail me', url: '', icon: '✉️' },
101
104
  phone: { kind: 'phone', label: 'Call me', url: '', icon: '📞' },
@@ -128,6 +131,8 @@ export default function LinksManager({ links: initial, settings, unsplashEnabled
128
131
  const [colorPickerFor, setColorPickerFor] = useState<string | null>(null);
129
132
  const [saveState, setSaveState] = useState<SaveState>('saved');
130
133
  const [tick, setTick] = useState(0);
134
+ // narrow screens show one pane at a time
135
+ const [view, setView] = useState<'edit' | 'preview' | 'insights'>('edit');
131
136
 
132
137
  const linkShape = (l: Link) => ({ id: l.id, kind: l.kind, label: l.label, url: l.url, icon: l.icon, html: l.html, meta: l.meta });
133
138
  const dirty = useMemo(
@@ -246,10 +251,21 @@ export default function LinksManager({ links: initial, settings, unsplashEnabled
246
251
  saveState === 'error' ? 'Save failed' :
247
252
  dirty ? 'Unsaved changes — not live yet' : 'Live';
248
253
 
254
+ const clickable = links.filter((l) => l.kind === 'link' || l.kind === 'email' || l.kind === 'phone' || l.kind === 'vcard');
255
+ const totalClicks = clickable.reduce((n, l) => n + l.clicks, 0);
256
+ const topClicks = [...clickable].sort((x, y) => y.clicks - x.clicks);
257
+
249
258
  return (
250
- <div className="lm-layout">
251
- <div>
252
- <div className="card card-pad lm-profile" style={{ marginBottom: 16 }}>
259
+ <div className={`lm-layout view-${view}`}>
260
+ <div className="lm-views seg" role="tablist" aria-label="Links page view">
261
+ {(['edit', 'preview', 'insights'] as const).map((v) => (
262
+ <button key={v} type="button" role="tab" aria-selected={view === v} onClick={() => { setView(v); if (v === 'preview') setTick((t) => t + 1); }}>
263
+ {v[0].toUpperCase() + v.slice(1)}
264
+ </button>
265
+ ))}
266
+ </div>
267
+ <div className="lm-editor">
268
+ <div className="lm-profile">
253
269
  <div className="lm-avatarwrap">
254
270
  <button
255
271
  type="button"
@@ -260,7 +276,7 @@ export default function LinksManager({ links: initial, settings, unsplashEnabled
260
276
  {profile.linksAvatar ? (
261
277
  <img src={profile.linksAvatar} alt="" />
262
278
  ) : (
263
- <span aria-hidden="true">{(profile.linksName.trim()[0] ?? '?').toUpperCase()}</span>
279
+ <svg aria-hidden="true" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M4 8h3l2-3h6l2 3h3v11H4zM12 11a3 3 0 1 0 0 6 3 3 0 0 0 0-6z" /></svg>
264
280
  )}
265
281
  <span className="lm-avatar-hint">Edit</span>
266
282
  </button>
@@ -299,50 +315,60 @@ export default function LinksManager({ links: initial, settings, unsplashEnabled
299
315
  </div>
300
316
  </div>
301
317
 
302
- <div className="card card-pad" style={{ marginBottom: 16 }}>
303
- <div className="lm-picker-head">
304
- <p className="card-title">{pickerOpen && sitesOpen ? 'Choose a site' : 'Add block'}</p>
305
- {pickerOpen && sitesOpen && (
306
- <button type="button" className="btn sm ghost" onClick={() => setSitesOpen(false)}>‹ All blocks</button>
307
- )}
308
- <button type="button" className="btn sm ghost" onClick={() => { setPickerOpen((v) => !v); setSitesOpen(false); }}>
309
- {pickerOpen ? 'Close' : '+ Add'}
310
- </button>
311
- </div>
312
- {pickerOpen && !sitesOpen && TILE_GROUPS.map((g) => (
313
- <div key={g.label}>
314
- <p className="lm-group-label">{g.label}</p>
315
- <div className="lm-tiles">
316
- {g.tiles.map((t) => (
317
- <button
318
- key={t.key}
319
- type="button"
320
- className="lm-tile"
321
- onClick={() => (t.kind === 'site' ? setSitesOpen(true) : add(NEW_ITEM[t.kind]))}
322
- >
323
- <span className="lm-tile-ico" dangerouslySetInnerHTML={{ __html: TILE_ICONS[t.key] }} />
324
- <span className="lm-tile-body">
325
- <b>{t.name}</b>
326
- <span>{t.desc}</span>
327
- </span>
328
- </button>
329
- ))}
330
- </div>
331
- </div>
332
- ))}
333
- {pickerOpen && sitesOpen && (
334
- <div className="lm-sites">
335
- {SITES.map((s) => (
336
- <button key={s.name} type="button" className="lm-site" onClick={() => addSite(s)} style={{ color: `#${s.si.hex}` }}>
337
- <span className="lm-site-ico" dangerouslySetInnerHTML={{ __html: svgOf(s.si) }} />
338
- <span>{s.name}</span>
339
- </button>
318
+ <div className="lm-addwrap">
319
+ <button
320
+ type="button"
321
+ className={`lm-addbtn ${pickerOpen ? 'is-open' : ''}`}
322
+ aria-expanded={pickerOpen}
323
+ onClick={() => { setPickerOpen((v) => !v); setSitesOpen(false); }}
324
+ >
325
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 5v14M5 12h14" /></svg>
326
+ Add block
327
+ </button>
328
+ {pickerOpen && (
329
+ <div className="lm-picker">
330
+ {sitesOpen && (
331
+ <div className="lm-picker-head">
332
+ <button type="button" className="btn sm ghost" onClick={() => setSitesOpen(false)}>‹ All blocks</button>
333
+ <b>Choose a site</b>
334
+ </div>
335
+ )}
336
+ {!sitesOpen && TILE_GROUPS.map((g) => (
337
+ <div key={g.label}>
338
+ <p className="lm-group-label">{g.label}</p>
339
+ <div className="lm-tiles">
340
+ {g.tiles.map((t) => (
341
+ <button
342
+ key={t.key}
343
+ type="button"
344
+ className="lm-tile"
345
+ onClick={() => (t.kind === 'site' ? setSitesOpen(true) : add(NEW_ITEM[t.kind]))}
346
+ >
347
+ <span className="lm-tile-ico" dangerouslySetInnerHTML={{ __html: TILE_ICONS[t.key] }} />
348
+ <span className="lm-tile-body">
349
+ <b>{t.name}</b>
350
+ <span>{t.desc}</span>
351
+ </span>
352
+ </button>
353
+ ))}
354
+ </div>
355
+ </div>
340
356
  ))}
357
+ {sitesOpen && (
358
+ <div className="lm-sites">
359
+ {SITES.map((s) => (
360
+ <button key={s.name} type="button" className="lm-site" onClick={() => addSite(s)} style={{ color: `#${s.si.hex}` }}>
361
+ <span className="lm-site-ico" dangerouslySetInnerHTML={{ __html: svgOf(s.si) }} />
362
+ <span>{s.name}</span>
363
+ </button>
364
+ ))}
365
+ </div>
366
+ )}
341
367
  </div>
342
368
  )}
343
369
  </div>
344
370
 
345
- <div className="card rowlist">
371
+ <div className="lm-list rowlist">
346
372
  <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
347
373
  <SortableContext items={links.map((l) => l.id)} strategy={verticalListSortingStrategy}>
348
374
  {links.map((l) => (
@@ -361,7 +387,7 @@ export default function LinksManager({ links: initial, settings, unsplashEnabled
361
387
  ))}
362
388
  </SortableContext>
363
389
  </DndContext>
364
- {links.length === 0 && <p className="hint empty">Nothing here yet — add your first block above.</p>}
390
+ {links.length === 0 && <p className="hint lm-empty">Nothing here yet — add your first block above.</p>}
365
391
  </div>
366
392
  </div>
367
393
 
@@ -377,23 +403,47 @@ export default function LinksManager({ links: initial, settings, unsplashEnabled
377
403
 
378
404
  <aside className="lm-preview">
379
405
  <div className="lm-preview-bar">
380
- <span className="lm-preview-label">Live preview</span>
381
- <span className={`lm-savestate ${saveState === 'saved' && dirty ? 'dirty' : saveState}`}>{saveLabel}</span>
382
- <div style={{ display: 'flex', gap: 2 }}>
383
- {dirty && (
384
- <>
385
- <button type="button" className="btn sm ghost" onClick={discard} disabled={saveState === 'saving'}>Discard</button>
386
- <button type="button" className="btn sm primary" onClick={() => void saveAll()} disabled={saveState === 'saving'}>Save changes</button>
387
- </>
388
- )}
389
- <button type="button" className="btn sm ghost" title="Refresh" onClick={() => setTick((t) => t + 1)}>↻</button>
390
- <a className="btn sm ghost" href="/links" target="_blank" rel="noopener" title="Open in tab">↗</a>
391
- </div>
406
+ <span className="lm-url">{location.host}/links</span>
407
+ <button
408
+ type="button"
409
+ className="icon-btn"
410
+ title="Copy link"
411
+ aria-label="Copy link"
412
+ onClick={() => void navigator.clipboard.writeText(`${location.origin}/links`)}
413
+ >
414
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M9 9h11v11H9zM5 15H4V4h11v1" /></svg>
415
+ </button>
416
+ <a className="icon-btn" href="/links" target="_blank" rel="noopener" title="Open in tab" aria-label="Open in tab">
417
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5" /></svg>
418
+ </a>
392
419
  </div>
393
420
  <div className="lm-phone">
394
421
  <iframe key={tick} src="/links" title="Links page preview" />
395
422
  </div>
423
+ <p className="lm-stat"><b>{totalClicks.toLocaleString()}</b> total clicks · <span className={`lm-savestate ${saveState === 'saved' && dirty ? 'dirty' : saveState}`}>{saveLabel}</span></p>
396
424
  </aside>
425
+
426
+ <section className="lm-insights" aria-label="Insights">
427
+ <div className="card-head"><b>Clicks by block</b><span className="hint">{totalClicks.toLocaleString()} total</span></div>
428
+ {topClicks.length === 0 && <p className="hint">No buttons yet.</p>}
429
+ {topClicks.map((l) => (
430
+ <div key={l.id} className="mini-row bar-row">
431
+ <i style={{ width: `${topClicks[0].clicks ? (l.clicks / topClicks[0].clicks) * 100 : 0}%` }} />
432
+ <span className="grow">{l.label || l.url || 'Untitled'}{l.meta?.hidden ? ' · hidden' : ''}</span>
433
+ <b>{l.clicks.toLocaleString()}</b>
434
+ </div>
435
+ ))}
436
+ </section>
437
+
438
+ {dirty && (
439
+ <div className="lm-savebar" role="status">
440
+ <span>{saveState === 'error' ? 'Save failed — try again' : 'Unsaved changes'}</span>
441
+ <button type="button" className="btn sm" onClick={discard} disabled={saveState === 'saving'}>Discard</button>
442
+ <button type="button" className="btn sm primary" onClick={() => void saveAll()} disabled={saveState === 'saving'}>
443
+ {saveState === 'saving' ? 'Saving…' : 'Save changes'}
444
+ </button>
445
+ </div>
446
+ )}
397
447
  </div>
398
448
  );
399
449
  }
@@ -401,6 +451,7 @@ export default function LinksManager({ links: initial, settings, unsplashEnabled
401
451
  // ---- rows -----------------------------------------------------------------
402
452
 
403
453
  const KIND_BADGE: Partial<Record<Kind, string>> = {
454
+ live: '●',
404
455
  heading: 'H', text: 'T', spacer: '↕', html: 'HTML', email: '@', phone: 'TEL', vcard: 'VCF',
405
456
  };
406
457
 
@@ -434,7 +485,7 @@ function ItemRow({
434
485
  return (
435
486
  <div
436
487
  ref={setNodeRef}
437
- className={`lm-item ${isDragging ? 'is-dragging' : ''}`}
488
+ className={`lm-item ${isDragging ? 'is-dragging' : ''} ${link.meta?.hidden ? 'is-hidden' : ''}`}
438
489
  style={{ transform: CSS.Transform.toString(transform), transition }}
439
490
  >
440
491
  <div className={`row lm-row ${hasEditorPane && editorOpen ? 'is-open' : ''}`}>
@@ -446,7 +497,7 @@ function ItemRow({
446
497
  <span className="lm-badge">{KIND_BADGE[k]}</span>
447
498
  )}
448
499
 
449
- {k === 'spacer' ? (
500
+ {k === 'live' ? <span style={{ flex: 1 }}>Live video · <a href="/admin/live">Manage broadcast</a></span> : k === 'spacer' ? (
450
501
  <>
451
502
  <span className="bare strong" style={{ padding: '5px 8px' }}>Spacer</span>
452
503
  <select
@@ -529,6 +580,16 @@ function ItemRow({
529
580
  {editorOpen ? 'Done' : expandLabel}
530
581
  </button>
531
582
  )}
583
+ <button
584
+ type="button"
585
+ role="switch"
586
+ aria-checked={!link.meta?.hidden}
587
+ className={`lm-switch ${link.meta?.hidden ? '' : 'on'}`}
588
+ title={link.meta?.hidden ? 'Hidden on the page — click to show' : 'Visible — click to hide'}
589
+ onClick={() => onEdit(link.id, { meta: { ...link.meta, hidden: !link.meta?.hidden } }, true)}
590
+ >
591
+ <i />
592
+ </button>
532
593
  <button type="button" className="btn sm ghost danger reveal" onClick={() => onDelete(link.id)}>Delete</button>
533
594
  </div>
534
595