@acms-subir/core 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (230) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/cli/common.mjs +35 -0
  3. package/cli/legacy.mjs +317 -0
  4. package/cli/main.mjs +66 -0
  5. package/cli/palette.mjs +4 -0
  6. package/cli/preview.mjs +50 -0
  7. package/cli/remote.mjs +55 -0
  8. package/cli/themes.mjs +127 -0
  9. package/cli.mjs +2 -369
  10. package/deploy/Caddyfile +8 -0
  11. package/deploy/Dockerfile +34 -0
  12. package/deploy/compose.https.yml +21 -0
  13. package/deploy/compose.yml +46 -0
  14. package/docs/theme-authoring.md +103 -0
  15. package/email-runner.mjs +32 -0
  16. package/integration.mjs +182 -2
  17. package/ops-check.mjs +27 -0
  18. package/package.json +27 -5
  19. package/src/actions/index.ts +575 -47
  20. package/src/components/AiCleaner.tsx +291 -0
  21. package/src/components/AiSettings.tsx +575 -0
  22. package/src/components/CommandPalette.tsx +200 -0
  23. package/src/components/ContentList.astro +246 -0
  24. package/src/components/DesignAccess.tsx +182 -0
  25. package/src/components/DesignManager.tsx +418 -46
  26. package/src/components/EmailSettings.tsx +521 -0
  27. package/src/components/EventPage.astro +178 -0
  28. package/src/components/LegalPage.astro +27 -0
  29. package/src/components/LinksManager.tsx +114 -57
  30. package/src/components/NavManager.tsx +42 -43
  31. package/src/components/ThemeScript.astro +50 -0
  32. package/src/components/ThemeSections.astro +57 -0
  33. package/src/components/ThemeSectionsEditor.tsx +513 -0
  34. package/src/components/booking/BookingFlow.astro +150 -0
  35. package/src/components/booking/HoursEditor.astro +63 -0
  36. package/src/components/builder/PageBuilder.tsx +10 -2
  37. package/src/components/editor/EventPanel.tsx +401 -0
  38. package/src/components/editor/FormatDock.tsx +81 -0
  39. package/src/components/editor/LivePane.tsx +122 -0
  40. package/src/components/editor/MobileToolbar.tsx +93 -0
  41. package/src/components/editor/PostEditor.tsx +605 -223
  42. package/src/components/editor/PreviewOverlay.tsx +242 -43
  43. package/src/components/editor/extensions/calendar.tsx +51 -7
  44. package/src/components/editor/extensions/events.tsx +153 -0
  45. package/src/components/editor/extensions/slash.tsx +7 -1
  46. package/src/components/site/Pager.astro +24 -0
  47. package/src/components/site/PostCard.astro +64 -0
  48. package/src/components/site/TagLabel.astro +11 -0
  49. package/src/layouts/Admin.astro +289 -49
  50. package/src/layouts/Site.astro +324 -59
  51. package/src/lib/agent-api.ts +13 -0
  52. package/src/lib/agent-discovery.ts +40 -0
  53. package/src/lib/agent.ts +114 -0
  54. package/src/lib/ai-config.ts +58 -0
  55. package/src/lib/ai-schema.ts +22 -0
  56. package/src/lib/ai.ts +90 -0
  57. package/src/lib/api-keys.ts +65 -0
  58. package/src/lib/api.ts +52 -0
  59. package/src/lib/auth.ts +25 -5
  60. package/src/lib/backup/content.ts +36 -0
  61. package/src/lib/backup/unzip.ts +12 -1
  62. package/src/lib/backup/zip.ts +1 -1
  63. package/src/lib/booking/agent.ts +108 -0
  64. package/src/lib/booking/availability.ts +221 -0
  65. package/src/lib/booking/emails.ts +115 -0
  66. package/src/lib/booking/forms.ts +77 -0
  67. package/src/lib/booking/service.ts +277 -0
  68. package/src/lib/booking/view.ts +54 -0
  69. package/src/lib/calendar/agent-schema.ts +48 -0
  70. package/src/lib/calendar/agent.ts +136 -0
  71. package/src/lib/calendar/api.ts +81 -0
  72. package/src/lib/calendar/cards.ts +102 -0
  73. package/src/lib/calendar/config.ts +52 -0
  74. package/src/lib/calendar/data.ts +130 -0
  75. package/src/lib/calendar/events.ts +261 -0
  76. package/src/lib/calendar/feeds.ts +43 -0
  77. package/src/lib/calendar/hooks.ts +38 -0
  78. package/src/lib/calendar/http.ts +30 -0
  79. package/src/lib/calendar/ics-write.ts +120 -0
  80. package/src/lib/calendar/ics.ts +15 -5
  81. package/src/lib/calendar/import.ts +203 -0
  82. package/src/lib/calendar/render.ts +120 -37
  83. package/src/lib/calendar/rrule.ts +149 -0
  84. package/src/lib/calendar/seo.ts +48 -0
  85. package/src/lib/design-service.ts +58 -0
  86. package/src/lib/designDiff.ts +145 -0
  87. package/src/lib/designPreview.ts +110 -12
  88. package/src/lib/documents/cleanup.ts +31 -0
  89. package/src/lib/documents/extract.ts +154 -0
  90. package/src/lib/documents/vendor.d.ts +4 -0
  91. package/src/lib/drafts.ts +60 -6
  92. package/src/lib/dynamicBlocks.ts +40 -8
  93. package/src/lib/email/accounts.ts +55 -0
  94. package/src/lib/email/config.ts +86 -0
  95. package/src/lib/email/migration.ts +24 -0
  96. package/src/lib/email/providers.ts +89 -0
  97. package/src/lib/email/service.ts +78 -0
  98. package/src/lib/email/store.ts +167 -0
  99. package/src/lib/email/templates.ts +18 -0
  100. package/src/lib/email/types.ts +32 -0
  101. package/src/lib/email/webhooks.ts +87 -0
  102. package/src/lib/email.ts +15 -0
  103. package/src/lib/env.ts +1 -1
  104. package/src/lib/legal/index.ts +25 -0
  105. package/src/lib/mobile/bookings.ts +73 -0
  106. package/src/lib/mobile/calendar.ts +85 -0
  107. package/src/lib/mobile/content.ts +85 -0
  108. package/src/lib/mobile/http.ts +63 -0
  109. package/src/lib/mobile/route.ts +20 -0
  110. package/src/lib/ops.ts +8 -0
  111. package/src/lib/preview.ts +21 -1
  112. package/src/lib/repo/migrations.ts +217 -0
  113. package/src/lib/repo/sql.ts +853 -60
  114. package/src/lib/repo/types.ts +122 -5
  115. package/src/lib/scheduler.ts +25 -0
  116. package/src/lib/schema.ts +309 -0
  117. package/src/lib/search-i18n.ts +3 -0
  118. package/src/lib/socialIcons.ts +32 -0
  119. package/src/lib/tagColor.ts +11 -0
  120. package/src/lib/theme.ts +59 -40
  121. package/src/lib/themeTokens.ts +174 -0
  122. package/src/lib/umami.ts +108 -0
  123. package/src/lib/webhooks.ts +139 -0
  124. package/src/middleware.ts +36 -3
  125. package/src/pages/404.astro +23 -0
  126. package/src/pages/[...slug].astro +8 -5
  127. package/src/pages/account/[action].astro +53 -0
  128. package/src/pages/admin/ai.astro +12 -0
  129. package/src/pages/admin/c/[collection]/[id].astro +4 -1
  130. package/src/pages/admin/c/[collection]/index.astro +127 -92
  131. package/src/pages/admin/calendar/bookings.astro +400 -0
  132. package/src/pages/admin/calendar/calendars.astro +265 -0
  133. package/src/pages/admin/calendar/events/[id].astro +56 -0
  134. package/src/pages/admin/calendar/events/index.astro +408 -0
  135. package/src/pages/admin/calendar/index.ts +4 -0
  136. package/src/pages/admin/calendar/integrations.astro +221 -0
  137. package/src/pages/admin/calendar/resources/[id].astro +180 -0
  138. package/src/pages/admin/calendar/resources/index.astro +106 -0
  139. package/src/pages/admin/calendar/resources/link.astro +196 -0
  140. package/src/pages/admin/design.astro +10 -3
  141. package/src/pages/admin/email.astro +13 -0
  142. package/src/pages/admin/index.astro +232 -85
  143. package/src/pages/admin/links.astro +3 -3
  144. package/src/pages/admin/login.astro +14 -10
  145. package/src/pages/admin/navigation.astro +8 -2
  146. package/src/pages/admin/pages/[id].astro +4 -1
  147. package/src/pages/admin/pages/index.astro +41 -48
  148. package/src/pages/admin/settings.astro +418 -367
  149. package/src/pages/admin/tags.astro +4 -4
  150. package/src/pages/admin/users.astro +38 -28
  151. package/src/pages/api/agent/docs.ts +33 -0
  152. package/src/pages/api/agent/index.ts +21 -0
  153. package/src/pages/api/ai/clean.ts +13 -0
  154. package/src/pages/api/ai/draft.ts +14 -0
  155. package/src/pages/api/ai/settings.ts +53 -0
  156. package/src/pages/api/ai/test.ts +12 -0
  157. package/src/pages/api/backup.ts +8 -21
  158. package/src/pages/api/calendar/availability.ts +35 -0
  159. package/src/pages/api/calendar/bookings/[id].ts +49 -0
  160. package/src/pages/api/calendar/bookings/index.ts +73 -0
  161. package/src/pages/api/calendar/calendars.ts +35 -0
  162. package/src/pages/api/calendar/docs.ts +64 -0
  163. package/src/pages/api/calendar/events/[id].ts +63 -0
  164. package/src/pages/api/calendar/events/index.ts +112 -0
  165. package/src/pages/api/calendar/resources.ts +34 -0
  166. package/src/pages/api/design/capabilities.ts +10 -0
  167. package/src/pages/api/design/index.ts +58 -0
  168. package/src/pages/api/design/keys.ts +24 -0
  169. package/src/pages/api/design/media.ts +32 -0
  170. package/src/pages/api/email/run.ts +11 -0
  171. package/src/pages/api/email/settings.ts +61 -0
  172. package/src/pages/api/email/webhooks/[provider].ts +4 -0
  173. package/src/pages/api/mcp.ts +59 -0
  174. package/src/pages/api/media/upload.ts +2 -2
  175. package/src/pages/api/ops/status.ts +39 -0
  176. package/src/pages/api/restore.ts +27 -37
  177. package/src/pages/api/search.ts +18 -4
  178. package/src/pages/api/v1/availability.ts +7 -0
  179. package/src/pages/api/v1/bookings.ts +17 -0
  180. package/src/pages/api/v1/calendars.ts +7 -0
  181. package/src/pages/api/v1/content/[id].ts +7 -0
  182. package/src/pages/api/v1/content/index.ts +7 -0
  183. package/src/pages/api/v1/events.ts +7 -0
  184. package/src/pages/api/v1/resources.ts +7 -0
  185. package/src/pages/author/[slug].astro +43 -35
  186. package/src/pages/blog/[slug].astro +137 -66
  187. package/src/pages/blog/index.astro +95 -30
  188. package/src/pages/blog/tag/[slug].astro +45 -40
  189. package/src/pages/book/[resource]/[link].astro +70 -0
  190. package/src/pages/book/[resource]/index.astro +63 -0
  191. package/src/pages/book/[resource].ics.ts +38 -0
  192. package/src/pages/book/index.astro +47 -0
  193. package/src/pages/book/manage/[token].astro +108 -0
  194. package/src/pages/calendar/[slug].ics.ts +27 -0
  195. package/src/pages/calendar/feed.ics.ts +15 -0
  196. package/src/pages/calendar/index.astro +191 -0
  197. package/src/pages/embed/book/[resource]/[link].astro +33 -0
  198. package/src/pages/embed/calendar/[slug].astro +43 -0
  199. package/src/pages/events/[slug].astro +74 -0
  200. package/src/pages/events/[slug].ics.ts +25 -0
  201. package/src/pages/index.astro +6 -3
  202. package/src/pages/legal/privacy.astro +4 -0
  203. package/src/pages/legal/terms.astro +4 -0
  204. package/src/pages/links.astro +23 -3
  205. package/src/pages/llms.txt.ts +4 -0
  206. package/src/pages/preview/[type]/[id].astro +27 -6
  207. package/src/pages/search.astro +40 -20
  208. package/src/pages/sitemap.xml.ts +16 -1
  209. package/src/styles/admin.css +924 -105
  210. package/src/styles/cards.css +92 -74
  211. package/src/styles/editor.css +492 -114
  212. package/src/styles/email-settings.css +117 -0
  213. package/src/styles/sections.css +12 -0
  214. package/src/styles/site.css +938 -281
  215. package/src/styles/theme-editor.css +135 -0
  216. package/src/theme-sdk/contract.d.mts +619 -0
  217. package/src/theme-sdk/contract.mjs +80 -0
  218. package/src/theme-sdk/index.ts +15 -0
  219. package/src/theme-sdk/tokens.d.mts +148 -0
  220. package/src/theme-sdk/tokens.mjs +52 -0
  221. package/src/themes/editorial/templates/home.astro +4 -4
  222. package/src/themes/editorial/templates/post-minimal.astro +2 -2
  223. package/src/themes/editorial/theme.css +22 -37
  224. package/src/themes/editorial/theme.json +23 -3
  225. package/src/themes/field/theme.json +26 -9
  226. package/src/themes/ink/theme.json +30 -12
  227. package/src/themes/night/theme.css +4 -7
  228. package/src/themes/night/theme.json +27 -10
  229. package/src/virtual.d.ts +14 -0
  230. package/src/worker.ts +5 -1
