@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
@@ -1,10 +1,13 @@
1
+ import { pickDesignDraft } from '../lib/drafts';
2
+ import { ApiError } from '../lib/api';
3
+ import { validateSectionDesign } from '../lib/design-service';
1
4
  import { defineAction, ActionError } from 'astro:actions';
2
5
  import { z } from 'astro:schema';
3
6
  import type { ActionAPIContext } from 'astro:actions';
4
7
  import { getRepo } from '../lib/repo';
5
8
  import { getMediaStore } from '../lib/media/store';
6
- import { verifySessionToken, SESSION_COOKIE } from '../lib/auth';
7
- import { parseConfig, slugify, widgetTypes, type WidgetType } from '../lib/schema';
9
+ import { validateSession, SESSION_COOKIE } from '../lib/auth';
10
+ import { LinkKind, parseConfig, slugify, widgetTypes, type WidgetType } from '../lib/schema';
8
11
  import { env } from '../lib/env';
9
12
  import { CONTENT, collectionUrl } from '../lib/content';
10
13
  import { invalidateRedirectCache } from '../middleware';
@@ -13,11 +16,31 @@ import { normalizeCalendarSrc } from '../lib/calendar/src';
13
16
  import { getCalendarEvents } from '../lib/calendar/ics';
14
17
  import { siteLocale } from '../lib/dates';
15
18
  import { normalizePageLocale, localeHomeUrl } from '../lib/i18n';
19
+ import { CALENDAR } from '../lib/calendar/config';
20
+ import { eventTimeZone, featuredOccurrence, formatOccurrence, isValidTimeZone, normalizeEventTimes, safeColor } from '../lib/calendar/events';
21
+ import { eventsFromCsv, eventsFromIcs, eventsFromVEvents } from '../lib/calendar/import';
22
+ import { loadFeed } from '../lib/calendar/ics';
23
+ import { NATIVE_ALL } from '../lib/calendar/render';
24
+ import { collectViewEvents } from '../lib/calendar/data';
25
+ import { waitUntilFrom } from '../lib/webhooks';
26
+ import { eventChanged, eventDeleted } from '../lib/calendar/hooks';
27
+ import { siteOrigin } from '../lib/seo';
28
+ import type { CalEvent } from '../lib/schema';
29
+ import { diffDesign, revertDesignChange, summarizeChanges } from '../lib/designDiff';
30
+ import { shareEpoch } from '../lib/drafts';
31
+ import { createDesignToken } from '../lib/preview';
32
+ import { getTheme } from '../lib/theme';
33
+
34
+ // "What changes" for the Design screen: the staged draft vs the live design
35
+ async function designChanges(repo: Awaited<ReturnType<typeof getRepo>>) {
36
+ const [settings, nav] = await Promise.all([repo.getSettings(), repo.listNavItems()]);
37
+ return diffDesign(settings, nav, settings.draftDesign, (slug) => getTheme(slug).name);
38
+ }
16
39
 
17
40
  // All admin mutations live here. The middleware only guards /admin pages,
18
41
  // so every action re-checks the session itself.
