@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.
- package/CHANGELOG.md +106 -0
- package/cli/common.mjs +35 -0
- package/cli/legacy.mjs +317 -0
- package/cli/main.mjs +66 -0
- package/cli/palette.mjs +4 -0
- package/cli/preview.mjs +50 -0
- package/cli/remote.mjs +55 -0
- package/cli/themes.mjs +127 -0
- package/cli.mjs +2 -315
- package/deploy/Caddyfile +8 -0
- package/deploy/Dockerfile +34 -0
- package/deploy/compose.https.yml +21 -0
- package/deploy/compose.yml +46 -0
- package/docs/theme-authoring.md +103 -0
- package/email-runner.mjs +32 -0
- package/integration.mjs +193 -2
- package/ops-check.mjs +27 -0
- package/package.json +35 -12
- package/src/actions/index.ts +744 -38
- package/src/components/AiCleaner.tsx +291 -0
- package/src/components/AiSettings.tsx +575 -0
- package/src/components/CommandPalette.tsx +200 -0
- package/src/components/ContentList.astro +246 -0
- package/src/components/DesignAccess.tsx +182 -0
- package/src/components/DesignManager.tsx +455 -47
- package/src/components/EmailSettings.tsx +521 -0
- package/src/components/EventPage.astro +178 -0
- package/src/components/LegalPage.astro +27 -0
- package/src/components/LinksManager.tsx +216 -97
- package/src/components/NavManager.tsx +83 -38
- package/src/components/ThemeScript.astro +50 -0
- package/src/components/ThemeSections.astro +57 -0
- package/src/components/ThemeSectionsEditor.tsx +513 -0
- package/src/components/booking/BookingFlow.astro +150 -0
- package/src/components/booking/HoursEditor.astro +63 -0
- package/src/components/builder/PageBuilder.tsx +10 -2
- package/src/components/editor/EventPanel.tsx +401 -0
- package/src/components/editor/FormatDock.tsx +81 -0
- package/src/components/editor/LivePane.tsx +122 -0
- package/src/components/editor/MobileToolbar.tsx +93 -0
- package/src/components/editor/PostEditor.tsx +826 -271
- package/src/components/editor/PreviewOverlay.tsx +242 -43
- package/src/components/editor/extensions/calendar.tsx +51 -7
- package/src/components/editor/extensions/cards.tsx +17 -1
- package/src/components/editor/extensions/codeBlock.tsx +1 -1
- package/src/components/editor/extensions/events.tsx +153 -0
- package/src/components/editor/extensions/infoCards.tsx +16 -1
- package/src/components/editor/extensions/mediaCards.tsx +36 -2
- package/src/components/editor/extensions/slash.tsx +77 -9
- package/src/components/editor/extensions/toc.tsx +89 -0
- package/src/components/editor/extensions/toggle.tsx +70 -0
- package/src/components/site/Pager.astro +24 -0
- package/src/components/site/PostCard.astro +64 -0
- package/src/components/site/TagLabel.astro +11 -0
- package/src/layouts/Admin.astro +289 -49
- package/src/layouts/Site.astro +331 -57
- package/src/lib/agent-api.ts +13 -0
- package/src/lib/agent-discovery.ts +40 -0
- package/src/lib/agent.ts +114 -0
- package/src/lib/ai-config.ts +58 -0
- package/src/lib/ai-schema.ts +22 -0
- package/src/lib/ai.ts +90 -0
- package/src/lib/analytics.ts +121 -0
- package/src/lib/api-keys.ts +65 -0
- package/src/lib/api.ts +52 -0
- package/src/lib/auth.ts +25 -5
- package/src/lib/backup/content.ts +36 -0
- package/src/lib/backup/unzip.ts +12 -1
- package/src/lib/backup/zip.ts +1 -1
- package/src/lib/booking/agent.ts +108 -0
- package/src/lib/booking/availability.ts +221 -0
- package/src/lib/booking/emails.ts +115 -0
- package/src/lib/booking/forms.ts +77 -0
- package/src/lib/booking/service.ts +277 -0
- package/src/lib/booking/view.ts +54 -0
- package/src/lib/calendar/agent-schema.ts +48 -0
- package/src/lib/calendar/agent.ts +136 -0
- package/src/lib/calendar/api.ts +81 -0
- package/src/lib/calendar/cards.ts +102 -0
- package/src/lib/calendar/config.ts +52 -0
- package/src/lib/calendar/data.ts +130 -0
- package/src/lib/calendar/events.ts +261 -0
- package/src/lib/calendar/feeds.ts +43 -0
- package/src/lib/calendar/hooks.ts +38 -0
- package/src/lib/calendar/http.ts +30 -0
- package/src/lib/calendar/ics-write.ts +120 -0
- package/src/lib/calendar/ics.ts +15 -5
- package/src/lib/calendar/import.ts +203 -0
- package/src/lib/calendar/render.ts +120 -37
- package/src/lib/calendar/rrule.ts +149 -0
- package/src/lib/calendar/seo.ts +48 -0
- package/src/lib/design-service.ts +58 -0
- package/src/lib/designDiff.ts +145 -0
- package/src/lib/designPreview.ts +112 -0
- package/src/lib/documents/cleanup.ts +31 -0
- package/src/lib/documents/extract.ts +154 -0
- package/src/lib/documents/vendor.d.ts +4 -0
- package/src/lib/drafts.ts +131 -0
- package/src/lib/dynamicBlocks.ts +79 -7
- package/src/lib/email/accounts.ts +55 -0
- package/src/lib/email/config.ts +86 -0
- package/src/lib/email/migration.ts +24 -0
- package/src/lib/email/providers.ts +89 -0
- package/src/lib/email/service.ts +78 -0
- package/src/lib/email/store.ts +167 -0
- package/src/lib/email/templates.ts +18 -0
- package/src/lib/email/types.ts +32 -0
- package/src/lib/email/webhooks.ts +87 -0
- package/src/lib/email.ts +15 -0
- package/src/lib/env.ts +1 -1
- package/src/lib/legal/index.ts +25 -0
- package/src/lib/mobile/bookings.ts +73 -0
- package/src/lib/mobile/calendar.ts +85 -0
- package/src/lib/mobile/content.ts +85 -0
- package/src/lib/mobile/http.ts +63 -0
- package/src/lib/mobile/route.ts +20 -0
- package/src/lib/ops.ts +8 -0
- package/src/lib/preview.ts +21 -1
- package/src/lib/repo/migrations.ts +254 -0
- package/src/lib/repo/sql.ts +956 -46
- package/src/lib/repo/types.ts +140 -3
- package/src/lib/scheduler.ts +25 -0
- package/src/lib/schema.ts +362 -0
- package/src/lib/search-i18n.ts +3 -0
- package/src/lib/socialIcons.ts +32 -0
- package/src/lib/tagColor.ts +11 -0
- package/src/lib/theme.ts +59 -40
- package/src/lib/themeTokens.ts +174 -0
- package/src/lib/umami.ts +108 -0
- package/src/lib/webhooks.ts +139 -0
- package/src/middleware.ts +36 -3
- package/src/pages/404.astro +23 -0
- package/src/pages/[...slug].astro +10 -5
- package/src/pages/account/[action].astro +53 -0
- package/src/pages/admin/ai.astro +12 -0
- package/src/pages/admin/c/[collection]/[id].astro +14 -4
- package/src/pages/admin/c/[collection]/index.astro +127 -91
- package/src/pages/admin/calendar/bookings.astro +400 -0
- package/src/pages/admin/calendar/calendars.astro +265 -0
- package/src/pages/admin/calendar/events/[id].astro +56 -0
- package/src/pages/admin/calendar/events/index.astro +408 -0
- package/src/pages/admin/calendar/index.ts +4 -0
- package/src/pages/admin/calendar/integrations.astro +221 -0
- package/src/pages/admin/calendar/resources/[id].astro +180 -0
- package/src/pages/admin/calendar/resources/index.astro +106 -0
- package/src/pages/admin/calendar/resources/link.astro +196 -0
- package/src/pages/admin/design.astro +16 -4
- package/src/pages/admin/email.astro +13 -0
- package/src/pages/admin/index.astro +237 -87
- package/src/pages/admin/links.astro +3 -3
- package/src/pages/admin/login.astro +14 -10
- package/src/pages/admin/navigation.astro +8 -2
- package/src/pages/admin/pages/[id].astro +14 -3
- package/src/pages/admin/pages/index.astro +41 -47
- package/src/pages/admin/settings.astro +475 -276
- package/src/pages/admin/tags.astro +4 -4
- package/src/pages/admin/users.astro +38 -28
- package/src/pages/api/agent/docs.ts +33 -0
- package/src/pages/api/agent/index.ts +21 -0
- package/src/pages/api/ai/clean.ts +13 -0
- package/src/pages/api/ai/draft.ts +14 -0
- package/src/pages/api/ai/settings.ts +53 -0
- package/src/pages/api/ai/test.ts +12 -0
- package/src/pages/api/backup.ts +8 -21
- package/src/pages/api/calendar/availability.ts +35 -0
- package/src/pages/api/calendar/bookings/[id].ts +49 -0
- package/src/pages/api/calendar/bookings/index.ts +73 -0
- package/src/pages/api/calendar/calendars.ts +35 -0
- package/src/pages/api/calendar/docs.ts +64 -0
- package/src/pages/api/calendar/events/[id].ts +63 -0
- package/src/pages/api/calendar/events/index.ts +112 -0
- package/src/pages/api/calendar/resources.ts +34 -0
- package/src/pages/api/design/capabilities.ts +10 -0
- package/src/pages/api/design/index.ts +58 -0
- package/src/pages/api/design/keys.ts +24 -0
- package/src/pages/api/design/media.ts +32 -0
- package/src/pages/api/email/run.ts +11 -0
- package/src/pages/api/email/settings.ts +61 -0
- package/src/pages/api/email/webhooks/[provider].ts +4 -0
- package/src/pages/api/mcp.ts +59 -0
- package/src/pages/api/media/upload.ts +2 -2
- package/src/pages/api/ops/status.ts +39 -0
- package/src/pages/api/restore.ts +27 -37
- package/src/pages/api/search.ts +18 -4
- package/src/pages/api/v1/availability.ts +7 -0
- package/src/pages/api/v1/bookings.ts +17 -0
- package/src/pages/api/v1/calendars.ts +7 -0
- package/src/pages/api/v1/content/[id].ts +7 -0
- package/src/pages/api/v1/content/index.ts +7 -0
- package/src/pages/api/v1/events.ts +7 -0
- package/src/pages/api/v1/resources.ts +7 -0
- package/src/pages/author/[slug].astro +43 -35
- package/src/pages/blog/[slug].astro +140 -66
- package/src/pages/blog/index.astro +95 -30
- package/src/pages/blog/tag/[slug].astro +45 -40
- package/src/pages/book/[resource]/[link].astro +70 -0
- package/src/pages/book/[resource]/index.astro +63 -0
- package/src/pages/book/[resource].ics.ts +38 -0
- package/src/pages/book/index.astro +47 -0
- package/src/pages/book/manage/[token].astro +108 -0
- package/src/pages/calendar/[slug].ics.ts +27 -0
- package/src/pages/calendar/feed.ics.ts +15 -0
- package/src/pages/calendar/index.astro +191 -0
- package/src/pages/embed/book/[resource]/[link].astro +33 -0
- package/src/pages/embed/calendar/[slug].astro +43 -0
- package/src/pages/events/[slug].astro +74 -0
- package/src/pages/events/[slug].ics.ts +25 -0
- package/src/pages/index.astro +9 -3
- package/src/pages/legal/privacy.astro +4 -0
- package/src/pages/legal/terms.astro +4 -0
- package/src/pages/links.astro +33 -7
- package/src/pages/llms.txt.ts +4 -0
- package/src/pages/preview/[type]/[id].astro +34 -8
- package/src/pages/search.astro +40 -20
- package/src/pages/sitemap.xml.ts +16 -1
- package/src/styles/admin.css +928 -105
- package/src/styles/cards.css +101 -60
- package/src/styles/editor.css +532 -102
- package/src/styles/email-settings.css +117 -0
- package/src/styles/sections.css +12 -0
- package/src/styles/site.css +938 -278
- package/src/styles/theme-editor.css +135 -0
- package/src/theme-sdk/contract.d.mts +619 -0
- package/src/theme-sdk/contract.mjs +80 -0
- package/src/theme-sdk/index.ts +15 -0
- package/src/theme-sdk/tokens.d.mts +148 -0
- package/src/theme-sdk/tokens.mjs +52 -0
- package/src/themes/editorial/templates/home.astro +4 -4
- package/src/themes/editorial/templates/post-minimal.astro +2 -2
- package/src/themes/editorial/theme.css +22 -37
- package/src/themes/editorial/theme.json +23 -3
- package/src/themes/field/theme.json +26 -9
- package/src/themes/ink/theme.json +30 -12
- package/src/themes/night/theme.css +4 -7
- package/src/themes/night/theme.json +27 -10
- package/src/virtual.d.ts +14 -0
- package/src/worker.ts +5 -1
package/src/actions/index.ts
CHANGED
|
@@ -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 {
|
|
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 (!
|
|
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
|
|
75
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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 {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
if (
|
|
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
|
-
|
|
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(() => {});
|