@acms-subir/core 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/cli/common.mjs +35 -0
  3. package/cli/legacy.mjs +317 -0
  4. package/cli/main.mjs +66 -0
  5. package/cli/palette.mjs +4 -0
  6. package/cli/preview.mjs +50 -0
  7. package/cli/remote.mjs +55 -0
  8. package/cli/themes.mjs +127 -0
  9. package/cli.mjs +2 -315
  10. package/deploy/Caddyfile +8 -0
  11. package/deploy/Dockerfile +34 -0
  12. package/deploy/compose.https.yml +21 -0
  13. package/deploy/compose.yml +46 -0
  14. package/docs/theme-authoring.md +103 -0
  15. package/email-runner.mjs +32 -0
  16. package/integration.mjs +193 -2
  17. package/ops-check.mjs +27 -0
  18. package/package.json +35 -12
  19. package/src/actions/index.ts +744 -38
  20. package/src/components/AiCleaner.tsx +291 -0
  21. package/src/components/AiSettings.tsx +575 -0
  22. package/src/components/CommandPalette.tsx +200 -0
  23. package/src/components/ContentList.astro +246 -0
  24. package/src/components/DesignAccess.tsx +182 -0
  25. package/src/components/DesignManager.tsx +455 -47
  26. package/src/components/EmailSettings.tsx +521 -0
  27. package/src/components/EventPage.astro +178 -0
  28. package/src/components/LegalPage.astro +27 -0
  29. package/src/components/LinksManager.tsx +216 -97
  30. package/src/components/NavManager.tsx +83 -38
  31. package/src/components/ThemeScript.astro +50 -0
  32. package/src/components/ThemeSections.astro +57 -0
  33. package/src/components/ThemeSectionsEditor.tsx +513 -0
  34. package/src/components/booking/BookingFlow.astro +150 -0
  35. package/src/components/booking/HoursEditor.astro +63 -0
  36. package/src/components/builder/PageBuilder.tsx +10 -2
  37. package/src/components/editor/EventPanel.tsx +401 -0
  38. package/src/components/editor/FormatDock.tsx +81 -0
  39. package/src/components/editor/LivePane.tsx +122 -0
  40. package/src/components/editor/MobileToolbar.tsx +93 -0
  41. package/src/components/editor/PostEditor.tsx +826 -271
  42. package/src/components/editor/PreviewOverlay.tsx +242 -43
  43. package/src/components/editor/extensions/calendar.tsx +51 -7
  44. package/src/components/editor/extensions/cards.tsx +17 -1
  45. package/src/components/editor/extensions/codeBlock.tsx +1 -1
  46. package/src/components/editor/extensions/events.tsx +153 -0
  47. package/src/components/editor/extensions/infoCards.tsx +16 -1
  48. package/src/components/editor/extensions/mediaCards.tsx +36 -2
  49. package/src/components/editor/extensions/slash.tsx +77 -9
  50. package/src/components/editor/extensions/toc.tsx +89 -0
  51. package/src/components/editor/extensions/toggle.tsx +70 -0
  52. package/src/components/site/Pager.astro +24 -0
  53. package/src/components/site/PostCard.astro +64 -0
  54. package/src/components/site/TagLabel.astro +11 -0
  55. package/src/layouts/Admin.astro +289 -49
  56. package/src/layouts/Site.astro +331 -57
  57. package/src/lib/agent-api.ts +13 -0
  58. package/src/lib/agent-discovery.ts +40 -0
  59. package/src/lib/agent.ts +114 -0
  60. package/src/lib/ai-config.ts +58 -0
  61. package/src/lib/ai-schema.ts +22 -0
  62. package/src/lib/ai.ts +90 -0
  63. package/src/lib/analytics.ts +121 -0
  64. package/src/lib/api-keys.ts +65 -0
  65. package/src/lib/api.ts +52 -0
  66. package/src/lib/auth.ts +25 -5
  67. package/src/lib/backup/content.ts +36 -0
  68. package/src/lib/backup/unzip.ts +12 -1
  69. package/src/lib/backup/zip.ts +1 -1
  70. package/src/lib/booking/agent.ts +108 -0
  71. package/src/lib/booking/availability.ts +221 -0
  72. package/src/lib/booking/emails.ts +115 -0
  73. package/src/lib/booking/forms.ts +77 -0
  74. package/src/lib/booking/service.ts +277 -0
  75. package/src/lib/booking/view.ts +54 -0
  76. package/src/lib/calendar/agent-schema.ts +48 -0
  77. package/src/lib/calendar/agent.ts +136 -0
  78. package/src/lib/calendar/api.ts +81 -0
  79. package/src/lib/calendar/cards.ts +102 -0
  80. package/src/lib/calendar/config.ts +52 -0
  81. package/src/lib/calendar/data.ts +130 -0
  82. package/src/lib/calendar/events.ts +261 -0
  83. package/src/lib/calendar/feeds.ts +43 -0
  84. package/src/lib/calendar/hooks.ts +38 -0
  85. package/src/lib/calendar/http.ts +30 -0
  86. package/src/lib/calendar/ics-write.ts +120 -0
  87. package/src/lib/calendar/ics.ts +15 -5
  88. package/src/lib/calendar/import.ts +203 -0
  89. package/src/lib/calendar/render.ts +120 -37
  90. package/src/lib/calendar/rrule.ts +149 -0
  91. package/src/lib/calendar/seo.ts +48 -0
  92. package/src/lib/design-service.ts +58 -0
  93. package/src/lib/designDiff.ts +145 -0
  94. package/src/lib/designPreview.ts +112 -0
  95. package/src/lib/documents/cleanup.ts +31 -0
  96. package/src/lib/documents/extract.ts +154 -0
  97. package/src/lib/documents/vendor.d.ts +4 -0
  98. package/src/lib/drafts.ts +131 -0
  99. package/src/lib/dynamicBlocks.ts +79 -7
  100. package/src/lib/email/accounts.ts +55 -0
  101. package/src/lib/email/config.ts +86 -0
  102. package/src/lib/email/migration.ts +24 -0
  103. package/src/lib/email/providers.ts +89 -0
  104. package/src/lib/email/service.ts +78 -0
  105. package/src/lib/email/store.ts +167 -0
  106. package/src/lib/email/templates.ts +18 -0
  107. package/src/lib/email/types.ts +32 -0
  108. package/src/lib/email/webhooks.ts +87 -0
  109. package/src/lib/email.ts +15 -0
  110. package/src/lib/env.ts +1 -1
  111. package/src/lib/legal/index.ts +25 -0
  112. package/src/lib/mobile/bookings.ts +73 -0
  113. package/src/lib/mobile/calendar.ts +85 -0
  114. package/src/lib/mobile/content.ts +85 -0
  115. package/src/lib/mobile/http.ts +63 -0
  116. package/src/lib/mobile/route.ts +20 -0
  117. package/src/lib/ops.ts +8 -0
  118. package/src/lib/preview.ts +21 -1
  119. package/src/lib/repo/migrations.ts +254 -0
  120. package/src/lib/repo/sql.ts +956 -46
  121. package/src/lib/repo/types.ts +140 -3
  122. package/src/lib/scheduler.ts +25 -0
  123. package/src/lib/schema.ts +362 -0
  124. package/src/lib/search-i18n.ts +3 -0
  125. package/src/lib/socialIcons.ts +32 -0
  126. package/src/lib/tagColor.ts +11 -0
  127. package/src/lib/theme.ts +59 -40
  128. package/src/lib/themeTokens.ts +174 -0
  129. package/src/lib/umami.ts +108 -0
  130. package/src/lib/webhooks.ts +139 -0
  131. package/src/middleware.ts +36 -3
  132. package/src/pages/404.astro +23 -0
  133. package/src/pages/[...slug].astro +10 -5
  134. package/src/pages/account/[action].astro +53 -0
  135. package/src/pages/admin/ai.astro +12 -0
  136. package/src/pages/admin/c/[collection]/[id].astro +14 -4
  137. package/src/pages/admin/c/[collection]/index.astro +127 -91
  138. package/src/pages/admin/calendar/bookings.astro +400 -0
  139. package/src/pages/admin/calendar/calendars.astro +265 -0
  140. package/src/pages/admin/calendar/events/[id].astro +56 -0
  141. package/src/pages/admin/calendar/events/index.astro +408 -0
  142. package/src/pages/admin/calendar/index.ts +4 -0
  143. package/src/pages/admin/calendar/integrations.astro +221 -0
  144. package/src/pages/admin/calendar/resources/[id].astro +180 -0
  145. package/src/pages/admin/calendar/resources/index.astro +106 -0
  146. package/src/pages/admin/calendar/resources/link.astro +196 -0
  147. package/src/pages/admin/design.astro +16 -4
  148. package/src/pages/admin/email.astro +13 -0
  149. package/src/pages/admin/index.astro +237 -87
  150. package/src/pages/admin/links.astro +3 -3
  151. package/src/pages/admin/login.astro +14 -10
  152. package/src/pages/admin/navigation.astro +8 -2
  153. package/src/pages/admin/pages/[id].astro +14 -3
  154. package/src/pages/admin/pages/index.astro +41 -47
  155. package/src/pages/admin/settings.astro +475 -276
  156. package/src/pages/admin/tags.astro +4 -4
  157. package/src/pages/admin/users.astro +38 -28
  158. package/src/pages/api/agent/docs.ts +33 -0
  159. package/src/pages/api/agent/index.ts +21 -0
  160. package/src/pages/api/ai/clean.ts +13 -0
  161. package/src/pages/api/ai/draft.ts +14 -0
  162. package/src/pages/api/ai/settings.ts +53 -0
  163. package/src/pages/api/ai/test.ts +12 -0
  164. package/src/pages/api/backup.ts +8 -21
  165. package/src/pages/api/calendar/availability.ts +35 -0
  166. package/src/pages/api/calendar/bookings/[id].ts +49 -0
  167. package/src/pages/api/calendar/bookings/index.ts +73 -0
  168. package/src/pages/api/calendar/calendars.ts +35 -0
  169. package/src/pages/api/calendar/docs.ts +64 -0
  170. package/src/pages/api/calendar/events/[id].ts +63 -0
  171. package/src/pages/api/calendar/events/index.ts +112 -0
  172. package/src/pages/api/calendar/resources.ts +34 -0
  173. package/src/pages/api/design/capabilities.ts +10 -0
  174. package/src/pages/api/design/index.ts +58 -0
  175. package/src/pages/api/design/keys.ts +24 -0
  176. package/src/pages/api/design/media.ts +32 -0
  177. package/src/pages/api/email/run.ts +11 -0
  178. package/src/pages/api/email/settings.ts +61 -0
  179. package/src/pages/api/email/webhooks/[provider].ts +4 -0
  180. package/src/pages/api/mcp.ts +59 -0
  181. package/src/pages/api/media/upload.ts +2 -2
  182. package/src/pages/api/ops/status.ts +39 -0
  183. package/src/pages/api/restore.ts +27 -37
  184. package/src/pages/api/search.ts +18 -4
  185. package/src/pages/api/v1/availability.ts +7 -0
  186. package/src/pages/api/v1/bookings.ts +17 -0
  187. package/src/pages/api/v1/calendars.ts +7 -0
  188. package/src/pages/api/v1/content/[id].ts +7 -0
  189. package/src/pages/api/v1/content/index.ts +7 -0
  190. package/src/pages/api/v1/events.ts +7 -0
  191. package/src/pages/api/v1/resources.ts +7 -0
  192. package/src/pages/author/[slug].astro +43 -35
  193. package/src/pages/blog/[slug].astro +140 -66
  194. package/src/pages/blog/index.astro +95 -30
  195. package/src/pages/blog/tag/[slug].astro +45 -40
  196. package/src/pages/book/[resource]/[link].astro +70 -0
  197. package/src/pages/book/[resource]/index.astro +63 -0
  198. package/src/pages/book/[resource].ics.ts +38 -0
  199. package/src/pages/book/index.astro +47 -0
  200. package/src/pages/book/manage/[token].astro +108 -0
  201. package/src/pages/calendar/[slug].ics.ts +27 -0
  202. package/src/pages/calendar/feed.ics.ts +15 -0
  203. package/src/pages/calendar/index.astro +191 -0
  204. package/src/pages/embed/book/[resource]/[link].astro +33 -0
  205. package/src/pages/embed/calendar/[slug].astro +43 -0
  206. package/src/pages/events/[slug].astro +74 -0
  207. package/src/pages/events/[slug].ics.ts +25 -0
  208. package/src/pages/index.astro +9 -3
  209. package/src/pages/legal/privacy.astro +4 -0
  210. package/src/pages/legal/terms.astro +4 -0
  211. package/src/pages/links.astro +33 -7
  212. package/src/pages/llms.txt.ts +4 -0
  213. package/src/pages/preview/[type]/[id].astro +34 -8
  214. package/src/pages/search.astro +40 -20
  215. package/src/pages/sitemap.xml.ts +16 -1
  216. package/src/styles/admin.css +928 -105
  217. package/src/styles/cards.css +101 -60
  218. package/src/styles/editor.css +532 -102
  219. package/src/styles/email-settings.css +117 -0
  220. package/src/styles/sections.css +12 -0
  221. package/src/styles/site.css +938 -278
  222. package/src/styles/theme-editor.css +135 -0
  223. package/src/theme-sdk/contract.d.mts +619 -0
  224. package/src/theme-sdk/contract.mjs +80 -0
  225. package/src/theme-sdk/index.ts +15 -0
  226. package/src/theme-sdk/tokens.d.mts +148 -0
  227. package/src/theme-sdk/tokens.mjs +52 -0
  228. package/src/themes/editorial/templates/home.astro +4 -4
  229. package/src/themes/editorial/templates/post-minimal.astro +2 -2
  230. package/src/themes/editorial/theme.css +22 -37
  231. package/src/themes/editorial/theme.json +23 -3
  232. package/src/themes/field/theme.json +26 -9
  233. package/src/themes/ink/theme.json +30 -12
  234. package/src/themes/night/theme.css +4 -7
  235. package/src/themes/night/theme.json +27 -10
  236. package/src/virtual.d.ts +14 -0
  237. package/src/worker.ts +5 -1