19
- function requireAdmin(ctx: ActionAPIContext) {
20
- if (!verifySessionToken(ctx.cookies.get(SESSION_COOKIE)?.value)) {
42
+ async function requireAdmin(ctx: ActionAPIContext) {
43
+ if (!(await validateSession(ctx.cookies.get(SESSION_COOKIE)?.value, ctx.locals))) {
21
44
  throw new ActionError({ code: 'UNAUTHORIZED', message: 'Not signed in.' });
22
45
  }
23
46
  }
@@ -39,6 +62,105 @@ const BlockInput = z.object({
39
62
  config: z.record(z.any()),
40
63
  });
41
64
 
65
+
66
+ // ---- calendar helpers ------------------------------------------------------
67
+
68
+ type EventSaveInput = Partial<Omit<CalEvent, 'id' | 'draft' | 'startAt' | 'endAt' | 'createdAt' | 'updatedAt' | 'sourceUid' | 'publishedAt'>>;
69
+
70
+ // Validate + normalize an event save. Times are checked as a PAIR against the
71
+ // merged (staged) state so a half-edited range can't slip through.
72
+ async function cleanEventInput(repo: Awaited<ReturnType<typeof getRepo>>, data: EventSaveInput, before: CalEvent): Promise<EventSaveInput> {
73
+ const out: EventSaveInput = { ...data };
74
+ if (out.slug !== undefined) {
75
+ const base = slugify(out.slug || out.title || before.title);
76
+ let slug = base;
77
+ for (let n = 2; ; n++) {
78
+ const clash = await repo.getEventBySlug(slug);
79
+ if (!clash || clash.id === before.id) break;
80
+ slug = `${base}-${n}`;
81
+ }
82
+ out.slug = slug;
83
+ }
84
+ if (out.timezone !== undefined && out.timezone !== '' && !isValidTimeZone(out.timezone)) {
85
+ throw new ActionError({ code: 'BAD_REQUEST', message: `“${out.timezone}” isn’t a timezone we recognize.` });
86
+ }
87
+ if (out.start !== undefined || out.end !== undefined || out.allDay !== undefined) {
88
+ const merged = before.draft ? { ...before, ...(before.draft as Partial<CalEvent>) } : before;
89
+ try {
90
+ const t = normalizeEventTimes({ start: out.start ?? merged.start, end: out.end ?? merged.end, allDay: out.allDay ?? merged.allDay });
91
+ out.start = t.start;
92
+ out.end = t.end;
93
+ } catch (err) {
94
+ throw new ActionError({ code: 'BAD_REQUEST', message: err instanceof Error ? err.message : 'Invalid time.' });
95
+ }
96
+ }
97
+ if (out.calendarIds !== undefined) {
98
+ const known = new Set((await repo.listCalendars()).filter((c) => c.kind === 'native').map((c) => c.id));
99
+ out.calendarIds = [...new Set(out.calendarIds.filter((c) => known.has(c)))];
100
+ }
101
+ if (out.fields !== undefined) {
102
+ const allowed = new Map(CALENDAR.eventFields.map((f) => [f.key, f]));
103
+ out.fields = Object.fromEntries(
104
+ Object.entries(out.fields)
105
+ .filter(([k, v]) => allowed.has(k) && v !== '')
106
+ .filter(([k, v]) => allowed.get(k)!.type !== 'select' || allowed.get(k)!.options.includes(v))
107
+ );
108
+ }
109
+ if (out.cta !== undefined) {
110
+ const href = out.cta.href.trim();
111
+ if (href && !/^(https?:\/\/|\/(?!\/)|mailto:|tel:|#)/i.test(href)) {
112
+ throw new ActionError({ code: 'BAD_REQUEST', message: 'The button link must start with https://, /, mailto:, or tel:.' });
113
+ }
114
+ out.cta = { ...out.cta, href };
115
+ }
116
+ if (out.location?.url) {
117
+ if (!/^https?:\/\//i.test(out.location.url.trim())) {
118
+ throw new ActionError({ code: 'BAD_REQUEST', message: 'The map or meeting link must start with https://.' });
119
+ }
120
+ }
121
+ if (out.tags !== undefined) out.tags = [...new Set(out.tags.map((t) => t.trim()).filter(Boolean))];
122
+ if (out.status === 'scheduled' && !(out.scheduledAt && out.scheduledAt > Date.now())) {
123
+ throw new ActionError({ code: 'BAD_REQUEST', message: 'Pick a future publish time to schedule.' });
124
+ }
125
+ if (out.status === 'published') out.scheduledAt = null;
126
+ return out;
127
+ }
128
+
129
+ async function eventSideEffects(
130
+ repo: Awaited<ReturnType<typeof getRepo>>,
131
+ ctx: ActionAPIContext,
132
+ before: CalEvent,
133
+ after: CalEvent,
134
+ applied = false
135
+ ) {
136
+ const settings = await repo.getSettings();
137
+ await eventChanged(repo, before, after, {
138
+ origin: siteOrigin(settings, ctx.url), waitUntil: waitUntilFrom(ctx.locals), applied, onRedirect: invalidateRedirectCache,
139
+ });
140
+ }
141
+
142
+ async function importEvents(
143
+ repo: Awaited<ReturnType<typeof getRepo>>,
144
+ parsed: { events: ReturnType<typeof eventsFromIcs>['events']; skipped: number },
145
+ calendarId?: string
146
+ ) {
147
+ // re-importing the same feed updates nothing and duplicates nothing: known
148
+ // source UIDs are skipped
149
+ const existing = new Set((await repo.listEvents()).map((e) => e.sourceUid).filter(Boolean));
150
+ let created = 0;
151
+ let duplicates = 0;
152
+ for (const e of parsed.events) {
153
+ if (e.sourceUid && existing.has(e.sourceUid)) { duplicates++; continue; }
154
+ const base = slugify(e.title);
155
+ let slug = base;
156
+ for (let n = 2; await repo.getEventBySlug(slug); n++) slug = `${base}-${n}`;
157
+ await repo.createEvent({ ...e, slug, status: 'draft', calendarIds: calendarId ? [calendarId] : [] });
158
+ if (e.sourceUid) existing.add(e.sourceUid);
159
+ created++;
160
+ }
161
+ return { created, duplicates, skipped: parsed.skipped };
162
+ }
163
+
42
164
  export const server = {
43
165
  posts: {
44
166
  save: defineAction({
@@ -65,7 +187,7 @@ export const server = {
65
187
  scheduledAt: nullableTs.optional(),
66
188
  }),
67
189
  handler: async ({ id, ...data }, ctx) => {
68
- requireAdmin(ctx);
190
+ await requireAdmin(ctx);
69
191
  const repo = await getRepo(ctx.locals);
70
192
  if (data.slug !== undefined) data.slug = slugify(data.slug);
71
193
  if (data.collection !== undefined && !CONTENT.collections[data.collection]) {
@@ -107,7 +229,7 @@ export const server = {
107
229
  applyUpdate: defineAction({
108
230
  input: z.object({ id: z.string() }),
109
231
  handler: async ({ id }, ctx) => {
110
- requireAdmin(ctx);
232
+ await requireAdmin(ctx);
111
233
  const repo = await getRepo(ctx.locals);
112
234
  const before = await repo.getPost(id);
113
235
  if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Post not found.' });
@@ -129,7 +251,7 @@ export const server = {
129
251
  discardChanges: defineAction({
130
252
  input: z.object({ id: z.string() }),
131
253
  handler: async ({ id }, ctx) => {
132
- requireAdmin(ctx);
254
+ await requireAdmin(ctx);
133
255
  const repo = await getRepo(ctx.locals);
134
256
  await repo.discardPostDraft(id);
135
257
  const post = await repo.getPost(id);
@@ -140,7 +262,7 @@ export const server = {
140
262
  delete: defineAction({
141
263
  input: z.object({ id: z.string() }),
142
264
  handler: async ({ id }, ctx) => {
143
- requireAdmin(ctx);
265
+ await requireAdmin(ctx);
144
266
  const repo = await getRepo(ctx.locals);
145
267
  await repo.deletePost(id);
146
268
  return { ok: true };
@@ -165,7 +287,7 @@ export const server = {
165
287
  blocks: z.array(BlockInput).optional(),
166
288
  }),
167
289
  handler: async ({ id, blocks, ...data }, ctx) => {
168
- requireAdmin(ctx);
290
+ await requireAdmin(ctx);
169
291
  const repo = await getRepo(ctx.locals);
170
292
  if (data.slug !== undefined) data.slug = slugify(data.slug);
171
293
  // default locale stores as '', unknown codes fall back to it
@@ -199,7 +321,7 @@ export const server = {
199
321
  applyUpdate: defineAction({
200
322
  input: z.object({ id: z.string() }),
201
323
  handler: async ({ id }, ctx) => {
202
- requireAdmin(ctx);
324
+ await requireAdmin(ctx);
203
325
  const repo = await getRepo(ctx.locals);
204
326
  const before = await repo.getPage(id);
205
327
  if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Page not found.' });
@@ -215,7 +337,7 @@ export const server = {
215
337
  discardChanges: defineAction({
216
338
  input: z.object({ id: z.string() }),
217
339
  handler: async ({ id }, ctx) => {
218
- requireAdmin(ctx);
340
+ await requireAdmin(ctx);
219
341
  const repo = await getRepo(ctx.locals);
220
342
  await repo.discardPageDraft(id);
221
343
  const page = await repo.getPage(id);
@@ -226,7 +348,7 @@ export const server = {
226
348
  delete: defineAction({
227
349
  input: z.object({ id: z.string() }),
228
350
  handler: async ({ id }, ctx) => {
229
- requireAdmin(ctx);
351
+ await requireAdmin(ctx);
230
352
  const repo = await getRepo(ctx.locals);
231
353
  await repo.deletePage(id);
232
354
  return { ok: true };
@@ -238,7 +360,7 @@ export const server = {
238
360
  list: defineAction({
239
361
  input: z.object({}),
240
362
  handler: async (_input, ctx) => {
241
- requireAdmin(ctx);
363
+ await requireAdmin(ctx);
242
364
  const repo = await getRepo(ctx.locals);
243
365
  return { media: await repo.listMedia() };
244
366
  },
@@ -246,7 +368,7 @@ export const server = {
246
368
  updateAlt: defineAction({
247
369
  input: z.object({ id: z.string(), alt: z.string() }),
248
370
  handler: async ({ id, alt }, ctx) => {
249
- requireAdmin(ctx);
371
+ await requireAdmin(ctx);
250
372
  const repo = await getRepo(ctx.locals);
251
373
  await repo.updateMedia(id, { alt });
252
374
  return { ok: true };
@@ -255,7 +377,7 @@ export const server = {
255
377
  delete: defineAction({
256
378
  input: z.object({ id: z.string() }),
257
379
  handler: async ({ id }, ctx) => {
258
- requireAdmin(ctx);
380
+ await requireAdmin(ctx);
259
381
  const repo = await getRepo(ctx.locals);
260
382
  const media = await repo.getMedia(id);
261
383
  if (media) {
@@ -271,7 +393,8 @@ export const server = {
271
393
  links: {
272
394
  create: defineAction({
273
395
  input: z.object({
274
- kind: z.enum(['link', 'email', 'phone', 'heading', 'text', 'spacer', 'vcard', 'html']).default('link'),
396
+ // every block kind the schema knows, incl. 'live' (kept in one place)
397
+ kind: z.enum(LinkKind.options).default('link'),
275
398
  label: z.string().default(''),
276
399
  url: z.string().default(''),
277
400
  icon: z.string().default(''),
@@ -279,7 +402,7 @@ export const server = {
279
402
  meta: z.record(z.any()).default({}),
280
403
  }),
281
404
  handler: async (data, ctx) => {
282
- requireAdmin(ctx);
405
+ await requireAdmin(ctx);
283
406
  const repo = await getRepo(ctx.locals);
284
407
  return { link: await repo.createLink(data) };
285
408
  },
@@ -294,7 +417,7 @@ export const server = {
294
417
  meta: z.record(z.any()).optional(),
295
418
  }),
296
419
  handler: async ({ id, ...data }, ctx) => {
297
- requireAdmin(ctx);
420
+ await requireAdmin(ctx);
298
421
  const repo = await getRepo(ctx.locals);
299
422
  await repo.updateLink(id, data);
300
423
  return { ok: true };
@@ -303,7 +426,7 @@ export const server = {
303
426
  reorder: defineAction({
304
427
  input: z.object({ ids: z.array(z.string()) }),
305
428
  handler: async ({ ids }, ctx) => {
306
- requireAdmin(ctx);
429
+ await requireAdmin(ctx);
307
430
  const repo = await getRepo(ctx.locals);
308
431
  await repo.reorderLinks(ids);
309
432
  return { ok: true };
@@ -312,7 +435,7 @@ export const server = {
312
435
  delete: defineAction({
313
436
  input: z.object({ id: z.string() }),
314
437
  handler: async ({ id }, ctx) => {
315
- requireAdmin(ctx);
438
+ await requireAdmin(ctx);
316
439
  const repo = await getRepo(ctx.locals);
317
440
  await repo.deleteLink(id);
318
441
  return { ok: true };
@@ -321,6 +444,26 @@ export const server = {
321
444
  },
322
445
 
323
446
  nav: {
447
+ // Navigation stages with the design: the full ordered list goes into the
448
+ // design draft and reaches the live menus on "Publish design".
449
+ saveDraft: defineAction({
450
+ input: z.object({
451
+ items: z.array(z.object({
452
+ id: z.string(),
453
+ label: z.string(),
454
+ url: z.string(),
455
+ area: z.enum(['header', 'footer', 'social']),
456
+ })),
457
+ }),
458
+ handler: async ({ items }, ctx) => {
459
+ await requireAdmin(ctx);
460
+ const repo = await getRepo(ctx.locals);
461
+ await repo.saveDesignDraft({ navItems: items });
462
+ const changes = await designChanges(repo);
463
+ if (!changes.length) await repo.discardDesignDraft();
464
+ return { ok: true, changes };
465
+ },
466
+ }),
324
467
  create: defineAction({
325
468
  input: z.object({
326
469
  label: z.string().default('New link'),
@@ -328,7 +471,7 @@ export const server = {
328
471
  area: z.enum(['header', 'footer', 'social']).default('header'),
329
472
  }),
330
473
  handler: async (data, ctx) => {
331
- requireAdmin(ctx);
474
+ await requireAdmin(ctx);
332
475
  const repo = await getRepo(ctx.locals);
333
476
  return { item: await repo.createNavItem(data) };
334
477
  },
@@ -341,7 +484,7 @@ export const server = {
341
484
  area: z.enum(['header', 'footer', 'social']).optional(),
342
485
  }),
343
486
  handler: async ({ id, ...data }, ctx) => {
344
- requireAdmin(ctx);
487
+ await requireAdmin(ctx);
345
488
  const repo = await getRepo(ctx.locals);
346
489
  await repo.updateNavItem(id, data);
347
490
  return { ok: true };
@@ -350,7 +493,7 @@ export const server = {
350
493
  reorder: defineAction({
351
494
  input: z.object({ ids: z.array(z.string()) }),
352
495
  handler: async ({ ids }, ctx) => {
353
- requireAdmin(ctx);
496
+ await requireAdmin(ctx);
354
497
  const repo = await getRepo(ctx.locals);
355
498
  await repo.reorderNavItems(ids);
356
499
  return { ok: true };
@@ -359,7 +502,7 @@ export const server = {
359
502
  delete: defineAction({
360
503
  input: z.object({ id: z.string() }),
361
504
  handler: async ({ id }, ctx) => {
362
- requireAdmin(ctx);
505
+ await requireAdmin(ctx);
363
506
  const repo = await getRepo(ctx.locals);
364
507
  await repo.deleteNavItem(id);
365
508
  return { ok: true };
@@ -383,14 +526,15 @@ export const server = {
383
526
  siteCover: z.string().optional(),
384
527
  }),
385
528
  handler: async (data, ctx) => {
386
- requireAdmin(ctx);
529
+ await requireAdmin(ctx);
387
530
  const repo = await getRepo(ctx.locals);
388
531
  await repo.updateSettings(data);
389
532
  return { ok: true };
390
533
  },
391
534
  }),
392
- // Design-screen staging: edits stage in settings.draftDesign (previewed
393
- // via ?designPreview=1) until "Publish design" applies them live.
535
+ // Design-screen staging: edits stage in settings.draftDesign (seen in
536
+ // preview mode) until "Publish design" applies them live. Every mutation
537
+ // returns the refreshed change list.
394
538
  saveDesignDraft: defineAction({
395
539
  input: z.object({
396
540
  activeTheme: z.string().optional(),
@@ -400,37 +544,181 @@ export const server = {
400
544
  siteCover: z.string().optional(),
401
545
  }),
402
546
  handler: async (data, ctx) => {
403
- requireAdmin(ctx);
547
+ await requireAdmin(ctx);
404
548
  const repo = await getRepo(ctx.locals);
405
549
  await repo.saveDesignDraft(data);
406
- return { ok: true };
550
+ // edits that land back on the live design leave nothing to publish
551
+ const changes = await designChanges(repo);
552
+ if (!changes.length) await repo.discardDesignDraft();
553
+ return { ok: true, changes };
554
+ },
555
+ }),
556
+ designStatus: defineAction({
557
+ input: z.object({}),
558
+ handler: async (_input, ctx) => {
559
+ await requireAdmin(ctx);
560
+ return { changes: await designChanges(await getRepo(ctx.locals)) };
561
+ },
562
+ }),
563
+ // undo one row of the change list (back to the live value)
564
+ revertDesignChange: defineAction({
565
+ input: z.object({ key: z.string() }),
566
+ handler: async ({ key }, ctx) => {
567
+ await requireAdmin(ctx);
568
+ const repo = await getRepo(ctx.locals);
569
+ const [settings, nav] = await Promise.all([repo.getSettings(), repo.listNavItems()]);
570
+ if (settings.draftDesign) await repo.setDesignDraft(revertDesignChange(settings, nav, settings.draftDesign, key));
571
+ const after = await repo.getSettings();
572
+ return { changes: await designChanges(repo), draft: after.draftDesign };
407
573
  },
408
574
  }),
409
575
  applyDesign: defineAction({
410
576
  input: z.object({}),
411
577
  handler: async (_input, ctx) => {
412
- requireAdmin(ctx);
578
+ const userId = await validateSession(ctx.cookies.get(SESSION_COOKIE)?.value, ctx.locals);
579
+ if (!userId) throw new ActionError({ code: 'UNAUTHORIZED', message: 'Not signed in.' });
413
580
  const repo = await getRepo(ctx.locals);
414
- await repo.applyDesignDraft();
581
+ const changes = await designChanges(repo);
582
+ const author = (await repo.listUsers()).find((u) => u.id === userId)?.name ?? 'Admin';
583
+ const settings = await repo.getSettings();
584
+ const effective = { ...settings, ...pickDesignDraft(settings.draftDesign ?? {}) };
585
+ try {
586
+ validateSectionDesign(effective, getTheme(effective.activeTheme));
587
+ await repo.applyDesignDraft({ author, summary: summarizeChanges(changes), expectedRevision: settings.designRevision });
588
+ } catch (error) {
589
+ if (error instanceof ApiError) throw new ActionError({ code: 'BAD_REQUEST', message: error.message });
590
+ throw error;
591
+ }
415
592
  return { ok: true };
416
593
  },
417
594
  }),
418
595
  discardDesign: defineAction({
419
596
  input: z.object({}),
420
597
  handler: async (_input, ctx) => {
421
- requireAdmin(ctx);
598
+ await requireAdmin(ctx);
422
599
  const repo = await getRepo(ctx.locals);
423
600
  await repo.discardDesignDraft();
424
601
  return { ok: true };
425
602
  },
426
603
  }),
604
+ // design history (snapshots taken on every publish)
605
+ listDesignVersions: defineAction({
606
+ input: z.object({}),
607
+ handler: async (_input, ctx) => {
608
+ await requireAdmin(ctx);
609
+ const repo = await getRepo(ctx.locals);
610
+ const versions = await repo.listDesignVersions(20);
611
+ return {
612
+ versions: versions.map((v) => ({
613
+ id: v.id, createdAt: v.createdAt, author: v.author, summary: v.summary,
614
+ theme: getTheme(String(v.snapshot.activeTheme || 'ink')).name,
615
+ })),
616
+ };
617
+ },
618
+ }),
619
+ // a restore never goes live directly: the snapshot becomes the draft
620
+ restoreDesignVersion: defineAction({
621
+ input: z.object({ id: z.string() }),
622
+ handler: async ({ id }, ctx) => {
623
+ await requireAdmin(ctx);
624
+ const repo = await getRepo(ctx.locals);
625
+ const v = await repo.getDesignVersion(id);
626
+ if (!v) throw new ActionError({ code: 'NOT_FOUND', message: 'That version no longer exists.' });
627
+ const current = await repo.getSettings();
628
+ await repo.setDesignDraft({ ...v.snapshot, shareEpoch: crypto.randomUUID() });
629
+ const changes = await designChanges(repo);
630
+ if (!changes.length) await repo.discardDesignDraft();
631
+ return { changes };
632
+ },
633
+ }),
634
+ // share links: signed ?preview=<token> URLs, tied to the draft's epoch
635
+ createShareLink: defineAction({
636
+ input: z.object({ days: z.number().int().min(1).max(30).default(7) }),
637
+ handler: async ({ days }, ctx) => {
638
+ await requireAdmin(ctx);
639
+ const repo = await getRepo(ctx.locals);
640
+ const settings = await repo.getSettings();
641
+ if (!settings.draftDesign) throw new ActionError({ code: 'BAD_REQUEST', message: 'There are no draft changes to share.' });
642
+ if (shareEpoch(settings.draftDesign) === 'legacy-disabled') {
643
+ await repo.saveDesignDraft({ shareEpoch: crypto.randomUUID() });
644
+ settings.draftDesign = (await repo.getSettings()).draftDesign;
645
+ }
646
+ const { token, expiresAt } = createDesignToken(shareEpoch(settings.draftDesign), days * 86_400_000);
647
+ return { url: `${siteOrigin(settings, ctx.url)}/?preview=${token}`, expiresAt };
648
+ },
649
+ }),
650
+ revokeShareLinks: defineAction({
651
+ input: z.object({}),
652
+ handler: async (_input, ctx) => {
653
+ await requireAdmin(ctx);
654
+ const repo = await getRepo(ctx.locals);
655
+ const settings = await repo.getSettings();
656
+ if (settings.draftDesign) await repo.saveDesignDraft({ shareEpoch: crypto.randomUUID() });
657
+ return { ok: true };
658
+ },
659
+ }),
660
+ },
661
+
662
+ admin: {
663
+ // ⌘K palette index: every page, collection entry and tag, small enough to
664
+ // fetch once per palette session and filter client-side
665
+ index: defineAction({
666
+ input: z.object({}),
667
+ handler: async (_input, ctx) => {
668
+ await requireAdmin(ctx);
669
+ const repo = await getRepo(ctx.locals);
670
+ const [pages, posts, tags, events] = await Promise.all([
671
+ repo.listPages(), repo.listPosts(), repo.listTags(), CALENDAR.enabled ? repo.listEvents() : Promise.resolve([] as CalEvent[]),
672
+ ]);
673
+ return {
674
+ items: [
675
+ ...events.map((e) => ({
676
+ kind: 'Event',
677
+ title: e.title || 'Untitled event',
678
+ sub: `${CALENDAR.eventsBase}/${e.slug}`,
679
+ href: `/admin/calendar/events/${e.id}`,
680
+ status: e.status as string,
681
+ image: e.cover || '',
682
+ at: e.updatedAt,
683
+ })),
684
+ ...posts.map((p) => ({
685
+ kind: (CONTENT.collections[p.collection] ?? CONTENT.posts).singular,
686
+ title: p.title || 'Untitled',
687
+ sub: collectionUrl(p.collection, p.slug),
688
+ href: `/admin/c/${p.collection}/${p.id}`,
689
+ status: p.status as string,
690
+ image: p.cover || '',
691
+ at: p.updatedAt,
692
+ })),
693
+ ...pages.map((p) => ({
694
+ kind: 'Page',
695
+ title: p.title || 'Untitled',
696
+ sub: p.isHome ? '/' : `/${p.slug}`,
697
+ href: `/admin/pages/${p.id}`,
698
+ status: p.status as string,
699
+ image: '',
700
+ at: p.updatedAt,
701
+ })),
702
+ ...tags.map((t) => ({
703
+ kind: 'Tag',
704
+ title: t.name,
705
+ sub: `${CONTENT.tags.base}/${t.slug}`,
706
+ href: '/admin/tags',
707
+ status: '',
708
+ image: '',
709
+ at: 0,
710
+ })),
711
+ ],
712
+ };
713
+ },
714
+ }),
427
715
  },
428
716
 
429
717
  tags: {
430
718
  list: defineAction({
431
719
  input: z.object({}),
432
720
  handler: async (_input, ctx) => {
433
- requireAdmin(ctx);
721
+ await requireAdmin(ctx);
434
722
  const repo = await getRepo(ctx.locals);
435
723
  return { tags: await repo.listTags() };
436
724
  },
@@ -441,7 +729,7 @@ export const server = {
441
729
  list: defineAction({
442
730
  input: z.object({}),
443
731
  handler: async (_input, ctx) => {
444
- requireAdmin(ctx);
732
+ await requireAdmin(ctx);
445
733
  const repo = await getRepo(ctx.locals);
446
734
  return { snippets: await repo.listSnippets() };
447
735
  },
@@ -449,7 +737,7 @@ export const server = {
449
737
  create: defineAction({
450
738
  input: z.object({ name: z.string().min(1).max(80), contentJson: z.string().min(2) }),
451
739
  handler: async ({ name, contentJson }, ctx) => {
452
- requireAdmin(ctx);
740
+ await requireAdmin(ctx);
453
741
  // must be a JSON array of ProseMirror nodes — reject anything else
454
742
  // before it can break the editor on re-insert
455
743
  let parsed: unknown;
@@ -464,7 +752,7 @@ export const server = {
464
752
  delete: defineAction({
465
753
  input: z.object({ id: z.string() }),
466
754
  handler: async ({ id }, ctx) => {
467
- requireAdmin(ctx);
755
+ await requireAdmin(ctx);
468
756
  const repo = await getRepo(ctx.locals);
469
757
  await repo.deleteSnippet(id);
470
758
  return { ok: true };
@@ -476,7 +764,7 @@ export const server = {
476
764
  list: defineAction({
477
765
  input: z.object({}),
478
766
  handler: async (_input, ctx) => {
479
- requireAdmin(ctx);
767
+ await requireAdmin(ctx);
480
768
  const repo = await getRepo(ctx.locals);
481
769
  return { users: await repo.listUsers() };
482
770
  },
@@ -490,7 +778,7 @@ export const server = {
490
778
  search: defineAction({
491
779
  input: z.object({ query: z.string().min(1) }),
492
780
  handler: async ({ query }, ctx) => {
493
- requireAdmin(ctx);
781
+ await requireAdmin(ctx);
494
782
  const repo = await getRepo(ctx.locals);
495
783
  const q = query.toLowerCase();
496
784
  const [posts, pages] = await Promise.all([repo.listPosts(), repo.listPages()]);
@@ -518,7 +806,7 @@ export const server = {
518
806
  fetch: defineAction({
519
807
  input: z.object({ url: z.string().url() }),
520
808
  handler: async ({ url }, ctx) => {
521
- requireAdmin(ctx);
809
+ await requireAdmin(ctx);
522
810
  // redirects are followed manually so EVERY hop is re-checked against
523
811
  // the private-host guard (a public URL 302ing to 127.0.0.1 or a cloud
524
812
  // metadata IP must not slip through)
@@ -586,9 +874,20 @@ export const server = {
586
874
  preview: defineAction({
587
875
  input: z.object({ sources: z.array(z.string()).min(1).max(6) }),
588
876
  handler: async ({ sources }, ctx) => {
589
- requireAdmin(ctx);
877
+ await requireAdmin(ctx);
590
878
  const normalized: string[] = [];
879
+ const repo0 = await getRepo(ctx.locals);
880
+ const siteCalendars = await repo0.listCalendars();
591
881
  for (const raw of sources) {
882
+ // site calendars: 'acms:all' or 'acms:cal:<slug>'
883
+ if (raw === NATIVE_ALL || raw.startsWith('acms:cal:')) {
884
+ const slug = raw.slice('acms:cal:'.length);
885
+ if (raw !== NATIVE_ALL && !siteCalendars.some((c) => c.slug === slug && c.visibility !== 'private')) {
886
+ throw new ActionError({ code: 'BAD_REQUEST', message: 'That calendar doesn’t exist or is private.' });
887
+ }
888
+ if (!normalized.includes(raw)) normalized.push(raw);
889
+ continue;
890
+ }
592
891
  const url = normalizeCalendarSrc(raw);
593
892
  if (!url) {
594
893
  throw new ActionError({
@@ -602,12 +901,18 @@ export const server = {
602
901
  const settings = await repo.getSettings();
603
902
  const tz = settings.timezone || 'UTC';
604
903
  const now = Date.now();
605
- const { events, errors } = await getCalendarEvents(normalized, {
606
- displayTz: tz, now,
607
- windowStartMs: now - 86_400_000,
608
- windowEndMs: now + 120 * 86_400_000,
609
- });
610
- if (!events.length && errors.length) throw new ActionError({ code: 'BAD_GATEWAY', message: errors[0] });
904
+ const win = { displayTz: tz, now, windowStartMs: now - 86_400_000, windowEndMs: now + 120 * 86_400_000 };
905
+ const feedUrls = normalized.filter((s) => s.startsWith('http'));
906
+ const natives = normalized.filter((s) => !s.startsWith('http'));
907
+ const { events: feedEvents, errors } = feedUrls.length ? await getCalendarEvents(feedUrls, win) : { events: [], errors: [] as string[] };
908
+ let events = feedEvents;
909
+ if (natives.length) {
910
+ const all = natives.includes(NATIVE_ALL);
911
+ const ids = siteCalendars.filter((c) => natives.includes(`acms:cal:${c.slug}`)).map((c) => c.id);
912
+ const got = await collectViewEvents(repo, settings, win, { audience: 'public', ...(all ? {} : { calendarIds: ids }) });
913
+ events = [...events, ...got.events.map((e) => ({ ...e, cal: e.cal + feedUrls.length }))].sort((x, y) => x.start - y.start);
914
+ }
915
+ if (!events.length && errors.length && !natives.length) throw new ActionError({ code: 'BAD_GATEWAY', message: errors[0] });
611
916
  const locale = siteLocale(settings);
612
917
  const dayFmt = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz });
613
918
  const timeFmt = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
@@ -619,7 +924,231 @@ export const server = {
619
924
  title: e.title,
620
925
  cal: e.cal,
621
926
  }));
622
- return { sources: normalized, upcoming, total: events.length };
927
+ const labels = Object.fromEntries(
928
+ natives.map((n) => [n, n === NATIVE_ALL ? 'All site events' : siteCalendars.find((c) => `acms:cal:${c.slug}` === n)?.name ?? n])
929
+ );
930
+ return { sources: normalized, upcoming, total: events.length, labels };
931
+ },
932
+ }),
933
+ },
934
+
935
+ events: {
936
+ save: defineAction({
937
+ input: z.object({
938
+ id: z.string(),
939
+ title: z.string().max(300).optional(),
940
+ slug: z.string().optional(),
941
+ bodyJson: z.string().optional(),
942
+ bodyHtml: z.string().optional(),
943
+ excerpt: z.string().max(1000).optional(),
944
+ tags: z.array(z.string()).optional(),
945
+ cover: z.string().optional(),
946
+ coverAlt: z.string().optional(),
947
+ seoTitle: z.string().optional(),
948
+ seoDesc: z.string().optional(),
949
+ status: StatusInput.optional(),
950
+ featured: z.boolean().optional(),
951
+ template: z.string().optional(),
952
+ start: z.string().optional(),
953
+ end: z.string().optional(),
954
+ allDay: z.boolean().optional(),
955
+ timezone: z.string().optional(),
956
+ rrule: z.string().max(500).optional(),
957
+ exdates: z.array(z.string()).max(500).optional(),
958
+ cancelled: z.boolean().optional(),
959
+ location: z.object({ name: z.string().max(300), address: z.string().max(500), url: z.string().max(1000) }).optional(),
960
+ cta: z.object({ label: z.string().max(120), href: z.string().max(1000), style: z.enum(['primary', 'outline']) }).optional(),
961
+ calendarIds: z.array(z.string()).max(50).optional(),
962
+ fields: z.record(z.string().max(2000)).optional(),
963
+ rsvp: z.object({ enabled: z.boolean(), capacity: z.number().int().min(0).max(100000), autoConfirm: z.boolean(), maxSeats: z.number().int().min(1).max(50) }).optional(),
964
+ scheduledAt: nullableTs.optional(),
965
+ }),
966
+ handler: async ({ id, ...data }, ctx) => {
967
+ await requireAdmin(ctx);
968
+ const repo = await getRepo(ctx.locals);
969
+ const before = await repo.getEvent(id);
970
+ if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Event not found.' });
971
+ const clean = await cleanEventInput(repo, data, before);
972
+ // live events stage edits until Update, exactly like posts
973
+ const transitions = clean.status !== undefined && clean.status !== before.status;
974
+ if ((before.status === 'published' || before.status === 'scheduled') && !transitions) {
975
+ const { status: _s, scheduledAt: _sa, ...staged } = clean;
976
+ await repo.saveEventDraft(id, staged);
977
+ return { event: (await repo.getEvent(id))! };
978
+ }
979
+ await repo.updateEvent(id, clean);
980
+ if (clean.tags !== undefined) await repo.ensureTags(clean.tags);
981
+ const event = (await repo.getEvent(id))!;
982
+ await eventSideEffects(repo, ctx, before, event);
983
+ return { event };
984
+ },
985
+ }),
986
+ applyUpdate: defineAction({
987
+ input: z.object({ id: z.string() }),
988
+ handler: async ({ id }, ctx) => {
989
+ await requireAdmin(ctx);
990
+ const repo = await getRepo(ctx.locals);
991
+ const before = await repo.getEvent(id);
992
+ if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Event not found.' });
993
+ await repo.applyEventDraft(id);
994
+ const event = (await repo.getEvent(id))!;
995
+ if (event.tags.length) await repo.ensureTags(event.tags);
996
+ await eventSideEffects(repo, ctx, before, event, true);
997
+ return { event };
998
+ },
999
+ }),
1000
+ discardChanges: defineAction({
1001
+ input: z.object({ id: z.string() }),
1002
+ handler: async ({ id }, ctx) => {
1003
+ await requireAdmin(ctx);
1004
+ const repo = await getRepo(ctx.locals);
1005
+ await repo.discardEventDraft(id);
1006
+ return { event: await repo.getEvent(id) };
1007
+ },
1008
+ }),
1009
+ delete: defineAction({
1010
+ input: z.object({ id: z.string() }),
1011
+ handler: async ({ id }, ctx) => {
1012
+ await requireAdmin(ctx);
1013
+ const repo = await getRepo(ctx.locals);
1014
+ const before = await repo.getEvent(id);
1015
+ await repo.deleteEvent(id);
1016
+ await eventDeleted(repo, before, { waitUntil: waitUntilFrom(ctx.locals) });
1017
+ return { ok: true };
1018
+ },
1019
+ }),
1020
+ // editor pickers (event card): every event with its next date
1021
+ list: defineAction({
1022
+ input: z.object({ publishedOnly: z.boolean().default(false) }),
1023
+ handler: async ({ publishedOnly }, ctx) => {
1024
+ await requireAdmin(ctx);
1025
+ const repo = await getRepo(ctx.locals);
1026
+ const [events, settings] = await Promise.all([publishedOnly ? repo.listPublishedEvents() : repo.listEvents(), repo.getSettings()]);
1027
+ const now = Date.now();
1028
+ return {
1029
+ events: events.map((e) => {
1030
+ const o = featuredOccurrence(e, settings, now);
1031
+ return {
1032
+ id: e.id, title: e.title || 'Untitled event', slug: e.slug, status: e.status, cover: e.cover,
1033
+ when: o ? formatOccurrence(o, settings.timezone || 'UTC', siteLocale(settings)).range : '',
1034
+ at: o?.start ?? e.startAt,
1035
+ };
1036
+ }).sort((a, b) => a.at - b.at),
1037
+ };
1038
+ },
1039
+ }),
1040
+ // one-time import: an ICS file/URL or a CSV becomes DRAFT events
1041
+ import: defineAction({
1042
+ input: z.object({
1043
+ format: z.enum(['ics', 'csv']),
1044
+ text: z.string().max(3_000_000).optional(),
1045
+ url: z.string().max(2000).optional(),
1046
+ calendarId: z.string().optional(),
1047
+ }),
1048
+ handler: async ({ format, text, url, calendarId }, ctx) => {
1049
+ await requireAdmin(ctx);
1050
+ const repo = await getRepo(ctx.locals);
1051
+ const settings = await repo.getSettings();
1052
+ if (calendarId && !(await repo.getCalendar(calendarId))) throw new ActionError({ code: 'BAD_REQUEST', message: 'Unknown calendar.' });
1053
+ let body = text ?? '';
1054
+ if (format === 'ics' && url) {
1055
+ const feedUrl = normalizeCalendarSrc(url);
1056
+ if (!feedUrl) throw new ActionError({ code: 'BAD_REQUEST', message: 'Paste a Google Calendar link, a calendar ID, or an .ics feed URL.' });
1057
+ try {
1058
+ const feed = await loadFeed(feedUrl, Date.now());
1059
+ return await importEvents(repo, eventsFromVEvents(feed.vevents, feed.calTz, settings.timezone), calendarId);
1060
+ } catch (err) {
1061
+ throw new ActionError({ code: 'BAD_GATEWAY', message: err instanceof Error ? err.message : 'Couldn’t load that feed.' });
1062
+ }
1063
+ }
1064
+ if (!body.trim()) throw new ActionError({ code: 'BAD_REQUEST', message: 'Choose a file or paste a feed URL.' });
1065
+ if (format === 'ics') {
1066
+ if (!body.includes('BEGIN:VCALENDAR')) throw new ActionError({ code: 'BAD_REQUEST', message: 'That file isn’t an .ics calendar.' });
1067
+ return importEvents(repo, eventsFromIcs(body, settings.timezone), calendarId);
1068
+ }
1069
+ const parsed = eventsFromCsv(body);
1070
+ if (parsed.error) throw new ActionError({ code: 'BAD_REQUEST', message: parsed.error });
1071
+ return importEvents(repo, parsed, calendarId);
1072
+ },
1073
+ }),
1074
+ },
1075
+
1076
+ calendars: {
1077
+ list: defineAction({
1078
+ input: z.object({}),
1079
+ handler: async (_input, ctx) => {
1080
+ await requireAdmin(ctx);
1081
+ const repo = await getRepo(ctx.locals);
1082
+ return { calendars: await repo.listCalendars() };
1083
+ },
1084
+ }),
1085
+ save: defineAction({
1086
+ input: z.object({
1087
+ id: z.string().optional(),
1088
+ name: z.string().trim().min(1).max(120),
1089
+ slug: z.string().optional(),
1090
+ color: z.string().optional(),
1091
+ description: z.string().max(1000).optional(),
1092
+ kind: z.enum(['native', 'feed']).optional(),
1093
+ feedUrl: z.string().max(2000).optional(),
1094
+ visibility: z.enum(['public', 'unlisted', 'private']).optional(),
1095
+ }),
1096
+ handler: async ({ id, ...data }, ctx) => {
1097
+ await requireAdmin(ctx);
1098
+ const repo = await getRepo(ctx.locals);
1099
+ const existing = id ? await repo.getCalendar(id) : null;
1100
+ if (id && !existing) throw new ActionError({ code: 'NOT_FOUND', message: 'Calendar not found.' });
1101
+ const kind = existing?.kind ?? data.kind ?? 'native';
1102
+ let feedUrl = existing?.feedUrl ?? '';
1103
+ if (kind === 'feed' && (data.feedUrl !== undefined || !existing)) {
1104
+ const normalized = normalizeCalendarSrc(data.feedUrl ?? '');
1105
+ if (!normalized) throw new ActionError({ code: 'BAD_REQUEST', message: 'Paste a Google Calendar link, a calendar ID, or an .ics feed URL.' });
1106
+ feedUrl = normalized;
1107
+ }
1108
+ const base = slugify(data.slug || data.name);
1109
+ let slug = base;
1110
+ for (let n = 2; ; n++) {
1111
+ const clash = await repo.getCalendarBySlug(slug);
1112
+ if (!clash || clash.id === id) break;
1113
+ slug = `${base}-${n}`;
1114
+ }
1115
+ const fields = {
1116
+ name: data.name, slug, color: safeColor(data.color ?? existing?.color ?? '#4f46e5'),
1117
+ description: data.description ?? existing?.description ?? '', feedUrl,
1118
+ visibility: data.visibility ?? existing?.visibility ?? 'public',
1119
+ };
1120
+ if (existing) {
1121
+ await repo.updateCalendar(existing.id, fields);
1122
+ return { calendar: (await repo.getCalendar(existing.id))! };
1123
+ }
1124
+ return { calendar: await repo.createCalendar({ ...fields, kind }) };
1125
+ },
1126
+ }),
1127
+ reorder: defineAction({
1128
+ input: z.object({ ids: z.array(z.string()) }),
1129
+ handler: async ({ ids }, ctx) => {
1130
+ await requireAdmin(ctx);
1131
+ await (await getRepo(ctx.locals)).reorderCalendars(ids);
1132
+ return { ok: true };
1133
+ },
1134
+ }),
1135
+ rotateToken: defineAction({
1136
+ input: z.object({ id: z.string() }),
1137
+ handler: async ({ id }, ctx) => {
1138
+ await requireAdmin(ctx);
1139
+ const repo = await getRepo(ctx.locals);
1140
+ const bytes = crypto.getRandomValues(new Uint8Array(18));
1141
+ const token = btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
1142
+ await repo.updateCalendar(id, { feedToken: token });
1143
+ return { calendar: await repo.getCalendar(id) };
1144
+ },
1145
+ }),
1146
+ delete: defineAction({
1147
+ input: z.object({ id: z.string() }),
1148
+ handler: async ({ id }, ctx) => {
1149
+ await requireAdmin(ctx);
1150
+ await (await getRepo(ctx.locals)).deleteCalendar(id);
1151
+ return { ok: true };
623
1152
  },
624
1153
  }),
625
1154
  },
@@ -628,7 +1157,7 @@ export const server = {
628
1157
  search: defineAction({
629
1158
  input: z.object({ query: z.string().min(1), page: z.number().int().min(1).default(1) }),
630
1159
  handler: async ({ query, page }, ctx) => {
631
- requireAdmin(ctx);
1160
+ await requireAdmin(ctx);
632
1161
  const key = env('UNSPLASH_ACCESS_KEY');
633
1162
  if (!key) throw new ActionError({ code: 'PRECONDITION_FAILED', message: 'Unsplash is not configured.' });
634
1163
  const res = await fetch(
@@ -669,7 +1198,7 @@ export const server = {
669
1198
  track: defineAction({
670
1199
  input: z.object({ downloadLocation: z.string().url() }),
671
1200
  handler: async ({ downloadLocation }, ctx) => {
672
- requireAdmin(ctx);
1201
+ await requireAdmin(ctx);
673
1202
  const key = env('UNSPLASH_ACCESS_KEY');
674
1203
  if (!key || !downloadLocation.startsWith('https://api.unsplash.com/')) return { ok: false };
675
1204
  await fetch(downloadLocation, { headers: { Authorization: `Client-ID ${key}` } }).catch(() => {});