@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,4 +1,8 @@
1
- import type { Page, Post, Link, Media, NavItem, Redirect, Settings, Tag, User, Role, WidgetInstance } from '../schema';
1
+ import type { BookingRequest } from '../backup/content';
2
+ import type { EmailStore } from '../email/store';
3
+ export interface DesignVersion { id: string; createdAt: number; author: string; summary: string; snapshot: Record<string, unknown> }
4
+
5
+ import type { Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Role, WidgetInstance, Calendar, CalEvent, ApiKey, ApiScope, Webhook, WebhookDelivery, Resource, BookingLink, Booking, BookingStatus } from '../schema';
2
6
 
3
7
  export interface Stats {
4
8
  publishedPages: number;
@@ -16,13 +20,32 @@ export interface SearchHit<T> {
16
20
  export interface SearchResults {
17
21
  posts: SearchHit<Post>[];
18
22
  pages: SearchHit<Page>[];
23
+ // published events; callers still apply calendar visibility (a private
24
+ // calendar's event must not surface publicly)
25
+ events: SearchHit<CalEvent>[];
19
26
  tags: { tag: Tag; count: number }[];
20
27
  authors: User[];
21
28
  }
22
29
 
30
+ export type EventUpdate = Partial<
31
+ Pick<
32
+ CalEvent,
33
+ | 'title' | 'slug' | 'bodyJson' | 'bodyHtml' | 'excerpt' | 'cover' | 'coverAlt'
34
+ | 'seoTitle' | 'seoDesc' | 'status' | 'featured' | 'tags' | 'template'
35
+ | 'start' | 'end' | 'allDay' | 'timezone' | 'rrule' | 'exdates' | 'cancelled'
36
+ | 'location' | 'cta' | 'calendarIds' | 'fields' | 'sourceUid' | 'rsvp'
37
+ | 'publishedAt' | 'scheduledAt'
38
+ >
39
+ >;
40
+
23
41
  // The single contract both backends implement. The app only ever imports
24
42
  // this — never a concrete adapter.
25
43
  export interface ContentRepository {
44
+ readonly email: EmailStore;
45
+ // Bounded public catalog; fetch limit + 1 IDs without loading every body.
46
+ listPublicContent(options: { page: number; limit: number; kind?: 'post' | 'page'; collection?: string; locale?: string; collections: string[]; defaultLocale: string }): Promise<{ id: string; kind: 'post' | 'page' }[]>;
47
+ exportBookingState(): Promise<{ bookings: Booking[]; bookingRequests: BookingRequest[] }>;
48
+ getBookingRequest(keyHash: string): Promise<{ requestHash: string; bookingId: string } | null>;
26
49
  // pages
27
50
  listPages(): Promise<Page[]>;
28
51
  getPage(id: string): Promise<Page | null>;
@@ -36,6 +59,12 @@ export interface ContentRepository {
36
59
  blocks?: WidgetInstance[];
37
60
  }
38
61
  ): Promise<void>;
62
+ // pending edits on a live page: save stages into the `draft` JSON column
63
+ // (live columns untouched), apply copies staged → live and clears, discard
64
+ // just clears. See lib/drafts.ts for the snapshot shape.
65
+ savePageDraft(id: string, data: Omit<Parameters<ContentRepository['updatePage']>[1], 'status'>): Promise<void>;
66
+ applyPageDraft(id: string): Promise<void>;
67
+ discardPageDraft(id: string): Promise<void>;
39
68
  deletePage(id: string): Promise<void>;
40
69
 
41
70
  // posts
@@ -60,10 +89,91 @@ export interface ContentRepository {
60
89
  >
61
90
  >
62
91
  ): Promise<void>;
92
+ // pending edits on a live post — same contract as the page variants above
93
+ savePostDraft(id: string, data: Omit<Parameters<ContentRepository['updatePost']>[1], 'status' | 'scheduledAt'>): Promise<void>;
94
+ applyPostDraft(id: string): Promise<void>;
95
+ discardPostDraft(id: string): Promise<void>;
63
96
  deletePost(id: string): Promise<void>;
64
- // flips scheduled posts whose time has passed to published; returns count
97
+ // flips scheduled posts AND events whose time has passed to published;
98
+ // returns the combined count
65
99
  publishDuePosts(now: number): Promise<number>;
66
100
 
101
+ // calendars — native calendars group events; feed calendars mirror an ICS
102
+ // URL live (their events are never stored). Deleting a calendar removes it
103
+ // from every event's calendarIds; the events themselves stay.
104
+ listCalendars(): Promise<Calendar[]>;
105
+ getCalendar(id: string): Promise<Calendar | null>;
106
+ getCalendarBySlug(slug: string): Promise<Calendar | null>;
107
+ createCalendar(data: Partial<Pick<Calendar, 'color' | 'description' | 'kind' | 'feedUrl' | 'visibility'>> & { name: string; slug: string }): Promise<Calendar>;
108
+ updateCalendar(id: string, data: Partial<Pick<Calendar, 'name' | 'slug' | 'color' | 'description' | 'feedUrl' | 'visibility' | 'feedToken'>>): Promise<void>;
109
+ reorderCalendars(ids: string[]): Promise<void>;
110
+ deleteCalendar(id: string): Promise<void>;
111
+
112
+ // events — same draft/publish contract as posts (live events stage edits
113
+ // until Update). startAt/endAt are derived from the wall-clock fields and
114
+ // the event (or site) timezone on every write.
115
+ listEvents(): Promise<CalEvent[]>;
116
+ listPublishedEvents(): Promise<CalEvent[]>;
117
+ getEvent(id: string): Promise<CalEvent | null>;
118
+ getEventBySlug(slug: string): Promise<CalEvent | null>;
119
+ createEvent(data: Partial<Omit<CalEvent, 'id' | 'draft' | 'startAt' | 'endAt' | 'createdAt' | 'updatedAt'>> & { title: string; slug: string; start: string }): Promise<CalEvent>;
120
+ updateEvent(id: string, data: EventUpdate): Promise<void>;
121
+ saveEventDraft(id: string, data: Omit<EventUpdate, 'status' | 'scheduledAt' | 'publishedAt'>): Promise<void>;
122
+ applyEventDraft(id: string): Promise<void>;
123
+ discardEventDraft(id: string): Promise<void>;
124
+ deleteEvent(id: string): Promise<void>;
125
+
126
+ // resources (rooms, people, vans…) and their booking links. Deleting a
127
+ // resource deletes its links; its bookings stay as history.
128
+ listResources(): Promise<Resource[]>;
129
+ getResource(id: string): Promise<Resource | null>;
130
+ getResourceBySlug(slug: string): Promise<Resource | null>;
131
+ createResource(data: Partial<Omit<Resource, 'id' | 'createdAt' | 'updatedAt' | 'order' | 'feedToken'>> & { name: string; slug: string }): Promise<Resource>;
132
+ updateResource(id: string, data: Partial<Omit<Resource, 'id' | 'createdAt' | 'updatedAt' | 'order'>>): Promise<void>;
133
+ reorderResources(ids: string[]): Promise<void>;
134
+ deleteResource(id: string): Promise<void>;
135
+ listBookingLinks(resourceId?: string): Promise<BookingLink[]>;
136
+ getBookingLink(id: string): Promise<BookingLink | null>;
137
+ getBookingLinkBySlug(resourceId: string, slug: string): Promise<BookingLink | null>;
138
+ createBookingLink(data: Partial<Omit<BookingLink, 'id' | 'createdAt' | 'updatedAt' | 'order'>> & { resourceId: string; name: string; slug: string }): Promise<BookingLink>;
139
+ updateBookingLink(id: string, data: Partial<Omit<BookingLink, 'id' | 'resourceId' | 'createdAt' | 'updatedAt'>>): Promise<void>;
140
+ deleteBookingLink(id: string): Promise<void>;
141
+
142
+ // bookings (+ staff blocks and event RSVPs). createBooking is the only
143
+ // write that can fail on a race: it inserts atomically only if the held
144
+ // range is still free (resource bookings) or seats remain (RSVPs), and
145
+ // returns null when it lost.
146
+ listBookings(filter?: { resourceId?: string; eventId?: string; from?: number; to?: number; statuses?: BookingStatus[]; kinds?: Booking['kind'][] }): Promise<Booking[]>;
147
+ getBooking(id: string): Promise<Booking | null>;
148
+ getBookingByToken(token: string): Promise<Booking | null>;
149
+ createBooking(
150
+ data: Omit<Booking, 'id' | 'createdAt' | 'updatedAt' | 'reminderSentAt' | 'notificationVersion'>,
151
+ guard?: { capacity?: number; idempotency?: { keyHash: string; requestHash: string } }
152
+ ): Promise<Booking | null>;
153
+ // moves a booking only if the new held range is free (reschedule)
154
+ moveBooking(id: string, times: { start: number; end: number; holdStart: number; holdEnd: number }): Promise<boolean>;
155
+ updateBooking(id: string, data: Partial<Pick<Booking, 'status' | 'notes' | 'name' | 'email' | 'phone' | 'answers' | 'reminderSentAt'>> & { notificationMessage?: string }): Promise<void>;
156
+ deleteBooking(id: string): Promise<void>;
157
+ // confirmed bookings starting in [from, to) that haven't had a reminder
158
+ listDueReminders(from: number, to: number): Promise<Booking[]>;
159
+
160
+ // scoped API keys — only the SHA-256 hash is stored; findApiKeyByHash
161
+ // also stamps lastUsedAt
162
+ listApiKeys(): Promise<ApiKey[]>;
163
+ createApiKey(data: { name: string; prefix: string; keyHash: string; scopes: ApiScope[] }): Promise<ApiKey>;
164
+ findApiKeyByHash(keyHash: string): Promise<ApiKey | null>;
165
+ deleteApiKey(id: string): Promise<void>;
166
+
167
+ // webhooks + their delivery queue (lib/webhooks.ts drives both)
168
+ listWebhooks(): Promise<Webhook[]>;
169
+ createWebhook(data: { url: string; secret: string; events: string[] }): Promise<Webhook>;
170
+ updateWebhook(id: string, data: Partial<Pick<Webhook, 'url' | 'events' | 'active' | 'secret'>>): Promise<void>;
171
+ deleteWebhook(id: string): Promise<void>;
172
+ createDeliveries(rows: Omit<WebhookDelivery, 'attempts' | 'responseCode' | 'deliveredAt'>[]): Promise<void>;
173
+ listDueDeliveries(now: number, limit: number): Promise<WebhookDelivery[]>;
174
+ listDeliveries(webhookId: string, limit: number): Promise<WebhookDelivery[]>;
175
+ recordDelivery(id: string, data: Pick<WebhookDelivery, 'status' | 'attempts' | 'nextAt' | 'responseCode' | 'deliveredAt'>): Promise<void>;
176
+
67
177
  // media
68
178
  listMedia(): Promise<Media[]>;
69
179
  getMedia(id: string): Promise<Media | null>;
@@ -98,13 +208,18 @@ export interface ContentRepository {
98
208
  // auto-register any names missing from the registry (called on post save)
99
209
  ensureTags(names: string[]): Promise<void>;
100
210
 
211
+ // snippets — named, reusable editor-content fragments
212
+ listSnippets(): Promise<Snippet[]>;
213
+ createSnippet(data: { name: string; contentJson: string }): Promise<Snippet>;
214
+ deleteSnippet(id: string): Promise<void>;
215
+
101
216
  // users — passHash stays inside the repo; getUserAuth is for login only
102
217
  listUsers(): Promise<User[]>;
103
218
  getUser(id: string): Promise<User | null>;
104
219
  createUser(data: { name: string; email: string; role: Role; passHash: string }): Promise<User>;
105
220
  updateUser(id: string, data: { name?: string; email?: string; role?: Role; passHash?: string; bio?: string; avatar?: string; slug?: string }): Promise<void>;
106
221
  deleteUser(id: string): Promise<void>;
107
- getUserAuth(email: string): Promise<{ user: User; passHash: string } | null>;
222
+ getUserAuth(email: string): Promise<{ user: User; passHash: string; sessionVersion: number } | null>;
108
223
 
109
224
  // navigation — managed header/footer/social items; empty header falls back
110
225
  // to the legacy auto-list of published pages in Site.astro
@@ -133,6 +248,13 @@ export interface ContentRepository {
133
248
  navItems: NavItem[];
134
249
  users: User[];
135
250
  redirects: Redirect[];
251
+ // added with the calendar module; older backups restore with none
252
+ calendars?: Calendar[];
253
+ events?: CalEvent[];
254
+ resources?: Resource[];
255
+ bookingLinks?: BookingLink[];
256
+ bookings?: Booking[];
257
+ bookingRequests?: BookingRequest[];
136
258
  }): Promise<void>;
137
259
 
138
260
  // redirects — exact-path forwards, consulted when a request would 404.
@@ -144,5 +266,20 @@ export interface ContentRepository {
144
266
  // settings & dashboard
145
267
  getSettings(): Promise<Settings>;
146
268
  updateSettings(data: Partial<Settings>): Promise<void>;
269
+ // Design-screen staging: save stages into settings.draftDesign, apply
270
+ // copies staged → live design fields and clears, discard just clears
271
+ saveDesignDraft(data: Partial<Pick<Settings, 'activeTheme' | 'designOverrides' | 'siteLogo' | 'siteFavicon' | 'siteCover' | 'sectionPages'>> & {
272
+ navItems?: { id: string; label: string; url: string; area: NavItem['area'] }[];
273
+ shareEpoch?: string;
274
+ }): Promise<void>;
275
+ // replace the whole staged draft (null clears it) — per-change undo, restore
276
+ setDesignDraft(draft: Record<string, unknown> | null, expectedRevision?: number, actor?: string): Promise<void>;
277
+ listDesignAudit(): Promise<Record<string, unknown>[]>;
278
+ // publish: snapshot the outgoing live design into design_versions (last 20
279
+ // kept), apply the draft's settings + staged navigation, clear the draft
280
+ applyDesignDraft(opts?: { author?: string; summary?: string; expectedRevision?: number }): Promise<void>;
281
+ discardDesignDraft(): Promise<void>;
282
+ listDesignVersions(limit?: number): Promise<DesignVersion[]>;
283
+ getDesignVersion(id: string): Promise<DesignVersion | null>;
147
284
  getStats(): Promise<Stats>;
148
285
  }
@@ -0,0 +1,25 @@
1
+ import type { ContentRepository } from './repo/types';
2
+ import { processDueDeliveries } from './webhooks';
3
+ import { dispatchEmail } from './email/service';
4
+ import { sendDueReminders } from './booking/service';
5
+
6
+ // All work is awaited on both hosts. Traffic-triggered fallbacks do not mark
7
+ // this heartbeat: monitoring must prove the independent scheduler ran.
8
+ export async function runScheduledTasks(repo: ContentRepository, locals?: unknown) {
9
+ const startedAt = Date.now();
10
+ const settings = await repo.getSettings();
11
+ const jobs = {
12
+ publish: () => repo.publishDuePosts(startedAt),
13
+ webhooks: () => processDueDeliveries(repo),
14
+ reminders: () => sendDueReminders(repo, { origin: settings.siteUrl, locals, now: startedAt }),
15
+ email: () => dispatchEmail(repo, locals, 10),
16
+ };
17
+ const results: Record<string, { ok: boolean; count?: number }> = {};
18
+ for (const [name, run] of Object.entries(jobs)) {
19
+ try { results[name] = { ok: true, count: await run() }; }
20
+ catch { results[name] = { ok: false }; }
21
+ }
22
+ const report = { startedAt, finishedAt: Date.now(), ok: Object.values(results).every(r => r.ok), jobs: results };
23
+ await repo.email.recordScheduler(report);
24
+ return report;
25
+ }
package/src/lib/schema.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { SectionPages } from '../theme-sdk/contract.mjs';
2
+ import { AiConfig, AgentConfig } from './ai-schema';
1
3
  import { z } from 'zod';
2
4
 
3
5
  // ---- Widgets -------------------------------------------------------------
@@ -78,6 +80,8 @@ export const Page = z.object({
78
80
  seoDesc: z.string().default(''),
79
81
  // theme template name (templates/page-<name>.astro); empty = default layout
80
82
  template: z.string().default(''),
83
+ // pending edits on a live page (see lib/drafts.ts); null = none staged
84
+ draft: z.record(z.any()).nullable().default(null),
81
85
  createdAt: z.number(),
82
86
  updatedAt: z.number(),
83
87
  });
@@ -110,6 +114,8 @@ export const Post = z.object({
110
114
  authorIds: z.array(z.string()).default([]),
111
115
  // theme template name (templates/post-<name>.astro); empty = default layout
112
116
  template: z.string().default(''),
117
+ // pending edits on a live post (see lib/drafts.ts); null = none staged
118
+ draft: z.record(z.any()).nullable().default(null),
113
119
  publishedAt: z.number().nullable(),
114
120
  scheduledAt: z.number().nullable().default(null),
115
121
  createdAt: z.number(),
@@ -152,6 +158,17 @@ export const Tag = z.object({
152
158
  });
153
159
  export type Tag = z.infer<typeof Tag>;
154
160
 
161
+ // Snippets: named, reusable fragments of editor content, saved from a
162
+ // selection and re-inserted from the "/" menu. contentJson is a JSON array of
163
+ // ProseMirror nodes.
164
+ export const Snippet = z.object({
165
+ id: z.string(),
166
+ name: z.string(),
167
+ contentJson: z.string(),
168
+ createdAt: z.number(),
169
+ });
170
+ export type Snippet = z.infer<typeof Snippet>;
171
+
155
172
  export const Role = z.enum(['owner', 'editor']);
156
173
  export type Role = z.infer<typeof Role>;
157
174
 
@@ -221,6 +238,309 @@ export const Redirect = z.object({
221
238
  });
222
239
  export type Redirect = z.infer<typeof Redirect>;
223
240
 
241
+ // ---- Calendar ------------------------------------------------------------
242
+ // Calendars group events. `native` calendars hold events authored in the
243
+ // admin; `feed` calendars mirror an external ICS feed (Google, Apple,
244
+ // Outlook, any .ics) live, read-only. Visibility decides where a calendar's
245
+ // events surface: `public` = listed on the calendar page + the all-calendars
246
+ // feed; `unlisted` = event pages and its own feed work, but it's not listed;
247
+ // `private` = admin-only, plus a secret-token feed for staff subscriptions.
248
+ export const CalendarKind = z.enum(['native', 'feed']);
249
+ export type CalendarKind = z.infer<typeof CalendarKind>;
250
+ export const CalendarVisibility = z.enum(['public', 'unlisted', 'private']);
251
+ export type CalendarVisibility = z.infer<typeof CalendarVisibility>;
252
+
253
+ export const Calendar = z.object({
254
+ id: z.string(),
255
+ slug: z.string(),
256
+ name: z.string(),
257
+ color: z.string().default('#4f46e5'),
258
+ description: z.string().default(''),
259
+ kind: CalendarKind.default('native'),
260
+ // feed calendars only: the normalized ICS URL
261
+ feedUrl: z.string().default(''),
262
+ visibility: CalendarVisibility.default('public'),
263
+ // secret for /calendar/<slug>.ics?t=… on private calendars (rotatable)
264
+ feedToken: z.string().default(''),
265
+ order: z.number().default(0),
266
+ createdAt: z.number(),
267
+ updatedAt: z.number(),
268
+ });
269
+ export type Calendar = z.infer<typeof Calendar>;
270
+
271
+ // Times are stored as WALL-CLOCK strings in the event's timezone — that's
272
+ // what recurrence is defined against (a 10am weekly service stays at 10am
273
+ // across DST). Timed: 'YYYY-MM-DDTHH:mm'. All-day: 'YYYY-MM-DD', with `end`
274
+ // the INCLUSIVE last day (how people think about it; ICS output converts to
275
+ // the exclusive form). `timezone` '' = the site timezone. startAt/endAt are
276
+ // the derived UTC instants of the first occurrence, kept for sorting.
277
+ export const EventLocation = z.object({
278
+ name: z.string().default(''),
279
+ address: z.string().default(''),
280
+ // a map link or an online-meeting URL
281
+ url: z.string().default(''),
282
+ });
283
+ export type EventLocation = z.infer<typeof EventLocation>;
284
+
285
+ export const EventCta = z.object({
286
+ label: z.string().default(''),
287
+ href: z.string().default(''),
288
+ style: z.enum(['primary', 'outline']).default('primary'),
289
+ });
290
+ export type EventCta = z.infer<typeof EventCta>;
291
+
292
+ export const CalEvent = z.object({
293
+ id: z.string(),
294
+ slug: z.string(),
295
+ title: z.string(),
296
+ bodyJson: z.string().default(''),
297
+ bodyHtml: z.string().default(''),
298
+ excerpt: z.string().default(''),
299
+ cover: z.string().default(''),
300
+ coverAlt: z.string().default(''),
301
+ seoTitle: z.string().default(''),
302
+ seoDesc: z.string().default(''),
303
+ status: Status,
304
+ featured: z.boolean().default(false),
305
+ tags: z.array(z.string()).default([]),
306
+ template: z.string().default(''),
307
+ start: z.string(),
308
+ end: z.string().default(''),
309
+ allDay: z.boolean().default(false),
310
+ timezone: z.string().default(''),
311
+ // RFC 5545 RRULE body without the "RRULE:" prefix; '' = one-off
312
+ rrule: z.string().default(''),
313
+ // wall-clock starts (same format as `start`) of skipped occurrences
314
+ exdates: z.array(z.string()).default([]),
315
+ // a cancelled event stays visible (struck through) so people aren't left
316
+ // wondering where it went; feeds emit STATUS:CANCELLED
317
+ cancelled: z.boolean().default(false),
318
+ location: EventLocation.default({}),
319
+ cta: EventCta.default({}),
320
+ calendarIds: z.array(z.string()).default([]),
321
+ // values for acms({ calendar.eventFields }) custom fields, keyed by field key
322
+ fields: z.record(z.string()).default({}),
323
+ // UID of the feed event this was imported from ('' = authored here)
324
+ sourceUid: z.string().default(''),
325
+ // RSVP: seats per occurrence (0 = unlimited) and whether RSVPs confirm at
326
+ // once; RSVPs are stored as bookings of kind 'rsvp'
327
+ rsvp: z.object({
328
+ enabled: z.boolean().default(false),
329
+ capacity: z.number().int().default(0),
330
+ autoConfirm: z.boolean().default(true),
331
+ maxSeats: z.number().int().default(4),
332
+ }).default({}),
333
+ startAt: z.number(),
334
+ endAt: z.number(),
335
+ draft: z.record(z.any()).nullable().default(null),
336
+ publishedAt: z.number().nullable().default(null),
337
+ scheduledAt: z.number().nullable().default(null),
338
+ createdAt: z.number(),
339
+ updatedAt: z.number(),
340
+ });
341
+ export type CalEvent = z.infer<typeof CalEvent>;
342
+
343
+ // ---- Bookings --------------------------------------------------------------
344
+ // A Resource is anything people reserve: a room, a person's time, a van. Its
345
+ // weekly hours + date overrides are the outer bound; each BookingLink narrows
346
+ // them and adds its own rules (length, buffers, notice, questions, whether it
347
+ // confirms automatically). Every link of a resource subtracts the same
348
+ // Bookings, so links can never double-book the resource. Times: weekly hours
349
+ // and overrides are wall-clock in the resource's zone; bookings store UTC.
350
+
351
+ export const TimeRange = z.object({
352
+ start: z.string().regex(/^\d{2}:\d{2}$/),
353
+ end: z.string().regex(/^\d{2}:\d{2}$/),
354
+ });
355
+ export type TimeRange = z.infer<typeof TimeRange>;
356
+
357
+ export const WEEKDAYS = ['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa'] as const;
358
+ export type Weekday = (typeof WEEKDAYS)[number];
359
+ export const WeeklyHours = z.object({
360
+ su: z.array(TimeRange).default([]),
361
+ mo: z.array(TimeRange).default([]),
362
+ tu: z.array(TimeRange).default([]),
363
+ we: z.array(TimeRange).default([]),
364
+ th: z.array(TimeRange).default([]),
365
+ fr: z.array(TimeRange).default([]),
366
+ sa: z.array(TimeRange).default([]),
367
+ });
368
+ export type WeeklyHours = z.infer<typeof WeeklyHours>;
369
+
370
+ // a date (or inclusive date span) with replacement hours; [] = closed
371
+ export const DateOverride = z.object({
372
+ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
373
+ endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).default(''),
374
+ ranges: z.array(TimeRange).default([]),
375
+ label: z.string().default(''),
376
+ });
377
+ export type DateOverride = z.infer<typeof DateOverride>;
378
+
379
+ export const Resource = z.object({
380
+ id: z.string(),
381
+ slug: z.string(),
382
+ name: z.string(),
383
+ // a free label: Room, Person, Vehicle, Equipment… (acms({ calendar.resourceKinds }) suggests values)
384
+ kind: z.string().default('Room'),
385
+ description: z.string().default(''),
386
+ photo: z.string().default(''),
387
+ capacity: z.number().int().nullable().default(null),
388
+ amenities: z.array(z.string()).default([]),
389
+ timezone: z.string().default(''),
390
+ hours: WeeklyHours.default({}),
391
+ overrides: z.array(DateOverride).default([]),
392
+ // external ICS feeds whose events block time (a person's Google calendar)
393
+ busyFeeds: z.array(z.string()).default([]),
394
+ notifyEmails: z.array(z.string()).default([]),
395
+ // public = listed in /book; unlisted = reachable by link only
396
+ visibility: z.enum(['public', 'unlisted']).default('public'),
397
+ // confirmed bookings show on the public calendar as "Reserved" blocks
398
+ showOnCalendar: z.boolean().default(false),
399
+ // secret for the staff feed /book/<slug>.ics?t=… (bookings with names)
400
+ feedToken: z.string().default(''),
401
+ order: z.number().default(0),
402
+ createdAt: z.number(),
403
+ updatedAt: z.number(),
404
+ });
405
+ export type Resource = z.infer<typeof Resource>;
406
+
407
+ export const QUESTION_TYPES = ['text', 'textarea', 'email', 'phone', 'number', 'select', 'checkbox', 'date'] as const;
408
+ export const BookingQuestion = z.object({
409
+ id: z.string(),
410
+ label: z.string(),
411
+ type: z.enum(QUESTION_TYPES).default('text'),
412
+ required: z.boolean().default(false),
413
+ options: z.array(z.string()).default([]),
414
+ help: z.string().default(''),
415
+ });
416
+ export type BookingQuestion = z.infer<typeof BookingQuestion>;
417
+
418
+ export const BookingMode = z.enum(['slots', 'range', 'day']);
419
+ export type BookingMode = z.infer<typeof BookingMode>;
420
+
421
+ export const BookingLink = z.object({
422
+ id: z.string(),
423
+ resourceId: z.string(),
424
+ slug: z.string(),
425
+ name: z.string(),
426
+ description: z.string().default(''),
427
+ // slots: fixed length; range: choose a length from `lengths`; day: whole days
428
+ mode: BookingMode.default('slots'),
429
+ duration: z.number().int().default(60), // minutes (slots)
430
+ lengths: z.array(z.number().int()).default([]), // minutes (range) — chips
431
+ minDays: z.number().int().default(1), // day mode
432
+ maxDays: z.number().int().default(1),
433
+ step: z.number().int().default(30), // minutes between start times
434
+ bufferBefore: z.number().int().default(0),
435
+ bufferAfter: z.number().int().default(0),
436
+ minNotice: z.number().int().default(120), // minutes
437
+ maxAdvance: z.number().int().default(90), // days
438
+ perDayCap: z.number().int().default(0), // 0 = unlimited
439
+ // narrows the resource's hours; null = use the resource's hours as-is
440
+ hours: WeeklyHours.nullable().default(null),
441
+ // confirmed at once (and on the calendar) vs. held as pending for approval
442
+ autoConfirm: z.boolean().default(true),
443
+ visibility: z.enum(['public', 'unlisted']).default('public'),
444
+ askPhone: z.boolean().default(false),
445
+ questions: z.array(BookingQuestion).default([]),
446
+ confirmMessage: z.string().default(''),
447
+ redirectUrl: z.string().default(''),
448
+ active: z.boolean().default(true),
449
+ order: z.number().default(0),
450
+ createdAt: z.number(),
451
+ updatedAt: z.number(),
452
+ });
453
+ export type BookingLink = z.infer<typeof BookingLink>;
454
+
455
+ export const BookingStatus = z.enum(['pending', 'confirmed', 'declined', 'cancelled']);
456
+ export type BookingStatus = z.infer<typeof BookingStatus>;
457
+
458
+ export const Booking = z.object({
459
+ id: z.string(),
460
+ // booking = someone reserved it; block = staff closed the time; rsvp = a
461
+ // seat at an event occurrence (resourceId/linkId empty, eventId set)
462
+ kind: z.enum(['booking', 'block', 'rsvp']).default('booking'),
463
+ resourceId: z.string().default(''),
464
+ linkId: z.string().default(''),
465
+ eventId: z.string().default(''),
466
+ status: BookingStatus,
467
+ start: z.number(),
468
+ end: z.number(),
469
+ // start/end widened by the link's buffers — what actually blocks the resource
470
+ holdStart: z.number(),
471
+ holdEnd: z.number(),
472
+ timezone: z.string().default(''),
473
+ name: z.string().default(''),
474
+ email: z.string().default(''),
475
+ phone: z.string().default(''),
476
+ seats: z.number().int().default(1),
477
+ answers: z.record(z.string()).default({}),
478
+ notes: z.string().default(''), // staff-only
479
+ manageToken: z.string().default(''),
480
+ source: z.enum(['web', 'admin', 'api', 'agent']).default('web'),
481
+ reminderSentAt: z.number().nullable().default(null),
482
+ notificationVersion: z.number().default(0),
483
+ createdAt: z.number(),
484
+ updatedAt: z.number(),
485
+ });
486
+ export type Booking = z.infer<typeof Booking>;
487
+
488
+ // ---- Integrations: scoped API keys + signed webhooks ----------------------
489
+ // Keys are shown once at creation; only a SHA-256 hash is stored. The agent
490
+ // key from Settings → AI & agents keeps working and counts as every scope.
491
+ export const API_SCOPES = [
492
+ 'events:read', 'events:write',
493
+ 'bookings:read', 'bookings:write',
494
+ 'resources:read', 'resources:write',
495
+ 'design:read', 'design:write-draft', 'design:publish', 'media:read', 'media:write',
496
+ ] as const;
497
+ export const ApiScope = z.enum(API_SCOPES);
498
+ export type ApiScope = z.infer<typeof ApiScope>;
499
+
500
+ export const ApiKey = z.object({
501
+ id: z.string(),
502
+ name: z.string(),
503
+ // first characters of the key, shown in lists so owners can tell keys apart
504
+ prefix: z.string(),
505
+ scopes: z.array(ApiScope).default([]),
506
+ lastUsedAt: z.number().nullable().default(null),
507
+ createdAt: z.number(),
508
+ });
509
+ export type ApiKey = z.infer<typeof ApiKey>;
510
+
511
+ export const WEBHOOK_EVENTS = [
512
+ 'event.published', 'event.updated', 'event.unpublished', 'event.deleted',
513
+ 'booking.created', 'booking.confirmed', 'booking.declined', 'booking.cancelled', 'booking.rescheduled',
514
+ ] as const;
515
+ export type WebhookEventType = (typeof WEBHOOK_EVENTS)[number];
516
+
517
+ export const Webhook = z.object({
518
+ id: z.string(),
519
+ url: z.string(),
520
+ // HMAC-SHA256 signing secret (X-Acms-Signature)
521
+ secret: z.string(),
522
+ events: z.array(z.string()).default([]),
523
+ active: z.boolean().default(true),
524
+ lastStatus: z.number().nullable().default(null),
525
+ lastAt: z.number().nullable().default(null),
526
+ createdAt: z.number(),
527
+ });
528
+ export type Webhook = z.infer<typeof Webhook>;
529
+
530
+ export const WebhookDelivery = z.object({
531
+ id: z.string(),
532
+ webhookId: z.string(),
533
+ event: z.string(),
534
+ payload: z.string(),
535
+ status: z.enum(['pending', 'delivered', 'failed']),
536
+ attempts: z.number().default(0),
537
+ nextAt: z.number().nullable().default(null),
538
+ responseCode: z.number().nullable().default(null),
539
+ createdAt: z.number(),
540
+ deliveredAt: z.number().nullable().default(null),
541
+ });
542
+ export type WebhookDelivery = z.infer<typeof WebhookDelivery>;
543
+
224
544
  // normalize a redirect path: leading slash, no trailing slash, no origin
225
545
  export function normalizeRedirectPath(input: string): string {
226
546
  let p = input.trim();
@@ -246,6 +566,8 @@ export const Settings = z.object({
246
566
  // overrides from the Design screen layered on top of the theme's defaults
247
567
  activeTheme: z.string().default(''),
248
568
  designOverrides: z.record(z.any()).default({}),
569
+ sectionPages: SectionPages.default({}),
570
+ designRevision: z.number().int().nonnegative().default(0),
249
571
  siteLogo: z.string().default(''),
250
572
  // SEO foundation: canonical origin for absolute URLs (sitemap, feeds, OG
251
573
  // tags — empty falls back to the request origin), favicon, and the default
@@ -268,11 +590,51 @@ export const Settings = z.object({
268
590
  dismissible: z.boolean().default(true),
269
591
  })
270
592
  .default({}),
593
+ // analytics providers, rendered into the public <head> by lib/analytics.ts
594
+ // (owner-only; IDs are re-validated there before any markup is built)
595
+ analytics: z
596
+ .object({
597
+ umami: z
598
+ .object({
599
+ enabled: z.boolean().default(false),
600
+ websiteId: z.string().default(''),
601
+ scriptUrl: z.string().default('https://cloud.umami.is/script.js'),
602
+ // session replay (Umami v3.1+): a second recorder.js tag, derived
603
+ // from scriptUrl. Defaults mirror Umami's own dashboard defaults.
604
+ replay: z
605
+ .object({
606
+ enabled: z.boolean().default(false),
607
+ sampleRate: z.number().min(0.01).max(1).default(0.15),
608
+ maskLevel: z.enum(['moderate', 'strict']).default('moderate'),
609
+ maxDuration: z.number().int().positive().default(300000),
610
+ })
611
+ .default({}),
612
+ })
613
+ .default({}),
614
+ metaPixel: z
615
+ .object({
616
+ enabled: z.boolean().default(false),
617
+ pixelId: z.string().default(''),
618
+ })
619
+ .default({}),
620
+ // experimental: same code path as the others, but not verified live yet
621
+ tiktokPixel: z
622
+ .object({
623
+ enabled: z.boolean().default(false),
624
+ pixelId: z.string().default(''),
625
+ })
626
+ .default({}),
627
+ })
628
+ .default({}),
271
629
  // external drafts API: bearer key for POST /api/drafts (empty = endpoint
272
630
  // off) and the browser origins allowed to call it ('*' entry allows all,
273
631
  // 'http://localhost:*' matches any port). Managed in /admin/settings.
632
+ ai: AiConfig,
633
+ agent: AgentConfig,
274
634
  draftsApiKey: z.string().default(''),
275
635
  draftsAllowedOrigins: z.array(z.string()).default([]),
636
+ // staged Design-screen changes (see lib/drafts.ts); null = none pending
637
+ draftDesign: z.record(z.any()).nullable().default(null),
276
638
  });
277
639
  export type Settings = z.infer<typeof Settings>;
278
640
 
@@ -7,6 +7,7 @@ export interface SearchStrings {
7
7
  placeholder: string;
8
8
  posts: string;
9
9
  pages: string;
10
+ events: string;
10
11
  tags: string;
11
12
  authors: string;
12
13
  empty: string;
@@ -21,6 +22,7 @@ const STRINGS: Record<string, SearchStrings> = {
21
22
  placeholder: 'Search posts, pages, tags and authors',
22
23
  posts: 'Posts',
23
24
  pages: 'Pages',
25
+ events: 'Events',
24
26
  tags: 'Tags',
25
27
  authors: 'Authors',
26
28
  empty: 'Nothing found for',
@@ -33,6 +35,7 @@ const STRINGS: Record<string, SearchStrings> = {
33
35
  placeholder: 'Busca artículos, páginas, etiquetas y autores',
34
36
  posts: 'Artículos',
35
37
  pages: 'Páginas',
38
+ events: 'Eventos',
36
39
  tags: 'Etiquetas',
37
40
  authors: 'Autores',
38
41
  empty: 'No hay resultados para',