@acms-subir/core 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/cli.mjs +75 -0
  2. package/integration.mjs +286 -0
  3. package/package.json +89 -0
  4. package/src/actions/index.ts +502 -0
  5. package/src/components/DesignManager.tsx +542 -0
  6. package/src/components/LinksManager.tsx +694 -0
  7. package/src/components/Modal.astro +65 -0
  8. package/src/components/NavManager.tsx +168 -0
  9. package/src/components/WidgetRenderer.astro +84 -0
  10. package/src/components/builder/PageBuilder.tsx +575 -0
  11. package/src/components/editor/ImagePicker.tsx +215 -0
  12. package/src/components/editor/PostEditor.tsx +1011 -0
  13. package/src/components/editor/PreviewOverlay.tsx +77 -0
  14. package/src/components/editor/extensions/calendar.tsx +230 -0
  15. package/src/components/editor/extensions/cards.tsx +488 -0
  16. package/src/components/editor/extensions/codeBlock.tsx +70 -0
  17. package/src/components/editor/extensions/infoCards.tsx +270 -0
  18. package/src/components/editor/extensions/mediaCards.tsx +376 -0
  19. package/src/components/editor/extensions/postList.tsx +130 -0
  20. package/src/components/editor/extensions/slash.tsx +250 -0
  21. package/src/components/editor/extensions/tableTyping.ts +63 -0
  22. package/src/components/editor/imaging.ts +146 -0
  23. package/src/components/media/CropModal.tsx +229 -0
  24. package/src/components/media/MediaLibrary.tsx +295 -0
  25. package/src/layouts/Admin.astro +119 -0
  26. package/src/layouts/Site.astro +218 -0
  27. package/src/lib/auth.ts +94 -0
  28. package/src/lib/backup/unzip.ts +38 -0
  29. package/src/lib/backup/zip.ts +105 -0
  30. package/src/lib/blocksToHtml.ts +68 -0
  31. package/src/lib/brand.ts +26 -0
  32. package/src/lib/calendar/ics.ts +402 -0
  33. package/src/lib/calendar/render.ts +275 -0
  34. package/src/lib/calendar/src.ts +68 -0
  35. package/src/lib/content.ts +53 -0
  36. package/src/lib/dates.ts +34 -0
  37. package/src/lib/dynamicBlocks.ts +115 -0
  38. package/src/lib/env.ts +6 -0
  39. package/src/lib/highlightCode.ts +44 -0
  40. package/src/lib/i18n.ts +40 -0
  41. package/src/lib/markdown.ts +7 -0
  42. package/src/lib/media/store.ts +95 -0
  43. package/src/lib/net.ts +51 -0
  44. package/src/lib/preview.ts +28 -0
  45. package/src/lib/repo/d1.ts +44 -0
  46. package/src/lib/repo/index.ts +23 -0
  47. package/src/lib/repo/migrations.ts +231 -0
  48. package/src/lib/repo/search.ts +14 -0
  49. package/src/lib/repo/sql.ts +721 -0
  50. package/src/lib/repo/sqlite.ts +43 -0
  51. package/src/lib/repo/types.ts +132 -0
  52. package/src/lib/schema.ts +286 -0
  53. package/src/lib/seo.ts +37 -0
  54. package/src/lib/theme.ts +206 -0
  55. package/src/lib/widgets.ts +108 -0
  56. package/src/middleware.ts +109 -0
  57. package/src/pages/[...slug].astro +43 -0
  58. package/src/pages/admin/c/[collection]/[id].astro +56 -0
  59. package/src/pages/admin/c/[collection]/index.astro +163 -0
  60. package/src/pages/admin/design.astro +42 -0
  61. package/src/pages/admin/index.astro +145 -0
  62. package/src/pages/admin/links.astro +21 -0
  63. package/src/pages/admin/login.astro +83 -0
  64. package/src/pages/admin/logout.ts +7 -0
  65. package/src/pages/admin/media.astro +13 -0
  66. package/src/pages/admin/navigation.astro +17 -0
  67. package/src/pages/admin/pages/[id].astro +49 -0
  68. package/src/pages/admin/pages/index.astro +100 -0
  69. package/src/pages/admin/posts/[id].astro +6 -0
  70. package/src/pages/admin/posts/index.astro +7 -0
  71. package/src/pages/admin/settings.astro +512 -0
  72. package/src/pages/admin/tags.astro +101 -0
  73. package/src/pages/admin/users.astro +149 -0
  74. package/src/pages/api/backup.ts +63 -0
  75. package/src/pages/api/drafts.ts +106 -0
  76. package/src/pages/api/media/upload.ts +89 -0
  77. package/src/pages/api/restore.ts +102 -0
  78. package/src/pages/author/[slug].astro +66 -0
  79. package/src/pages/blog/[slug].astro +240 -0
  80. package/src/pages/blog/index.astro +51 -0
  81. package/src/pages/blog/tag/[slug].astro +83 -0
  82. package/src/pages/embed/[pageId]/[blockId].ts +45 -0
  83. package/src/pages/go/[id].ts +10 -0
  84. package/src/pages/index.astro +52 -0
  85. package/src/pages/links.astro +110 -0
  86. package/src/pages/llms.txt.ts +47 -0
  87. package/src/pages/media/[...key].ts +18 -0
  88. package/src/pages/preview/[type]/[id].astro +52 -0
  89. package/src/pages/robots.txt.ts +28 -0
  90. package/src/pages/rss.xml.ts +58 -0
  91. package/src/pages/search.astro +83 -0
  92. package/src/pages/sitemap.xml.ts +55 -0
  93. package/src/styles/admin.css +652 -0
  94. package/src/styles/builder.css +121 -0
  95. package/src/styles/cards.css +176 -0
  96. package/src/styles/editor.css +596 -0
  97. package/src/styles/site.css +427 -0
  98. package/src/themes/editorial/templates/home.astro +127 -0
  99. package/src/themes/editorial/templates/post-minimal.astro +50 -0
  100. package/src/themes/editorial/theme.css +108 -0
  101. package/src/themes/editorial/theme.json +20 -0
  102. package/src/themes/field/theme.json +17 -0
  103. package/src/themes/ink/theme.json +17 -0
  104. package/src/themes/night/theme.css +7 -0
  105. package/src/themes/night/theme.json +17 -0
  106. package/src/virtual.d.ts +25 -0
  107. package/src/worker.ts +28 -0