@@ -1,9 +1,12 @@
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';
9
+ import { validateSession, SESSION_COOKIE } from '../lib/auth';
7
10
  import { parseConfig, slugify, widgetTypes, type WidgetType } from '../lib/schema';
8
11
  import { env } from '../lib/env';
9
12
  import { CONTENT, collectionUrl } from '../lib/content';
@@ -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) {
@@ -279,7 +401,7 @@ export const server = {
279
401
  meta: z.record(z.any()).default({}),
280
402
  }),
281
403
  handler: async (data, ctx) => {
282
- requireAdmin(ctx);
404
+ await requireAdmin(ctx);
283
405
  const repo = await getRepo(ctx.locals);
284
406
  return { link: await repo.createLink(data) };
285
407
  },
@@ -294,7 +416,7 @@ export const server = {
294
416
  meta: z.record(z.any()).optional(),
295
417
  }),
296
418
  handler: async ({ id, ...data }, ctx) => {
297
- requireAdmin(ctx);
419
+ await requireAdmin(ctx);
298
420
  const repo = await getRepo(ctx.locals);
299
421
  await repo.updateLink(id, data);
300
422
  return { ok: true };
@@ -303,7 +425,7 @@ export const server = {
303
425
  reorder: defineAction({
304
426
  input: z.object({ ids: z.array(z.string()) }),
305
427
  handler: async ({ ids }, ctx) => {
306
- requireAdmin(ctx);
428
+ await requireAdmin(ctx);
307
429
  const repo = await getRepo(ctx.locals);
308
430
  await repo.reorderLinks(ids);
309
431
  return { ok: true };
@@ -312,7 +434,7 @@ export const server = {
312
434
  delete: defineAction({
313
435
  input: z.object({ id: z.string() }),
314
436
  handler: async ({ id }, ctx) => {
315
- requireAdmin(ctx);
437
+ await requireAdmin(ctx);
316
438
  const repo = await getRepo(ctx.locals);
317
439
  await repo.deleteLink(id);
318
440
  return { ok: true };
@@ -321,6 +443,26 @@ export const server = {
321
443
  },
322
444
 
323
445
  nav: {
446
+ // Navigation stages with the design: the full ordered list goes into the
447
+ // design draft and reaches the live menus on "Publish design".
448
+ saveDraft: defineAction({
449
+ input: z.object({
450
+ items: z.array(z.object({
451
+ id: z.string(),
452
+ label: z.string(),
453
+ url: z.string(),
454
+ area: z.enum(['header', 'footer', 'social']),
455
+ })),
456
+ }),
457
+ handler: async ({ items }, ctx) => {
458
+ await requireAdmin(ctx);
459
+ const repo = await getRepo(ctx.locals);
460
+ await repo.saveDesignDraft({ navItems: items });
461
+ const changes = await designChanges(repo);
462
+ if (!changes.length) await repo.discardDesignDraft();
463
+ return { ok: true, changes };
464
+ },
465
+ }),
324
466
  create: defineAction({
325
467
  input: z.object({
326
468
  label: z.string().default('New link'),
@@ -328,7 +470,7 @@ export const server = {
328
470
  area: z.enum(['header', 'footer', 'social']).default('header'),
329
471
  }),
330
472
  handler: async (data, ctx) => {
331
- requireAdmin(ctx);
473
+ await requireAdmin(ctx);
332
474
  const repo = await getRepo(ctx.locals);
333
475
  return { item: await repo.createNavItem(data) };
334
476
  },
@@ -341,7 +483,7 @@ export const server = {
341
483
  area: z.enum(['header', 'footer', 'social']).optional(),
342
484
  }),
343
485
  handler: async ({ id, ...data }, ctx) => {
344
- requireAdmin(ctx);
486
+ await requireAdmin(ctx);
345
487
  const repo = await getRepo(ctx.locals);
346
488
  await repo.updateNavItem(id, data);
347
489
  return { ok: true };
@@ -350,7 +492,7 @@ export const server = {
350
492
  reorder: defineAction({
351
493
  input: z.object({ ids: z.array(z.string()) }),
352
494
  handler: async ({ ids }, ctx) => {
353
- requireAdmin(ctx);
495
+ await requireAdmin(ctx);
354
496
  const repo = await getRepo(ctx.locals);
355
497
  await repo.reorderNavItems(ids);
356
498
  return { ok: true };
@@ -359,7 +501,7 @@ export const server = {
359
501
  delete: defineAction({
360
502
  input: z.object({ id: z.string() }),
361
503
  handler: async ({ id }, ctx) => {
362
- requireAdmin(ctx);
504
+ await requireAdmin(ctx);
363
505
  const repo = await getRepo(ctx.locals);
364
506
  await repo.deleteNavItem(id);
365
507
  return { ok: true };
@@ -383,14 +525,15 @@ export const server = {
383
525
  siteCover: z.string().optional(),
384
526
  }),
385
527
  handler: async (data, ctx) => {
386
- requireAdmin(ctx);
528
+ await requireAdmin(ctx);
387
529
  const repo = await getRepo(ctx.locals);
388
530
  await repo.updateSettings(data);
389
531
  return { ok: true };
390
532
  },
391
533
  }),
392
- // Design-screen staging: edits stage in settings.draftDesign (previewed
393
- // via ?designPreview=1) until "Publish design" applies them live.
534
+ // Design-screen staging: edits stage in settings.draftDesign (seen in
535
+ // preview mode) until "Publish design" applies them live. Every mutation
536
+ // returns the refreshed change list.
394
537
  saveDesignDraft: defineAction({
395
538
  input: z.object({
396
539
  activeTheme: z.string().optional(),
@@ -400,37 +543,181 @@ export const server = {
400
543
  siteCover: z.string().optional(),
401
544
  }),
402
545
  handler: async (data, ctx) => {
403
- requireAdmin(ctx);
546
+ await requireAdmin(ctx);
404
547
  const repo = await getRepo(ctx.locals);
405
548
  await repo.saveDesignDraft(data);
406
- return { ok: true };
549
+ // edits that land back on the live design leave nothing to publish
550
+ const changes = await designChanges(repo);
551
+ if (!changes.length) await repo.discardDesignDraft();
552
+ return { ok: true, changes };
553
+ },
554
+ }),
555
+ designStatus: defineAction({
556
+ input: z.object({}),
557
+ handler: async (_input, ctx) => {
558
+ await requireAdmin(ctx);
559
+ return { changes: await designChanges(await getRepo(ctx.locals)) };
560
+ },
561
+ }),
562
+ // undo one row of the change list (back to the live value)
563
+ revertDesignChange: defineAction({
564
+ input: z.object({ key: z.string() }),
565
+ handler: async ({ key }, ctx) => {
566
+ await requireAdmin(ctx);
567
+ const repo = await getRepo(ctx.locals);
568
+ const [settings, nav] = await Promise.all([repo.getSettings(), repo.listNavItems()]);
569
+ if (settings.draftDesign) await repo.setDesignDraft(revertDesignChange(settings, nav, settings.draftDesign, key));
570
+ const after = await repo.getSettings();
571
+ return { changes: await designChanges(repo), draft: after.draftDesign };
407
572
  },
408
573
  }),
409
574
  applyDesign: defineAction({
410
575
  input: z.object({}),
411
576
  handler: async (_input, ctx) => {
412
- requireAdmin(ctx);
577
+ const userId = await validateSession(ctx.cookies.get(SESSION_COOKIE)?.value, ctx.locals);
578
+ if (!userId) throw new ActionError({ code: 'UNAUTHORIZED', message: 'Not signed in.' });
413
579
  const repo = await getRepo(ctx.locals);
414
- await repo.applyDesignDraft();
580
+ const changes = await designChanges(repo);
581
+ const author = (await repo.listUsers()).find((u) => u.id === userId)?.name ?? 'Admin';
582
+ const settings = await repo.getSettings();
583
+ const effective = { ...settings, ...pickDesignDraft(settings.draftDesign ?? {}) };
584
+ try {
585
+ validateSectionDesign(effective, getTheme(effective.activeTheme));
586
+ await repo.applyDesignDraft({ author, summary: summarizeChanges(changes), expectedRevision: settings.designRevision });
587
+ } catch (error) {
588
+ if (error instanceof ApiError) throw new ActionError({ code: 'BAD_REQUEST', message: error.message });
589
+ throw error;
590
+ }
415
591
  return { ok: true };
416
592
  },
417
593
  }),
418
594
  discardDesign: defineAction({
419
595
  input: z.object({}),
420
596
  handler: async (_input, ctx) => {
421
- requireAdmin(ctx);
597
+ await requireAdmin(ctx);
422
598
  const repo = await getRepo(ctx.locals);
423
599
  await repo.discardDesignDraft();
424
600
  return { ok: true };
425
601
  },
426
602
  }),
603
+ // design history (snapshots taken on every publish)
604
+ listDesignVersions: defineAction({
605
+ input: z.object({}),
606
+ handler: async (_input, ctx) => {
607
+ await requireAdmin(ctx);
608
+ const repo = await getRepo(ctx.locals);
609
+ const versions = await repo.listDesignVersions(20);
610
+ return {
611
+ versions: versions.map((v) => ({
612
+ id: v.id, createdAt: v.createdAt, author: v.author, summary: v.summary,
613
+ theme: getTheme(String(v.snapshot.activeTheme || 'ink')).name,
614
+ })),
615
+ };
616
+ },
617
+ }),
618
+ // a restore never goes live directly: the snapshot becomes the draft
619
+ restoreDesignVersion: defineAction({
620
+ input: z.object({ id: z.string() }),
621
+ handler: async ({ id }, ctx) => {
622
+ await requireAdmin(ctx);
623
+ const repo = await getRepo(ctx.locals);
624
+ const v = await repo.getDesignVersion(id);
625
+ if (!v) throw new ActionError({ code: 'NOT_FOUND', message: 'That version no longer exists.' });
626
+ const current = await repo.getSettings();
627
+ await repo.setDesignDraft({ ...v.snapshot, shareEpoch: crypto.randomUUID() });
628
+ const changes = await designChanges(repo);
629
+ if (!changes.length) await repo.discardDesignDraft();
630
+ return { changes };
631
+ },
632
+ }),
633
+ // share links: signed ?preview=<token> URLs, tied to the draft's epoch
634
+ createShareLink: defineAction({
635
+ input: z.object({ days: z.number().int().min(1).max(30).default(7) }),
636
+ handler: async ({ days }, ctx) => {
637
+ await requireAdmin(ctx);
638
+ const repo = await getRepo(ctx.locals);
639
+ const settings = await repo.getSettings();
640
+ if (!settings.draftDesign) throw new ActionError({ code: 'BAD_REQUEST', message: 'There are no draft changes to share.' });
641
+ if (shareEpoch(settings.draftDesign) === 'legacy-disabled') {
642
+ await repo.saveDesignDraft({ shareEpoch: crypto.randomUUID() });
643
+ settings.draftDesign = (await repo.getSettings()).draftDesign;
644
+ }
645
+ const { token, expiresAt } = createDesignToken(shareEpoch(settings.draftDesign), days * 86_400_000);
646
+ return { url: `${siteOrigin(settings, ctx.url)}/?preview=${token}`, expiresAt };
647
+ },
648
+ }),
649
+ revokeShareLinks: defineAction({
650
+ input: z.object({}),
651
+ handler: async (_input, ctx) => {
652
+ await requireAdmin(ctx);
653
+ const repo = await getRepo(ctx.locals);
654
+ const settings = await repo.getSettings();
655
+ if (settings.draftDesign) await repo.saveDesignDraft({ shareEpoch: crypto.randomUUID() });
656
+ return { ok: true };
657
+ },
658
+ }),
659
+ },
660
+
661
+ admin: {
662
+ // ⌘K palette index: every page, collection entry and tag, small enough to
663
+ // fetch once per palette session and filter client-side
664
+ index: defineAction({
665
+ input: z.object({}),
666
+ handler: async (_input, ctx) => {
667
+ await requireAdmin(ctx);
668
+ const repo = await getRepo(ctx.locals);
669
+ const [pages, posts, tags, events] = await Promise.all([
670
+ repo.listPages(), repo.listPosts(), repo.listTags(), CALENDAR.enabled ? repo.listEvents() : Promise.resolve([] as CalEvent[]),
671
+ ]);
672
+ return {
673
+ items: [
674
+ ...events.map((e) => ({
675
+ kind: 'Event',
676
+ title: e.title || 'Untitled event',
677
+ sub: `${CALENDAR.eventsBase}/${e.slug}`,
678
+ href: `/admin/calendar/events/${e.id}`,
679
+ status: e.status as string,
680
+ image: e.cover || '',
681
+ at: e.updatedAt,
682
+ })),
683
+ ...posts.map((p) => ({
684
+ kind: (CONTENT.collections[p.collection] ?? CONTENT.posts).singular,
685
+ title: p.title || 'Untitled',
686
+ sub: collectionUrl(p.collection, p.slug),
687
+ href: `/admin/c/${p.collection}/${p.id}`,
688
+ status: p.status as string,
689
+ image: p.cover || '',
690
+ at: p.updatedAt,
691
+ })),
692
+ ...pages.map((p) => ({
693
+ kind: 'Page',
694
+ title: p.title || 'Untitled',
695
+ sub: p.isHome ? '/' : `/${p.slug}`,
696
+ href: `/admin/pages/${p.id}`,
697
+ status: p.status as string,
698
+ image: '',
699
+ at: p.updatedAt,
700
+ })),
701
+ ...tags.map((t) => ({
702
+ kind: 'Tag',
703
+ title: t.name,
704
+ sub: `${CONTENT.tags.base}/${t.slug}`,
705
+ href: '/admin/tags',
706
+ status: '',
707
+ image: '',
708
+ at: 0,
709
+ })),
710
+ ],
711
+ };
712
+ },
713
+ }),
427
714
  },
428
715
 
429
716
  tags: {
430
717
  list: defineAction({
431
718
  input: z.object({}),
432
719
  handler: async (_input, ctx) => {
433
- requireAdmin(ctx);
720
+ await requireAdmin(ctx);
434
721
  const repo = await getRepo(ctx.locals);
435
722
  return { tags: await repo.listTags() };
436
723
  },
@@ -441,7 +728,7 @@ export const server = {
441
728
  list: defineAction({
442
729
  input: z.object({}),
443
730
  handler: async (_input, ctx) => {
444
- requireAdmin(ctx);
731
+ await requireAdmin(ctx);
445
732
  const repo = await getRepo(ctx.locals);
446
733
  return { snippets: await repo.listSnippets() };
447
734
  },
@@ -449,7 +736,7 @@ export const server = {
449
736
  create: defineAction({
450
737
  input: z.object({ name: z.string().min(1).max(80), contentJson: z.string().min(2) }),
451
738
  handler: async ({ name, contentJson }, ctx) => {
452
- requireAdmin(ctx);
739
+ await requireAdmin(ctx);
453
740
  // must be a JSON array of ProseMirror nodes — reject anything else
454
741
  // before it can break the editor on re-insert
455
742
  let parsed: unknown;
@@ -464,7 +751,7 @@ export const server = {
464
751
  delete: defineAction({
465
752
  input: z.object({ id: z.string() }),
466
753
  handler: async ({ id }, ctx) => {
467
- requireAdmin(ctx);
754
+ await requireAdmin(ctx);
468
755
  const repo = await getRepo(ctx.locals);
469
756
  await repo.deleteSnippet(id);
470
757
  return { ok: true };
@@ -476,7 +763,7 @@ export const server = {
476
763
  list: defineAction({
477
764
  input: z.object({}),
478
765
  handler: async (_input, ctx) => {
479
- requireAdmin(ctx);
766
+ await requireAdmin(ctx);
480
767
  const repo = await getRepo(ctx.locals);
481
768
  return { users: await repo.listUsers() };
482
769
  },
@@ -490,7 +777,7 @@ export const server = {
490
777
  search: defineAction({
491
778
  input: z.object({ query: z.string().min(1) }),
492
779
  handler: async ({ query }, ctx) => {
493
- requireAdmin(ctx);
780
+ await requireAdmin(ctx);
494
781
  const repo = await getRepo(ctx.locals);
495
782
  const q = query.toLowerCase();
496
783
  const [posts, pages] = await Promise.all([repo.listPosts(), repo.listPages()]);
@@ -518,7 +805,7 @@ export const server = {
518
805
  fetch: defineAction({
519
806
  input: z.object({ url: z.string().url() }),
520
807
  handler: async ({ url }, ctx) => {
521
- requireAdmin(ctx);
808
+ await requireAdmin(ctx);
522
809
  // redirects are followed manually so EVERY hop is re-checked against
523
810
  // the private-host guard (a public URL 302ing to 127.0.0.1 or a cloud
524
811
  // metadata IP must not slip through)
@@ -586,9 +873,20 @@ export const server = {
586
873
  preview: defineAction({
587
874
  input: z.object({ sources: z.array(z.string()).min(1).max(6) }),
588
875
  handler: async ({ sources }, ctx) => {
589
- requireAdmin(ctx);
876
+ await requireAdmin(ctx);
590
877
  const normalized: string[] = [];
878
+ const repo0 = await getRepo(ctx.locals);
879
+ const siteCalendars = await repo0.listCalendars();
591
880
  for (const raw of sources) {
881
+ // site calendars: 'acms:all' or 'acms:cal:<slug>'
882
+ if (raw === NATIVE_ALL || raw.startsWith('acms:cal:')) {
883
+ const slug = raw.slice('acms:cal:'.length);
884
+ if (raw !== NATIVE_ALL && !siteCalendars.some((c) => c.slug === slug && c.visibility !== 'private')) {
885
+ throw new ActionError({ code: 'BAD_REQUEST', message: 'That calendar doesn’t exist or is private.' });
886
+ }
887
+ if (!normalized.includes(raw)) normalized.push(raw);
888
+ continue;
889
+ }
592
890
  const url = normalizeCalendarSrc(raw);
593
891
  if (!url) {
594
892
  throw new ActionError({
@@ -602,12 +900,18 @@ export const server = {
602
900
  const settings = await repo.getSettings();
603
901
  const tz = settings.timezone || 'UTC';
604
902
  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] });
903
+ const win = { displayTz: tz, now, windowStartMs: now - 86_400_000, windowEndMs: now + 120 * 86_400_000 };
904
+ const feedUrls = normalized.filter((s) => s.startsWith('http'));
905
+ const natives = normalized.filter((s) => !s.startsWith('http'));
906
+ const { events: feedEvents, errors } = feedUrls.length ? await getCalendarEvents(feedUrls, win) : { events: [], errors: [] as string[] };
907
+ let events = feedEvents;
908
+ if (natives.length) {
909
+ const all = natives.includes(NATIVE_ALL);
910
+ const ids = siteCalendars.filter((c) => natives.includes(`acms:cal:${c.slug}`)).map((c) => c.id);
911
+ const got = await collectViewEvents(repo, settings, win, { audience: 'public', ...(all ? {} : { calendarIds: ids }) });
912
+ events = [...events, ...got.events.map((e) => ({ ...e, cal: e.cal + feedUrls.length }))].sort((x, y) => x.start - y.start);
913
+ }
914
+ if (!events.length && errors.length && !natives.length) throw new ActionError({ code: 'BAD_GATEWAY', message: errors[0] });
611
915
  const locale = siteLocale(settings);
612
916
  const dayFmt = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz });
613
917
  const timeFmt = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
@@ -619,7 +923,231 @@ export const server = {
619
923
  title: e.title,
620
924
  cal: e.cal,
621
925
  }));
622
- return { sources: normalized, upcoming, total: events.length };
926
+ const labels = Object.fromEntries(
927
+ natives.map((n) => [n, n === NATIVE_ALL ? 'All site events' : siteCalendars.find((c) => `acms:cal:${c.slug}` === n)?.name ?? n])
928
+ );
929
+ return { sources: normalized, upcoming, total: events.length, labels };
930
+ },
931
+ }),
932
+ },
933
+
934
+ events: {
935
+ save: defineAction({
936
+ input: z.object({
937
+ id: z.string(),
938
+ title: z.string().max(300).optional(),
939
+ slug: z.string().optional(),
940
+ bodyJson: z.string().optional(),
941
+ bodyHtml: z.string().optional(),
942
+ excerpt: z.string().max(1000).optional(),
943
+ tags: z.array(z.string()).optional(),
944
+ cover: z.string().optional(),
945
+ coverAlt: z.string().optional(),
946
+ seoTitle: z.string().optional(),
947
+ seoDesc: z.string().optional(),
948
+ status: StatusInput.optional(),
949
+ featured: z.boolean().optional(),
950
+ template: z.string().optional(),
951
+ start: z.string().optional(),
952
+ end: z.string().optional(),
953
+ allDay: z.boolean().optional(),
954
+ timezone: z.string().optional(),
955
+ rrule: z.string().max(500).optional(),
956
+ exdates: z.array(z.string()).max(500).optional(),
957
+ cancelled: z.boolean().optional(),
958
+ location: z.object({ name: z.string().max(300), address: z.string().max(500), url: z.string().max(1000) }).optional(),
959
+ cta: z.object({ label: z.string().max(120), href: z.string().max(1000), style: z.enum(['primary', 'outline']) }).optional(),
960
+ calendarIds: z.array(z.string()).max(50).optional(),
961
+ fields: z.record(z.string().max(2000)).optional(),
962
+ 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(),
963
+ scheduledAt: nullableTs.optional(),
964
+ }),
965
+ handler: async ({ id, ...data }, ctx) => {
966
+ await requireAdmin(ctx);
967
+ const repo = await getRepo(ctx.locals);
968
+ const before = await repo.getEvent(id);
969
+ if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Event not found.' });
970
+ const clean = await cleanEventInput(repo, data, before);
971
+ // live events stage edits until Update, exactly like posts
972
+ const transitions = clean.status !== undefined && clean.status !== before.status;
973
+ if ((before.status === 'published' || before.status === 'scheduled') && !transitions) {
974
+ const { status: _s, scheduledAt: _sa, ...staged } = clean;
975
+ await repo.saveEventDraft(id, staged);
976
+ return { event: (await repo.getEvent(id))! };
977
+ }
978
+ await repo.updateEvent(id, clean);
979
+ if (clean.tags !== undefined) await repo.ensureTags(clean.tags);
980
+ const event = (await repo.getEvent(id))!;
981
+ await eventSideEffects(repo, ctx, before, event);
982
+ return { event };
983
+ },
984
+ }),
985
+ applyUpdate: defineAction({
986
+ input: z.object({ id: z.string() }),
987
+ handler: async ({ id }, ctx) => {
988
+ await requireAdmin(ctx);
989
+ const repo = await getRepo(ctx.locals);
990
+ const before = await repo.getEvent(id);
991
+ if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Event not found.' });
992
+ await repo.applyEventDraft(id);
993
+ const event = (await repo.getEvent(id))!;
994
+ if (event.tags.length) await repo.ensureTags(event.tags);
995
+ await eventSideEffects(repo, ctx, before, event, true);
996
+ return { event };
997
+ },
998
+ }),
999
+ discardChanges: defineAction({
1000
+ input: z.object({ id: z.string() }),
1001
+ handler: async ({ id }, ctx) => {
1002
+ await requireAdmin(ctx);
1003
+ const repo = await getRepo(ctx.locals);
1004
+ await repo.discardEventDraft(id);
1005
+ return { event: await repo.getEvent(id) };
1006
+ },
1007
+ }),
1008
+ delete: defineAction({
1009
+ input: z.object({ id: z.string() }),
1010
+ handler: async ({ id }, ctx) => {
1011
+ await requireAdmin(ctx);
1012
+ const repo = await getRepo(ctx.locals);
1013
+ const before = await repo.getEvent(id);
1014
+ await repo.deleteEvent(id);
1015
+ await eventDeleted(repo, before, { waitUntil: waitUntilFrom(ctx.locals) });
1016
+ return { ok: true };
1017
+ },
1018
+ }),
1019
+ // editor pickers (event card): every event with its next date
1020
+ list: defineAction({
1021
+ input: z.object({ publishedOnly: z.boolean().default(false) }),
1022
+ handler: async ({ publishedOnly }, ctx) => {
1023
+ await requireAdmin(ctx);
1024
+ const repo = await getRepo(ctx.locals);
1025
+ const [events, settings] = await Promise.all([publishedOnly ? repo.listPublishedEvents() : repo.listEvents(), repo.getSettings()]);
1026
+ const now = Date.now();
1027
+ return {
1028
+ events: events.map((e) => {
1029
+ const o = featuredOccurrence(e, settings, now);
1030
+ return {
1031
+ id: e.id, title: e.title || 'Untitled event', slug: e.slug, status: e.status, cover: e.cover,
1032
+ when: o ? formatOccurrence(o, settings.timezone || 'UTC', siteLocale(settings)).range : '',
1033
+ at: o?.start ?? e.startAt,
1034
+ };
1035
+ }).sort((a, b) => a.at - b.at),
1036
+ };
1037
+ },
1038
+ }),
1039
+ // one-time import: an ICS file/URL or a CSV becomes DRAFT events
1040
+ import: defineAction({
1041
+ input: z.object({
1042
+ format: z.enum(['ics', 'csv']),
1043
+ text: z.string().max(3_000_000).optional(),
1044
+ url: z.string().max(2000).optional(),
1045
+ calendarId: z.string().optional(),
1046
+ }),
1047
+ handler: async ({ format, text, url, calendarId }, ctx) => {
1048
+ await requireAdmin(ctx);
1049
+ const repo = await getRepo(ctx.locals);
1050
+ const settings = await repo.getSettings();
1051
+ if (calendarId && !(await repo.getCalendar(calendarId))) throw new ActionError({ code: 'BAD_REQUEST', message: 'Unknown calendar.' });
1052
+ let body = text ?? '';
1053
+ if (format === 'ics' && url) {
1054
+ const feedUrl = normalizeCalendarSrc(url);
1055
+ if (!feedUrl) throw new ActionError({ code: 'BAD_REQUEST', message: 'Paste a Google Calendar link, a calendar ID, or an .ics feed URL.' });
1056
+ try {
1057
+ const feed = await loadFeed(feedUrl, Date.now());
1058
+ return await importEvents(repo, eventsFromVEvents(feed.vevents, feed.calTz, settings.timezone), calendarId);
1059
+ } catch (err) {
1060
+ throw new ActionError({ code: 'BAD_GATEWAY', message: err instanceof Error ? err.message : 'Couldn’t load that feed.' });
1061
+ }
1062
+ }
1063
+ if (!body.trim()) throw new ActionError({ code: 'BAD_REQUEST', message: 'Choose a file or paste a feed URL.' });
1064
+ if (format === 'ics') {
1065
+ if (!body.includes('BEGIN:VCALENDAR')) throw new ActionError({ code: 'BAD_REQUEST', message: 'That file isn’t an .ics calendar.' });
1066
+ return importEvents(repo, eventsFromIcs(body, settings.timezone), calendarId);
1067
+ }
1068
+ const parsed = eventsFromCsv(body);
1069
+ if (parsed.error) throw new ActionError({ code: 'BAD_REQUEST', message: parsed.error });
1070
+ return importEvents(repo, parsed, calendarId);
1071
+ },
1072
+ }),
1073
+ },
1074
+
1075
+ calendars: {
1076
+ list: defineAction({
1077
+ input: z.object({}),
1078
+ handler: async (_input, ctx) => {
1079
+ await requireAdmin(ctx);
1080
+ const repo = await getRepo(ctx.locals);
1081
+ return { calendars: await repo.listCalendars() };
1082
+ },
1083
+ }),
1084
+ save: defineAction({
1085
+ input: z.object({
1086
+ id: z.string().optional(),
1087
+ name: z.string().trim().min(1).max(120),
1088
+ slug: z.string().optional(),
1089
+ color: z.string().optional(),
1090
+ description: z.string().max(1000).optional(),
1091
+ kind: z.enum(['native', 'feed']).optional(),
1092
+ feedUrl: z.string().max(2000).optional(),
1093
+ visibility: z.enum(['public', 'unlisted', 'private']).optional(),
1094
+ }),
1095
+ handler: async ({ id, ...data }, ctx) => {
1096
+ await requireAdmin(ctx);
1097
+ const repo = await getRepo(ctx.locals);
1098
+ const existing = id ? await repo.getCalendar(id) : null;
1099
+ if (id && !existing) throw new ActionError({ code: 'NOT_FOUND', message: 'Calendar not found.' });
1100
+ const kind = existing?.kind ?? data.kind ?? 'native';
1101
+ let feedUrl = existing?.feedUrl ?? '';
1102
+ if (kind === 'feed' && (data.feedUrl !== undefined || !existing)) {
1103
+ const normalized = normalizeCalendarSrc(data.feedUrl ?? '');
1104
+ if (!normalized) throw new ActionError({ code: 'BAD_REQUEST', message: 'Paste a Google Calendar link, a calendar ID, or an .ics feed URL.' });
1105
+ feedUrl = normalized;
1106
+ }
1107
+ const base = slugify(data.slug || data.name);
1108
+ let slug = base;
1109
+ for (let n = 2; ; n++) {
1110
+ const clash = await repo.getCalendarBySlug(slug);
1111
+ if (!clash || clash.id === id) break;
1112
+ slug = `${base}-${n}`;
1113
+ }
1114
+ const fields = {
1115
+ name: data.name, slug, color: safeColor(data.color ?? existing?.color ?? '#4f46e5'),
1116
+ description: data.description ?? existing?.description ?? '', feedUrl,
1117
+ visibility: data.visibility ?? existing?.visibility ?? 'public',
1118
+ };
1119
+ if (existing) {
1120
+ await repo.updateCalendar(existing.id, fields);
1121
+ return { calendar: (await repo.getCalendar(existing.id))! };
1122
+ }
1123
+ return { calendar: await repo.createCalendar({ ...fields, kind }) };
1124
+ },
1125
+ }),
1126
+ reorder: defineAction({
1127
+ input: z.object({ ids: z.array(z.string()) }),
1128
+ handler: async ({ ids }, ctx) => {
1129
+ await requireAdmin(ctx);
1130
+ await (await getRepo(ctx.locals)).reorderCalendars(ids);
1131
+ return { ok: true };
1132
+ },
1133
+ }),
1134
+ rotateToken: defineAction({
1135
+ input: z.object({ id: z.string() }),
1136
+ handler: async ({ id }, ctx) => {
1137
+ await requireAdmin(ctx);
1138
+ const repo = await getRepo(ctx.locals);
1139
+ const bytes = crypto.getRandomValues(new Uint8Array(18));
1140
+ const token = btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
1141
+ await repo.updateCalendar(id, { feedToken: token });
1142
+ return { calendar: await repo.getCalendar(id) };
1143
+ },
1144
+ }),
1145
+ delete: defineAction({
1146
+ input: z.object({ id: z.string() }),
1147
+ handler: async ({ id }, ctx) => {
1148
+ await requireAdmin(ctx);
1149
+ await (await getRepo(ctx.locals)).deleteCalendar(id);
1150
+ return { ok: true };
623
1151
  },
624
1152
  }),
625
1153
  },
@@ -628,7 +1156,7 @@ export const server = {
628
1156
  search: defineAction({
629
1157
  input: z.object({ query: z.string().min(1), page: z.number().int().min(1).default(1) }),
630
1158
  handler: async ({ query, page }, ctx) => {
631
- requireAdmin(ctx);
1159
+ await requireAdmin(ctx);
632
1160
  const key = env('UNSPLASH_ACCESS_KEY');
633
1161
  if (!key) throw new ActionError({ code: 'PRECONDITION_FAILED', message: 'Unsplash is not configured.' });
634
1162
  const res = await fetch(
@@ -669,7 +1197,7 @@ export const server = {
669
1197
  track: defineAction({
670
1198
  input: z.object({ downloadLocation: z.string().url() }),
671
1199
  handler: async ({ downloadLocation }, ctx) => {
672
- requireAdmin(ctx);
1200
+ await requireAdmin(ctx);
673
1201
  const key = env('UNSPLASH_ACCESS_KEY');
674
1202
  if (!key || !downloadLocation.startsWith('https://api.unsplash.com/')) return { ok: false };
675
1203
  await fetch(downloadLocation, { headers: { Authorization: `Client-ID ${key}` } }).catch(() => {});