@@ -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';
@@ -12,12 +15,32 @@ import { isPrivateHost } from '../lib/net';
12
15
  import { normalizeCalendarSrc } from '../lib/calendar/src';
13
16
  import { getCalendarEvents } from '../lib/calendar/ics';
14
17
  import { siteLocale } from '../lib/dates';
15
- import { normalizePageLocale } from '../lib/i18n';
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,14 +187,23 @@ 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]) {
72
194
  throw new ActionError({ code: 'BAD_REQUEST', message: `Unknown collection "${data.collection}".` });
73
195
  }
74
- const movesUrl = data.slug !== undefined || data.collection !== undefined;
75
- const before = movesUrl ? await repo.getPost(id) : null;
196
+ const before = await repo.getPost(id);
197
+ if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Post not found.' });
198
+ // A live (published/scheduled) post never takes content edits
199
+ // directly: they stage into the pending draft until the user hits
200
+ // Update. Status transitions stay deliberate, immediate writes.
201
+ const transitions = data.status !== undefined && data.status !== before.status;
202
+ if ((before.status === 'published' || before.status === 'scheduled') && !transitions) {
203
+ const { status: _status, scheduledAt: _scheduledAt, authorId: _authorId, ...staged } = data;
204
+ await repo.savePostDraft(id, staged);
205
+ return { post: (await repo.getPost(id))! };
206
+ }
76
207
  await repo.updatePost(id, data);