@@ -0,0 +1,502 @@
1
+ import { defineAction, ActionError } from 'astro:actions';
2
+ import { z } from 'astro:schema';
3
+ import type { ActionAPIContext } from 'astro:actions';
4
+ import { getRepo } from '../lib/repo';
5
+ import { getMediaStore } from '../lib/media/store';
6
+ import { verifySessionToken, SESSION_COOKIE } from '../lib/auth';
7
+ import { parseConfig, slugify, widgetTypes, type WidgetType } from '../lib/schema';
8
+ import { env } from '../lib/env';
9
+ import { CONTENT, collectionUrl } from '../lib/content';
10
+ import { invalidateRedirectCache } from '../middleware';
11
+ import { isPrivateHost } from '../lib/net';
12
+ import { normalizeCalendarSrc } from '../lib/calendar/src';
13
+ import { getCalendarEvents } from '../lib/calendar/ics';
14
+ import { siteLocale } from '../lib/dates';
15
+ import { normalizePageLocale } from '../lib/i18n';
16
+
17
+ // All admin mutations live here. The middleware only guards /admin pages,
18
+ // so every action re-checks the session itself.
19
+ function requireAdmin(ctx: ActionAPIContext) {
20
+ if (!verifySessionToken(ctx.cookies.get(SESSION_COOKIE)?.value)) {
21
+ throw new ActionError({ code: 'UNAUTHORIZED', message: 'Not signed in.' });
22
+ }
23
+ }
24
+
25
+ function decodeEntities(s: string): string {
26
+ return s
27
+ .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
28
+ .replace(/&quot;/g, '"').replace(/&#0?39;|&apos;/g, "'").replace(/&#x27;/gi, "'");
29
+ }
30
+
31
+ // Schemas here use astro:schema's zod only — mixing in the project's own zod
32
+ // instance breaks the action client's type inference (it falls back to the
33
+ // FormData overload).
34
+ const nullableTs = z.number().nullable();
35
+ const StatusInput = z.enum(['draft', 'scheduled', 'published', 'archived']);
36
+ const BlockInput = z.object({
37
+ id: z.string(),
38
+ type: z.string(),
39
+ config: z.record(z.any()),
40
+ });
41
+
42
+ export const server = {
43
+ posts: {
44
+ save: defineAction({
45
+ input: z.object({
46
+ id: z.string(),
47
+ title: z.string().optional(),
48
+ slug: z.string().optional(),
49
+ collection: z.string().optional(),
50
+ body: z.string().optional(),
51
+ bodyJson: z.string().optional(),
52
+ bodyHtml: z.string().optional(),
53
+ excerpt: z.string().optional(),
54
+ tags: z.array(z.string()).optional(),
55
+ cover: z.string().optional(),
56
+ coverAlt: z.string().optional(),
57
+ seoTitle: z.string().optional(),
58
+ seoDesc: z.string().optional(),
59
+ status: StatusInput.optional(),
60
+ featured: z.boolean().optional(),
61
+ authorId: z.string().nullable().optional(),
62
+ authorIds: z.array(z.string()).optional(),
63
+ template: z.string().optional(),
64
+ publishedAt: nullableTs.optional(),
65
+ scheduledAt: nullableTs.optional(),
66
+ }),
67
+ handler: async ({ id, ...data }, ctx) => {
68
+ requireAdmin(ctx);
69
+ const repo = await getRepo(ctx.locals);
70
+ if (data.slug !== undefined) data.slug = slugify(data.slug);
71
+ if (data.collection !== undefined && !CONTENT.collections[data.collection]) {
72
+ throw new ActionError({ code: 'BAD_REQUEST', message: `Unknown collection "${data.collection}".` });
73
+ }
74
+ const movesUrl = data.slug !== undefined || data.collection !== undefined;
75
+ const before = movesUrl ? await repo.getPost(id) : null;
76
+ await repo.updatePost(id, data);
77
+ // any new tag names become registry entries with slugs for archives
78
+ if (data.tags !== undefined) await repo.ensureTags(data.tags);
79
+ const post = await repo.getPost(id);
80
+ if (!post) throw new ActionError({ code: 'NOT_FOUND', message: 'Post not found.' });
81
+ // a published post whose slug or collection changed leaves a 301
82
+ // behind so old links keep working
83
+ if (before && before.status === 'published' && (before.slug !== post.slug || before.collection !== post.collection)) {
84
+ await repo
85
+ .createRedirect({
86
+ fromPath: collectionUrl(before.collection, before.slug),
87
+ toPath: collectionUrl(post.collection, post.slug),
88
+ })
89
+ .catch(() => {});
90
+ invalidateRedirectCache();
91
+ }
92
+ return { post };
93
+ },
94
+ }),
95
+ delete: defineAction({
96
+ input: z.object({ id: z.string() }),
97
+ handler: async ({ id }, ctx) => {
98
+ requireAdmin(ctx);
99
+ const repo = await getRepo(ctx.locals);
100
+ await repo.deletePost(id);
101
+ return { ok: true };
102
+ },
103
+ }),
104
+ },
105
+
106
+ pages: {
107
+ save: defineAction({
108
+ input: z.object({
109
+ id: z.string(),
110
+ title: z.string().optional(),
111
+ slug: z.string().optional(),
112
+ status: StatusInput.optional(),
113
+ isHome: z.boolean().optional(),
114
+ locale: z.string().optional(),
115
+ seoTitle: z.string().optional(),
116
+ seoDesc: z.string().optional(),
117
+ template: z.string().optional(),
118
+ bodyJson: z.string().optional(),
119
+ bodyHtml: z.string().optional(),
120
+ blocks: z.array(BlockInput).optional(),
121
+ }),
122
+ handler: async ({ id, blocks, ...data }, ctx) => {
123
+ requireAdmin(ctx);
124
+ const repo = await getRepo(ctx.locals);
125
+ if (data.slug !== undefined) data.slug = slugify(data.slug);
126
+ // default locale stores as '', unknown codes fall back to it
127
+ if (data.locale !== undefined) data.locale = normalizePageLocale(data.locale);
128
+ const before = data.slug !== undefined ? await repo.getPage(id) : null;
129
+ const cleanBlocks =
130
+ blocks === undefined
131
+ ? undefined
132
+ : blocks
133
+ .filter((b) => widgetTypes.includes(b.type as WidgetType))
134
+ .map((b) => ({ ...b, type: b.type as WidgetType, config: parseConfig(b.type as WidgetType, b.config) as Record<string, unknown> }));
135
+ await repo.updatePage(id, { ...data, blocks: cleanBlocks });
136
+ const page = await repo.getPage(id);
137
+ if (!page) throw new ActionError({ code: 'NOT_FOUND', message: 'Page not found.' });
138
+ // 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) {
140
+ await repo.createRedirect({ fromPath: `/${before.slug}`, toPath: `/${page.slug}` }).catch(() => {});
141
+ invalidateRedirectCache();
142
+ }
143
+ return { page };
144
+ },
145
+ }),
146
+ delete: defineAction({
147
+ input: z.object({ id: z.string() }),
148
+ handler: async ({ id }, ctx) => {
149
+ requireAdmin(ctx);
150
+ const repo = await getRepo(ctx.locals);
151
+ await repo.deletePage(id);
152
+ return { ok: true };
153
+ },
154
+ }),
155
+ },
156
+
157
+ media: {
158
+ list: defineAction({
159
+ input: z.object({}),
160
+ handler: async (_input, ctx) => {
161
+ requireAdmin(ctx);
162
+ const repo = await getRepo(ctx.locals);
163
+ return { media: await repo.listMedia() };
164
+ },
165
+ }),
166
+ updateAlt: defineAction({
167
+ input: z.object({ id: z.string(), alt: z.string() }),
168
+ handler: async ({ id, alt }, ctx) => {
169
+ requireAdmin(ctx);
170
+ const repo = await getRepo(ctx.locals);
171
+ await repo.updateMedia(id, { alt });
172
+ return { ok: true };
173
+ },
174
+ }),
175
+ delete: defineAction({
176
+ input: z.object({ id: z.string() }),
177
+ handler: async ({ id }, ctx) => {
178
+ requireAdmin(ctx);
179
+ const repo = await getRepo(ctx.locals);
180
+ const media = await repo.getMedia(id);
181
+ if (media) {
182
+ const store = getMediaStore(ctx.locals);
183
+ await Promise.all([media.key, ...media.variants.map((v) => v.key)].map((k) => store.delete(k)));
184
+ await repo.deleteMedia(id);
185
+ }
186
+ return { ok: true };
187
+ },
188
+ }),
189
+ },
190
+
191
+ links: {
192
+ create: defineAction({
193
+ input: z.object({
194
+ kind: z.enum(['link', 'email', 'phone', 'heading', 'text', 'spacer', 'vcard', 'html']).default('link'),
195
+ label: z.string().default(''),
196
+ url: z.string().default(''),
197
+ icon: z.string().default(''),
198
+ html: z.string().default(''),
199
+ meta: z.record(z.any()).default({}),
200
+ }),
201
+ handler: async (data, ctx) => {
202
+ requireAdmin(ctx);
203
+ const repo = await getRepo(ctx.locals);
204
+ return { link: await repo.createLink(data) };
205
+ },
206
+ }),
207
+ update: defineAction({
208
+ input: z.object({
209
+ id: z.string(),
210
+ label: z.string().optional(),
211
+ url: z.string().optional(),
212
+ icon: z.string().optional(),
213
+ html: z.string().optional(),
214
+ meta: z.record(z.any()).optional(),
215
+ }),
216
+ handler: async ({ id, ...data }, ctx) => {
217
+ requireAdmin(ctx);
218
+ const repo = await getRepo(ctx.locals);
219
+ await repo.updateLink(id, data);
220
+ return { ok: true };
221
+ },
222
+ }),
223
+ reorder: defineAction({
224
+ input: z.object({ ids: z.array(z.string()) }),
225
+ handler: async ({ ids }, ctx) => {
226
+ requireAdmin(ctx);
227
+ const repo = await getRepo(ctx.locals);
228
+ await repo.reorderLinks(ids);
229
+ return { ok: true };
230
+ },
231
+ }),
232
+ delete: defineAction({
233
+ input: z.object({ id: z.string() }),
234
+ handler: async ({ id }, ctx) => {
235
+ requireAdmin(ctx);
236
+ const repo = await getRepo(ctx.locals);
237
+ await repo.deleteLink(id);
238
+ return { ok: true };
239
+ },
240
+ }),
241
+ },
242
+
243
+ nav: {
244
+ create: defineAction({
245
+ input: z.object({
246
+ label: z.string().default('New link'),
247
+ url: z.string().default('/'),
248
+ area: z.enum(['header', 'footer', 'social']).default('header'),
249
+ }),
250
+ handler: async (data, ctx) => {
251
+ requireAdmin(ctx);
252
+ const repo = await getRepo(ctx.locals);
253
+ return { item: await repo.createNavItem(data) };
254
+ },
255
+ }),
256
+ update: defineAction({
257
+ input: z.object({
258
+ id: z.string(),
259
+ label: z.string().optional(),
260
+ url: z.string().optional(),
261
+ area: z.enum(['header', 'footer', 'social']).optional(),
262
+ }),
263
+ handler: async ({ id, ...data }, ctx) => {
264
+ requireAdmin(ctx);
265
+ const repo = await getRepo(ctx.locals);
266
+ await repo.updateNavItem(id, data);
267
+ return { ok: true };
268
+ },
269
+ }),
270
+ reorder: defineAction({
271
+ input: z.object({ ids: z.array(z.string()) }),
272
+ handler: async ({ ids }, ctx) => {
273
+ requireAdmin(ctx);
274
+ const repo = await getRepo(ctx.locals);
275
+ await repo.reorderNavItems(ids);
276
+ return { ok: true };
277
+ },
278
+ }),
279
+ delete: defineAction({
280
+ input: z.object({ id: z.string() }),
281
+ handler: async ({ id }, ctx) => {
282
+ requireAdmin(ctx);
283
+ const repo = await getRepo(ctx.locals);
284
+ await repo.deleteNavItem(id);
285
+ return { ok: true };
286
+ },
287
+ }),
288
+ },
289
+
290
+ settings: {
291
+ update: defineAction({
292
+ input: z.object({
293
+ siteTitle: z.string().optional(),
294
+ tagline: z.string().optional(),
295
+ footerText: z.string().optional(),
296
+ linksName: z.string().optional(),
297
+ linksBio: z.string().optional(),
298
+ linksAvatar: z.string().optional(),
299
+ activeTheme: z.string().optional(),
300
+ designOverrides: z.record(z.any()).optional(),
301
+ siteLogo: z.string().optional(),
302
+ siteFavicon: z.string().optional(),
303
+ siteCover: z.string().optional(),
304
+ }),
305
+ handler: async (data, ctx) => {
306
+ requireAdmin(ctx);
307
+ const repo = await getRepo(ctx.locals);
308
+ await repo.updateSettings(data);
309
+ return { ok: true };
310
+ },
311
+ }),
312
+ },
313
+
314
+ tags: {
315
+ list: defineAction({
316
+ input: z.object({}),
317
+ handler: async (_input, ctx) => {
318
+ requireAdmin(ctx);
319
+ const repo = await getRepo(ctx.locals);
320
+ return { tags: await repo.listTags() };
321
+ },
322
+ }),
323
+ },
324
+
325
+ users: {
326
+ list: defineAction({
327
+ input: z.object({}),
328
+ handler: async (_input, ctx) => {
329
+ requireAdmin(ctx);
330
+ const repo = await getRepo(ctx.locals);
331
+ return { users: await repo.listUsers() };
332
+ },
333
+ }),
334
+ },
335
+
336
+ bookmark: {
337
+ // Server-side link-metadata fetch for the bookmark card (no CORS in the
338
+ // browser). Fetches the page, scrapes OG/meta tags, returns a compact
339
+ // shape. Guards: https/http only, no private hosts, 1.5 MB / 8 s caps.
340
+ fetch: defineAction({
341
+ input: z.object({ url: z.string().url() }),
342
+ handler: async ({ url }, ctx) => {
343
+ requireAdmin(ctx);
344
+ // redirects are followed manually so EVERY hop is re-checked against
345
+ // the private-host guard (a public URL 302ing to 127.0.0.1 or a cloud
346
+ // metadata IP must not slip through)
347
+ let current = url;
348
+ let res: Response | null = null;
349
+ for (let hop = 0; hop < 5; hop++) {
350
+ const u = new URL(current);
351
+ if (!/^https?:$/.test(u.protocol) || isPrivateHost(u.hostname)) {
352
+ throw new ActionError({ code: 'BAD_REQUEST', message: 'That URL can’t be bookmarked.' });
353
+ }
354
+ res = await fetch(u.href, {
355
+ redirect: 'manual',
356
+ signal: AbortSignal.timeout(8000),
357
+ headers: { 'User-Agent': 'Mozilla/5.0 (compatible; acms-bookmark/1.0)', Accept: 'text/html' },
358
+ }).catch(() => null);
359
+ if (res && res.status >= 300 && res.status < 400) {
360
+ const loc = res.headers.get('location');
361
+ if (!loc) break;
362
+ current = new URL(loc, u.href).href;
363
+ continue;
364
+ }
365
+ break;
366
+ }
367
+ if (!res || !res.ok) throw new ActionError({ code: 'BAD_GATEWAY', message: 'Couldn’t reach that page.' });
368
+ const finalUrl = current;
369
+ const html = (await res.text()).slice(0, 1_500_000);
370
+
371
+ const tag = (patterns: RegExp[]) => {
372
+ for (const p of patterns) {
373
+ const m = html.match(p);
374
+ if (m?.[1]) return decodeEntities(m[1].trim());
375
+ }
376
+ return '';
377
+ };
378
+ const metaContent = (name: string) => [
379
+ new RegExp(`<meta[^>]+(?:property|name)=["']${name}["'][^>]+content=["']([^"']+)["']`, 'i'),
380
+ new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+(?:property|name)=["']${name}["']`, 'i'),
381
+ ];
382
+ const abs = (u: string) => {
383
+ if (!u) return '';
384
+ try { return new URL(u, finalUrl).href; } catch { return ''; }
385
+ };
386
+
387
+ const title = tag(metaContent('og:title')) || tag(metaContent('twitter:title')) || tag([/<title[^>]*>([^<]+)<\/title>/i]);
388
+ const description = tag(metaContent('og:description')) || tag(metaContent('twitter:description')) || tag(metaContent('description'));
389
+ const image = abs(tag(metaContent('og:image')) || tag(metaContent('twitter:image')));
390
+ const siteName = tag(metaContent('og:site_name')) || new URL(finalUrl).hostname;
391
+ const favicon = abs(
392
+ tag([
393
+ /<link[^>]+rel=["'](?:shortcut )?icon["'][^>]+href=["']([^"']+)["']/i,
394
+ /<link[^>]+href=["']([^"']+)["'][^>]+rel=["'](?:shortcut )?icon["']/i,
395
+ ]) || '/favicon.ico'
396
+ );
397
+
398
+ return { meta: { title, description, image, favicon, siteName } };
399
+ },
400
+ }),
401
+ },
402
+
403
+ calendar: {
404
+ // Editor-side check for the calendar card: normalize whatever the author
405
+ // pasted, fetch the feed(s), and return the next few events so the card
406
+ // can show a live preview (and reject dead URLs at paste time). The
407
+ // public site never calls this — it renders via expandDynamicBlocks.
408
+ preview: defineAction({
409
+ input: z.object({ sources: z.array(z.string()).min(1).max(6) }),
410
+ handler: async ({ sources }, ctx) => {
411
+ requireAdmin(ctx);
412
+ const normalized: string[] = [];
413
+ for (const raw of sources) {
414
+ const url = normalizeCalendarSrc(raw);
415
+ if (!url) {
416
+ throw new ActionError({
417
+ code: 'BAD_REQUEST',
418
+ message: 'Paste a Google Calendar link, a calendar ID, or an .ics feed URL.',
419
+ });
420
+ }
421
+ normalized.push(url);
422
+ }
423
+ const repo = await getRepo(ctx.locals);
424
+ const settings = await repo.getSettings();
425
+ const tz = settings.timezone || 'UTC';
426
+ 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] });
433
+ const locale = siteLocale(settings);
434
+ const dayFmt = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz });
435
+ const timeFmt = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
436
+ const upcoming = events
437
+ .filter((e) => e.end > now)
438
+ .slice(0, 5)
439
+ .map((e) => ({
440
+ when: dayFmt.format(e.start) + (e.allDay ? '' : ` · ${timeFmt.format(e.start)}`),
441
+ title: e.title,
442
+ cal: e.cal,
443
+ }));
444
+ return { sources: normalized, upcoming, total: events.length };
445
+ },
446
+ }),
447
+ },
448
+
449
+ unsplash: {
450
+ search: defineAction({
451
+ input: z.object({ query: z.string().min(1), page: z.number().int().min(1).default(1) }),
452
+ handler: async ({ query, page }, ctx) => {
453
+ requireAdmin(ctx);
454
+ const key = env('UNSPLASH_ACCESS_KEY');
455
+ if (!key) throw new ActionError({ code: 'PRECONDITION_FAILED', message: 'Unsplash is not configured.' });
456
+ const res = await fetch(
457
+ `https://api.unsplash.com/search/photos?query=${encodeURIComponent(query)}&page=${page}&per_page=24`,
458
+ { headers: { Authorization: `Client-ID ${key}` } }
459
+ );
460
+ if (!res.ok) throw new ActionError({ code: 'BAD_GATEWAY', message: `Unsplash error ${res.status}` });
461
+ const data = (await res.json()) as {
462
+ total_pages: number;
463
+ results: {
464
+ id: string;
465
+ description: string | null;
466
+ alt_description: string | null;
467
+ width: number;
468
+ height: number;
469
+ urls: { regular: string; small: string; full: string };
470
+ links: { download_location: string };
471
+ user: { name: string; links: { html: string } };
472
+ }[];
473
+ };
474
+ return {
475
+ totalPages: data.total_pages,
476
+ photos: data.results.map((p) => ({
477
+ id: p.id,
478
+ alt: p.alt_description ?? p.description ?? '',
479
+ width: p.width,
480
+ height: p.height,
481
+ thumb: p.urls.small,
482
+ regular: p.urls.regular,
483
+ downloadLocation: p.links.download_location,
484
+ authorName: p.user.name,
485
+ authorUrl: p.user.links.html,
486
+ })),
487
+ };
488
+ },
489
+ }),
490
+ // Unsplash API guideline: trigger the download endpoint when a photo is used.
491
+ track: defineAction({
492
+ input: z.object({ downloadLocation: z.string().url() }),
493
+ handler: async ({ downloadLocation }, ctx) => {
494
+ requireAdmin(ctx);
495
+ const key = env('UNSPLASH_ACCESS_KEY');
496
+ if (!key || !downloadLocation.startsWith('https://api.unsplash.com/')) return { ok: false };
497
+ await fetch(downloadLocation, { headers: { Authorization: `Client-ID ${key}` } }).catch(() => {});
498
+ return { ok: true };
499
+ },
500
+ }),
501
+ },
502
+ };