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