77
208
  // any new tag names become registry entries with slugs for archives
78
209
  if (data.tags !== undefined) await repo.ensureTags(data.tags);
@@ -80,7 +211,32 @@ export const server = {
80
211
  if (!post) throw new ActionError({ code: 'NOT_FOUND', message: 'Post not found.' });
81
212
  // a published post whose slug or collection changed leaves a 301
82
213
  // behind so old links keep working
83
- if (before && before.status === 'published' && (before.slug !== post.slug || before.collection !== post.collection)) {
214
+ if (before.status === 'published' && (before.slug !== post.slug || before.collection !== post.collection)) {
215
+ await repo
216
+ .createRedirect({
217
+ fromPath: collectionUrl(before.collection, before.slug),
218
+ toPath: collectionUrl(post.collection, post.slug),
219
+ })
220
+ .catch(() => {});
221
+ invalidateRedirectCache();
222
+ }
223
+ return { post };
224
+ },
225
+ }),
226
+ // Update button: copy the pending draft onto the live columns. The
227
+ // deferred side-effects (tag registry, slug-change 301) happen here —
228
+ // the moment the change actually goes live.
229
+ applyUpdate: defineAction({
230
+ input: z.object({ id: z.string() }),
231
+ handler: async ({ id }, ctx) => {
232
+ await requireAdmin(ctx);
233
+ const repo = await getRepo(ctx.locals);
234
+ const before = await repo.getPost(id);
235
+ if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Post not found.' });
236
+ await repo.applyPostDraft(id);
237
+ const post = (await repo.getPost(id))!;
238
+ if (post.tags.length) await repo.ensureTags(post.tags);
239
+ if (before.status === 'published' && (before.slug !== post.slug || before.collection !== post.collection)) {
84
240
  await repo
85
241
  .createRedirect({
86
242
  fromPath: collectionUrl(before.collection, before.slug),
@@ -92,10 +248,21 @@ export const server = {
92
248
  return { post };
93
249
  },
94
250
  }),
251
+ discardChanges: defineAction({
252
+ input: z.object({ id: z.string() }),
253
+ handler: async ({ id }, ctx) => {
254
+ await requireAdmin(ctx);
255
+ const repo = await getRepo(ctx.locals);
256
+ await repo.discardPostDraft(id);
257
+ const post = await repo.getPost(id);
258
+ if (!post) throw new ActionError({ code: 'NOT_FOUND', message: 'Post not found.' });
259
+ return { post };
260
+ },
261
+ }),
95
262
  delete: defineAction({
96
263
  input: z.object({ id: z.string() }),
97
264
  handler: async ({ id }, ctx) => {
98
- requireAdmin(ctx);
265
+ await requireAdmin(ctx);
99
266
  const repo = await getRepo(ctx.locals);
100
267
  await repo.deletePost(id);
101
268
  return { ok: true };
@@ -120,33 +287,68 @@ export const server = {
120
287
  blocks: z.array(BlockInput).optional(),
121
288
  }),
122
289
  handler: async ({ id, blocks, ...data }, ctx) => {
123
- requireAdmin(ctx);
290
+ await requireAdmin(ctx);
124
291
  const repo = await getRepo(ctx.locals);
125
292
  if (data.slug !== undefined) data.slug = slugify(data.slug);
126
293
  // default locale stores as '', unknown codes fall back to it
127
294
  if (data.locale !== undefined) data.locale = normalizePageLocale(data.locale);
128
- const before = data.slug !== undefined ? await repo.getPage(id) : null;
295
+ const before = await repo.getPage(id);
296
+ if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Page not found.' });
129
297
  const cleanBlocks =
130
298
  blocks === undefined
131
299
  ? undefined
132
300
  : blocks
133
301
  .filter((b) => widgetTypes.includes(b.type as WidgetType))
134
302
  .map((b) => ({ ...b, type: b.type as WidgetType, config: parseConfig(b.type as WidgetType, b.config) as Record<string, unknown> }));
303
+ // live pages stage their edits until Update, same as posts
304
+ const transitions = data.status !== undefined && data.status !== before.status;
305
+ if ((before.status === 'published' || before.status === 'scheduled') && !transitions) {
306
+ const { status: _status, ...staged } = data;
307
+ await repo.savePageDraft(id, { ...staged, blocks: cleanBlocks });
308
+ return { page: (await repo.getPage(id))! };
309
+ }
135
310
  await repo.updatePage(id, { ...data, blocks: cleanBlocks });
136
311
  const page = await repo.getPage(id);
137
312
  if (!page) throw new ActionError({ code: 'NOT_FOUND', message: 'Page not found.' });
138
313
  // renaming a published slug leaves a 301 behind so old links keep working
139
- if (before && before.status === 'published' && before.slug !== page.slug && !page.isHome) {
314
+ if (before.status === 'published' && before.slug !== page.slug && !page.isHome) {
315
+ await repo.createRedirect({ fromPath: `/${before.slug}`, toPath: `/${page.slug}` }).catch(() => {});
316
+ invalidateRedirectCache();
317
+ }
318
+ return { page };
319
+ },
320
+ }),
321
+ applyUpdate: defineAction({
322
+ input: z.object({ id: z.string() }),
323
+ handler: async ({ id }, ctx) => {
324
+ await requireAdmin(ctx);
325
+ const repo = await getRepo(ctx.locals);
326
+ const before = await repo.getPage(id);
327
+ if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Page not found.' });
328
+ await repo.applyPageDraft(id);
329
+ const page = (await repo.getPage(id))!;
330
+ if (before.status === 'published' && before.slug !== page.slug && !page.isHome) {
140
331
  await repo.createRedirect({ fromPath: `/${before.slug}`, toPath: `/${page.slug}` }).catch(() => {});
141
332
  invalidateRedirectCache();
142
333
  }
143
334
  return { page };
144
335
  },
145
336
  }),
337
+ discardChanges: defineAction({
338
+ input: z.object({ id: z.string() }),
339
+ handler: async ({ id }, ctx) => {
340
+ await requireAdmin(ctx);
341
+ const repo = await getRepo(ctx.locals);
342
+ await repo.discardPageDraft(id);
343
+ const page = await repo.getPage(id);
344
+ if (!page) throw new ActionError({ code: 'NOT_FOUND', message: 'Page not found.' });
345
+ return { page };
346
+ },
347
+ }),
146
348
  delete: defineAction({
147
349
  input: z.object({ id: z.string() }),
148
350
  handler: async ({ id }, ctx) => {
149
- requireAdmin(ctx);
351
+ await requireAdmin(ctx);
150
352
  const repo = await getRepo(ctx.locals);
151
353
  await repo.deletePage(id);
152
354
  return { ok: true };
@@ -158,7 +360,7 @@ export const server = {
158
360
  list: defineAction({
159
361
  input: z.object({}),
160
362
  handler: async (_input, ctx) => {
161
- requireAdmin(ctx);
363
+ await requireAdmin(ctx);
162
364
  const repo = await getRepo(ctx.locals);
163
365
  return { media: await repo.listMedia() };
164
366
  },
@@ -166,7 +368,7 @@ export const server = {
166
368
  updateAlt: defineAction({
167
369
  input: z.object({ id: z.string(), alt: z.string() }),
168
370
  handler: async ({ id, alt }, ctx) => {
169
- requireAdmin(ctx);
371
+ await requireAdmin(ctx);
170
372
  const repo = await getRepo(ctx.locals);
171
373
  await repo.updateMedia(id, { alt });
172
374
  return { ok: true };
@@ -175,7 +377,7 @@ export const server = {
175
377
  delete: defineAction({
176
378
  input: z.object({ id: z.string() }),
177
379
  handler: async ({ id }, ctx) => {
178
- requireAdmin(ctx);
380
+ await requireAdmin(ctx);
179
381
  const repo = await getRepo(ctx.locals);
180
382
  const media = await repo.getMedia(id);
181
383
  if (media) {
@@ -199,7 +401,7 @@ export const server = {
199
401
  meta: z.record(z.any()).default({}),
200
402
  }),
201
403
  handler: async (data, ctx) => {
202
- requireAdmin(ctx);
404
+ await requireAdmin(ctx);
203
405
  const repo = await getRepo(ctx.locals);
204
406
  return { link: await repo.createLink(data) };
205
407
  },
@@ -214,7 +416,7 @@ export const server = {
214
416
  meta: z.record(z.any()).optional(),
215
417
  }),
216
418
  handler: async ({ id, ...data }, ctx) => {
217
- requireAdmin(ctx);
419
+ await requireAdmin(ctx);
218
420
  const repo = await getRepo(ctx.locals);
219
421
  await repo.updateLink(id, data);
220
422
  return { ok: true };
@@ -223,7 +425,7 @@ export const server = {
223
425
  reorder: defineAction({
224
426
  input: z.object({ ids: z.array(z.string()) }),
225
427
  handler: async ({ ids }, ctx) => {
226
- requireAdmin(ctx);
428
+ await requireAdmin(ctx);
227
429
  const repo = await getRepo(ctx.locals);
228
430
  await repo.reorderLinks(ids);
229
431
  return { ok: true };
@@ -232,7 +434,7 @@ export const server = {
232
434
  delete: defineAction({
233
435
  input: z.object({ id: z.string() }),
234
436
  handler: async ({ id }, ctx) => {
235
- requireAdmin(ctx);
437
+ await requireAdmin(ctx);
236
438
  const repo = await getRepo(ctx.locals);
237
439
  await repo.deleteLink(id);
238
440
  return { ok: true };
@@ -241,6 +443,26 @@ export const server = {
241
443
  },
242
444
 
243
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
+ }),
244
466
  create: defineAction({
245
467
  input: z.object({
246
468
  label: z.string().default('New link'),
@@ -248,7 +470,7 @@ export const server = {
248
470
  area: z.enum(['header', 'footer', 'social']).default('header'),
249
471
  }),
250
472
  handler: async (data, ctx) => {
251
- requireAdmin(ctx);
473
+ await requireAdmin(ctx);
252
474
  const repo = await getRepo(ctx.locals);
253
475
  return { item: await repo.createNavItem(data) };
254
476
  },
@@ -261,7 +483,7 @@ export const server = {
261
483
  area: z.enum(['header', 'footer', 'social']).optional(),
262
484
  }),
263
485
  handler: async ({ id, ...data }, ctx) => {
264
- requireAdmin(ctx);
486
+ await requireAdmin(ctx);
265
487
  const repo = await getRepo(ctx.locals);
266
488
  await repo.updateNavItem(id, data);
267
489
  return { ok: true };
@@ -270,7 +492,7 @@ export const server = {
270
492
  reorder: defineAction({
271
493
  input: z.object({ ids: z.array(z.string()) }),
272
494
  handler: async ({ ids }, ctx) => {
273
- requireAdmin(ctx);
495
+ await requireAdmin(ctx);
274
496
  const repo = await getRepo(ctx.locals);
275
497
  await repo.reorderNavItems(ids);
276
498
  return { ok: true };
@@ -279,7 +501,7 @@ export const server = {
279
501
  delete: defineAction({
280
502
  input: z.object({ id: z.string() }),
281
503
  handler: async ({ id }, ctx) => {
282
- requireAdmin(ctx);
504
+ await requireAdmin(ctx);
283
505
  const repo = await getRepo(ctx.locals);
284
506
  await repo.deleteNavItem(id);
285
507
  return { ok: true };
@@ -303,36 +525,279 @@ export const server = {
303
525
  siteCover: z.string().optional(),
304
526
  }),
305
527
  handler: async (data, ctx) => {
306
- requireAdmin(ctx);
528
+ await requireAdmin(ctx);
307
529
  const repo = await getRepo(ctx.locals);
308
530
  await repo.updateSettings(data);
309
531
  return { ok: true };
310
532
  },
311
533
  }),
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.
537
+ saveDesignDraft: defineAction({
538
+ input: z.object({
539
+ activeTheme: z.string().optional(),
540
+ designOverrides: z.record(z.any()).optional(),
541
+ siteLogo: z.string().optional(),
542
+ siteFavicon: z.string().optional(),
543
+ siteCover: z.string().optional(),
544
+ }),
545
+ handler: async (data, ctx) => {
546
+ await requireAdmin(ctx);
547
+ const repo = await getRepo(ctx.locals);
548
+ await repo.saveDesignDraft(data);
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 };
572
+ },
573
+ }),
574
+ applyDesign: defineAction({
575
+ input: z.object({}),
576
+ handler: async (_input, 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.' });
579
+ const repo = await getRepo(ctx.locals);
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
+ }
591
+ return { ok: true };
592
+ },
593
+ }),
594
+ discardDesign: defineAction({
595
+ input: z.object({}),
596
+ handler: async (_input, ctx) => {
597
+ await requireAdmin(ctx);
598
+ const repo = await getRepo(ctx.locals);
599
+ await repo.discardDesignDraft();
600
+ return { ok: true };
601
+ },
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
+ }),
312
714
  },
313
715
 
314
716
  tags: {
315
717
  list: defineAction({
316
718
  input: z.object({}),
317
719
  handler: async (_input, ctx) => {
318
- requireAdmin(ctx);
720
+ await requireAdmin(ctx);
319
721
  const repo = await getRepo(ctx.locals);
320
722
  return { tags: await repo.listTags() };
321
723
  },
322
724
  }),
323
725
  },
324
726
 
727
+ snippets: {
728
+ list: defineAction({
729
+ input: z.object({}),
730
+ handler: async (_input, ctx) => {
731
+ await requireAdmin(ctx);
732
+ const repo = await getRepo(ctx.locals);
733
+ return { snippets: await repo.listSnippets() };
734
+ },
735
+ }),
736
+ create: defineAction({
737
+ input: z.object({ name: z.string().min(1).max(80), contentJson: z.string().min(2) }),
738
+ handler: async ({ name, contentJson }, ctx) => {
739
+ await requireAdmin(ctx);
740
+ // must be a JSON array of ProseMirror nodes — reject anything else
741
+ // before it can break the editor on re-insert
742
+ let parsed: unknown;
743
+ try { parsed = JSON.parse(contentJson); } catch { parsed = null; }
744
+ if (!Array.isArray(parsed) || !parsed.length) {
745
+ throw new ActionError({ code: 'BAD_REQUEST', message: 'Select some content first.' });
746
+ }
747
+ const repo = await getRepo(ctx.locals);
748
+ return { snippet: await repo.createSnippet({ name, contentJson }) };
749
+ },
750
+ }),
751
+ delete: defineAction({
752
+ input: z.object({ id: z.string() }),
753
+ handler: async ({ id }, ctx) => {
754
+ await requireAdmin(ctx);
755
+ const repo = await getRepo(ctx.locals);
756
+ await repo.deleteSnippet(id);
757
+ return { ok: true };
758
+ },
759
+ }),
760
+ },
761
+
325
762
  users: {
326
763
  list: defineAction({
327
764
  input: z.object({}),
328
765
  handler: async (_input, ctx) => {
329
- requireAdmin(ctx);
766
+ await requireAdmin(ctx);
330
767
  const repo = await getRepo(ctx.locals);
331
768
  return { users: await repo.listUsers() };
332
769
  },
333
770
  }),
334
771
  },
335
772
 
773
+ linkTargets: {
774
+ // ⌘K internal link search: published posts + pages matched by title or
775
+ // URL. Drafts are excluded on purpose — linking to one would 404 until
776
+ // it's published.
777
+ search: defineAction({
778
+ input: z.object({ query: z.string().min(1) }),
779
+ handler: async ({ query }, ctx) => {
780
+ await requireAdmin(ctx);
781
+ const repo = await getRepo(ctx.locals);
782
+ const q = query.toLowerCase();
783
+ const [posts, pages] = await Promise.all([repo.listPosts(), repo.listPages()]);
784
+ const targets = [
785
+ ...pages
786
+ .filter((p) => p.status === 'published')
787
+ .map((p) => ({ kind: 'Page', title: p.title, url: p.isHome ? localeHomeUrl(p.locale) : `/${p.slug}` })),
788
+ ...posts
789
+ .filter((p) => p.status === 'published')
790
+ .map((p) => ({
791
+ kind: (CONTENT.collections[p.collection] ?? CONTENT.posts).singular,
792
+ title: p.title,
793
+ url: collectionUrl(p.collection, p.slug),
794
+ })),
795
+ ].filter((t) => t.title.toLowerCase().includes(q) || t.url.toLowerCase().includes(q));
796
+ return { targets: targets.slice(0, 8) };
797
+ },
798
+ }),
799
+ },
800
+
336
801
  bookmark: {
337
802
  // Server-side link-metadata fetch for the bookmark card (no CORS in the
338
803
  // browser). Fetches the page, scrapes OG/meta tags, returns a compact
@@ -340,7 +805,7 @@ export const server = {
340
805
  fetch: defineAction({
341
806
  input: z.object({ url: z.string().url() }),
342
807
  handler: async ({ url }, ctx) => {
343
- requireAdmin(ctx);
808
+ await requireAdmin(ctx);
344
809
  // redirects are followed manually so EVERY hop is re-checked against
345
810
  // the private-host guard (a public URL 302ing to 127.0.0.1 or a cloud
346
811
  // metadata IP must not slip through)
@@ -408,9 +873,20 @@ export const server = {
408
873
  preview: defineAction({
409
874
  input: z.object({ sources: z.array(z.string()).min(1).max(6) }),
410
875
  handler: async ({ sources }, ctx) => {
411
- requireAdmin(ctx);
876
+ await requireAdmin(ctx);
412
877
  const normalized: string[] = [];
878
+ const repo0 = await getRepo(ctx.locals);
879
+ const siteCalendars = await repo0.listCalendars();
413
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
+ }
414
890
  const url = normalizeCalendarSrc(raw);
415
891
  if (!url) {
416
892
  throw new ActionError({
@@ -424,12 +900,18 @@ export const server = {
424
900
  const settings = await repo.getSettings();
425
901
  const tz = settings.timezone || 'UTC';
426
902
  const now = Date.now();
427
- const { events, errors } = await getCalendarEvents(normalized, {
428
- displayTz: tz, now,
429
- windowStartMs: now - 86_400_000,
430
- windowEndMs: now + 120 * 86_400_000,
431
- });
432
- 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] });
433
915
  const locale = siteLocale(settings);
434
916
  const dayFmt = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz });
435
917
  const timeFmt = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
@@ -441,7 +923,231 @@ export const server = {
441
923
  title: e.title,
442
924
  cal: e.cal,
443
925
  }));
444
- 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 };
445
1151
  },
446
1152
  }),
447
1153
  },
@@ -450,7 +1156,7 @@ export const server = {
450
1156
  search: defineAction({
451
1157
  input: z.object({ query: z.string().min(1), page: z.number().int().min(1).default(1) }),
452
1158
  handler: async ({ query, page }, ctx) => {
453
- requireAdmin(ctx);
1159
+ await requireAdmin(ctx);
454
1160
  const key = env('UNSPLASH_ACCESS_KEY');
455
1161
  if (!key) throw new ActionError({ code: 'PRECONDITION_FAILED', message: 'Unsplash is not configured.' });
456
1162
  const res = await fetch(
@@ -491,7 +1197,7 @@ export const server = {
491
1197
  track: defineAction({
492
1198
  input: z.object({ downloadLocation: z.string().url() }),
493
1199
  handler: async ({ downloadLocation }, ctx) => {
494
- requireAdmin(ctx);
1200
+ await requireAdmin(ctx);
495
1201
  const key = env('UNSPLASH_ACCESS_KEY');
496
1202
  if (!key || !downloadLocation.startsWith('https://api.unsplash.com/')) return { ok: false };
497
1203
  await fetch(downloadLocation, { headers: { Authorization: `Client-ID ${key}` } }).catch(() => {});