@acms-subir/core 0.2.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 +78 -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/theme-authoring.md +103 -0
- package/email-runner.mjs +32 -0
- package/integration.mjs +182 -2
- package/ops-check.mjs +27 -0
- package/package.json +27 -5
- package/src/actions/index.ts +575 -47
- 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 +114 -57
- package/src/components/NavManager.tsx +42 -43
- 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 +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 +324 -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 +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 +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/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 +217 -0
- package/src/lib/repo/sql.ts +853 -60
- package/src/lib/repo/types.ts +122 -5
- package/src/lib/scheduler.ts +25 -0
- package/src/lib/schema.ts +309 -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 +8 -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 +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 +10 -3
- package/src/pages/admin/email.astro +13 -0
- package/src/pages/admin/index.astro +232 -85
- 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 +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 +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 +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 +6 -3
- package/src/pages/legal/privacy.astro +4 -0
- package/src/pages/legal/terms.astro +4 -0
- package/src/pages/links.astro +23 -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/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 +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';
|
|
@@ -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) {
|
|
@@ -279,7 +401,7 @@ export const server = {
|
|
|
279
401
|
meta: z.record(z.any()).default({}),
|
|
280
402
|
}),
|
|
281
403
|
handler: async (data, ctx) => {
|
|
282
|
-
requireAdmin(ctx);
|
|
404
|
+
await requireAdmin(ctx);
|
|
283
405
|
const repo = await getRepo(ctx.locals);
|
|
284
406
|
return { link: await repo.createLink(data) };
|
|
285
407
|
},
|
|
@@ -294,7 +416,7 @@ export const server = {
|
|
|
294
416
|
meta: z.record(z.any()).optional(),
|
|
295
417
|
}),
|
|
296
418
|
handler: async ({ id, ...data }, ctx) => {
|
|
297
|
-
requireAdmin(ctx);
|
|
419
|
+
await requireAdmin(ctx);
|
|
298
420
|
const repo = await getRepo(ctx.locals);
|
|
299
421
|
await repo.updateLink(id, data);
|
|
300
422
|
return { ok: true };
|
|
@@ -303,7 +425,7 @@ export const server = {
|
|
|
303
425
|
reorder: defineAction({
|
|
304
426
|
input: z.object({ ids: z.array(z.string()) }),
|
|
305
427
|
handler: async ({ ids }, ctx) => {
|
|
306
|
-
requireAdmin(ctx);
|
|
428
|
+
await requireAdmin(ctx);
|
|
307
429
|
const repo = await getRepo(ctx.locals);
|
|
308
430
|
await repo.reorderLinks(ids);
|
|
309
431
|
return { ok: true };
|
|
@@ -312,7 +434,7 @@ export const server = {
|
|
|
312
434
|
delete: defineAction({
|
|
313
435
|
input: z.object({ id: z.string() }),
|
|
314
436
|
handler: async ({ id }, ctx) => {
|
|
315
|
-
requireAdmin(ctx);
|
|
437
|
+
await requireAdmin(ctx);
|
|
316
438
|
const repo = await getRepo(ctx.locals);
|
|
317
439
|
await repo.deleteLink(id);
|
|
318
440
|
return { ok: true };
|
|
@@ -321,6 +443,26 @@ export const server = {
|
|
|
321
443
|
},
|
|
322
444
|
|
|
323
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
|
+
}),
|
|
324
466
|
create: defineAction({
|
|
325
467
|
input: z.object({
|
|
326
468
|
label: z.string().default('New link'),
|
|
@@ -328,7 +470,7 @@ export const server = {
|
|
|
328
470
|
area: z.enum(['header', 'footer', 'social']).default('header'),
|
|
329
471
|
}),
|
|
330
472
|
handler: async (data, ctx) => {
|
|
331
|
-
requireAdmin(ctx);
|
|
473
|
+
await requireAdmin(ctx);
|
|
332
474
|
const repo = await getRepo(ctx.locals);
|
|
333
475
|
return { item: await repo.createNavItem(data) };
|
|
334
476
|
},
|
|
@@ -341,7 +483,7 @@ export const server = {
|
|
|
341
483
|
area: z.enum(['header', 'footer', 'social']).optional(),
|
|
342
484
|
}),
|
|
343
485
|
handler: async ({ id, ...data }, ctx) => {
|
|
344
|
-
requireAdmin(ctx);
|
|
486
|
+
await requireAdmin(ctx);
|
|
345
487
|
const repo = await getRepo(ctx.locals);
|
|
346
488
|
await repo.updateNavItem(id, data);
|
|
347
489
|
return { ok: true };
|
|
@@ -350,7 +492,7 @@ export const server = {
|
|
|
350
492
|
reorder: defineAction({
|
|
351
493
|
input: z.object({ ids: z.array(z.string()) }),
|
|
352
494
|
handler: async ({ ids }, ctx) => {
|
|
353
|
-
requireAdmin(ctx);
|
|
495
|
+
await requireAdmin(ctx);
|
|
354
496
|
const repo = await getRepo(ctx.locals);
|
|
355
497
|
await repo.reorderNavItems(ids);
|
|
356
498
|
return { ok: true };
|
|
@@ -359,7 +501,7 @@ export const server = {
|
|
|
359
501
|
delete: defineAction({
|
|
360
502
|
input: z.object({ id: z.string() }),
|
|
361
503
|
handler: async ({ id }, ctx) => {
|
|
362
|
-
requireAdmin(ctx);
|
|
504
|
+
await requireAdmin(ctx);
|
|
363
505
|
const repo = await getRepo(ctx.locals);
|
|
364
506
|
await repo.deleteNavItem(id);
|
|
365
507
|
return { ok: true };
|
|
@@ -383,14 +525,15 @@ export const server = {
|
|
|
383
525
|
siteCover: z.string().optional(),
|
|
384
526
|
}),
|
|
385
527
|
handler: async (data, ctx) => {
|
|
386
|
-
requireAdmin(ctx);
|
|
528
|
+
await requireAdmin(ctx);
|
|
387
529
|
const repo = await getRepo(ctx.locals);
|
|
388
530
|
await repo.updateSettings(data);
|
|
389
531
|
return { ok: true };
|
|
390
532
|
},
|
|
391
533
|
}),
|
|
392
|
-
// Design-screen staging: edits stage in settings.draftDesign (
|
|
393
|
-
//
|
|
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.
|
|
394
537
|
saveDesignDraft: defineAction({
|
|
395
538
|
input: z.object({
|
|
396
539
|
activeTheme: z.string().optional(),
|
|
@@ -400,37 +543,181 @@ export const server = {
|
|
|
400
543
|
siteCover: z.string().optional(),
|
|
401
544
|
}),
|
|
402
545
|
handler: async (data, ctx) => {
|
|
403
|
-
requireAdmin(ctx);
|
|
546
|
+
await requireAdmin(ctx);
|
|
404
547
|
const repo = await getRepo(ctx.locals);
|
|
405
548
|
await repo.saveDesignDraft(data);
|
|
406
|
-
|
|
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 };
|
|
407
572
|
},
|
|
408
573
|
}),
|
|
409
574
|
applyDesign: defineAction({
|
|
410
575
|
input: z.object({}),
|
|
411
576
|
handler: async (_input, ctx) => {
|
|
412
|
-
|
|
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.' });
|
|
413
579
|
const repo = await getRepo(ctx.locals);
|
|
414
|
-
await repo
|
|
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
|
+
}
|
|
415
591
|
return { ok: true };
|
|
416
592
|
},
|
|
417
593
|
}),
|
|
418
594
|
discardDesign: defineAction({
|
|
419
595
|
input: z.object({}),
|
|
420
596
|
handler: async (_input, ctx) => {
|
|
421
|
-
requireAdmin(ctx);
|
|
597
|
+
await requireAdmin(ctx);
|
|
422
598
|
const repo = await getRepo(ctx.locals);
|
|
423
599
|
await repo.discardDesignDraft();
|
|
424
600
|
return { ok: true };
|
|
425
601
|
},
|
|
426
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
|
+
}),
|
|
427
714
|
},
|
|
428
715
|
|
|
429
716
|
tags: {
|
|
430
717
|
list: defineAction({
|
|
431
718
|
input: z.object({}),
|
|
432
719
|
handler: async (_input, ctx) => {
|
|
433
|
-
requireAdmin(ctx);
|
|
720
|
+
await requireAdmin(ctx);
|
|
434
721
|
const repo = await getRepo(ctx.locals);
|
|
435
722
|
return { tags: await repo.listTags() };
|
|
436
723
|
},
|
|
@@ -441,7 +728,7 @@ export const server = {
|
|
|
441
728
|
list: defineAction({
|
|
442
729
|
input: z.object({}),
|
|
443
730
|
handler: async (_input, ctx) => {
|
|
444
|
-
requireAdmin(ctx);
|
|
731
|
+
await requireAdmin(ctx);
|
|
445
732
|
const repo = await getRepo(ctx.locals);
|
|
446
733
|
return { snippets: await repo.listSnippets() };
|
|
447
734
|
},
|
|
@@ -449,7 +736,7 @@ export const server = {
|
|
|
449
736
|
create: defineAction({
|
|
450
737
|
input: z.object({ name: z.string().min(1).max(80), contentJson: z.string().min(2) }),
|
|
451
738
|
handler: async ({ name, contentJson }, ctx) => {
|
|
452
|
-
requireAdmin(ctx);
|
|
739
|
+
await requireAdmin(ctx);
|
|
453
740
|
// must be a JSON array of ProseMirror nodes — reject anything else
|
|
454
741
|
// before it can break the editor on re-insert
|
|
455
742
|
let parsed: unknown;
|
|
@@ -464,7 +751,7 @@ export const server = {
|
|
|
464
751
|
delete: defineAction({
|
|
465
752
|
input: z.object({ id: z.string() }),
|
|
466
753
|
handler: async ({ id }, ctx) => {
|
|
467
|
-
requireAdmin(ctx);
|
|
754
|
+
await requireAdmin(ctx);
|
|
468
755
|
const repo = await getRepo(ctx.locals);
|
|
469
756
|
await repo.deleteSnippet(id);
|
|
470
757
|
return { ok: true };
|
|
@@ -476,7 +763,7 @@ export const server = {
|
|
|
476
763
|
list: defineAction({
|
|
477
764
|
input: z.object({}),
|
|
478
765
|
handler: async (_input, ctx) => {
|
|
479
|
-
requireAdmin(ctx);
|
|
766
|
+
await requireAdmin(ctx);
|
|
480
767
|
const repo = await getRepo(ctx.locals);
|
|
481
768
|
return { users: await repo.listUsers() };
|
|
482
769
|
},
|
|
@@ -490,7 +777,7 @@ export const server = {
|
|
|
490
777
|
search: defineAction({
|
|
491
778
|
input: z.object({ query: z.string().min(1) }),
|
|
492
779
|
handler: async ({ query }, ctx) => {
|
|
493
|
-
requireAdmin(ctx);
|
|
780
|
+
await requireAdmin(ctx);
|
|
494
781
|
const repo = await getRepo(ctx.locals);
|
|
495
782
|
const q = query.toLowerCase();
|
|
496
783
|
const [posts, pages] = await Promise.all([repo.listPosts(), repo.listPages()]);
|
|
@@ -518,7 +805,7 @@ export const server = {
|
|
|
518
805
|
fetch: defineAction({
|
|
519
806
|
input: z.object({ url: z.string().url() }),
|
|
520
807
|
handler: async ({ url }, ctx) => {
|
|
521
|
-
requireAdmin(ctx);
|
|
808
|
+
await requireAdmin(ctx);
|
|
522
809
|
// redirects are followed manually so EVERY hop is re-checked against
|
|
523
810
|
// the private-host guard (a public URL 302ing to 127.0.0.1 or a cloud
|
|
524
811
|
// metadata IP must not slip through)
|
|
@@ -586,9 +873,20 @@ export const server = {
|
|
|
586
873
|
preview: defineAction({
|
|
587
874
|
input: z.object({ sources: z.array(z.string()).min(1).max(6) }),
|
|
588
875
|
handler: async ({ sources }, ctx) => {
|
|
589
|
-
requireAdmin(ctx);
|
|
876
|
+
await requireAdmin(ctx);
|
|
590
877
|
const normalized: string[] = [];
|
|
878
|
+
const repo0 = await getRepo(ctx.locals);
|
|
879
|
+
const siteCalendars = await repo0.listCalendars();
|
|
591
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
|
+
}
|
|
592
890
|
const url = normalizeCalendarSrc(raw);
|
|
593
891
|
if (!url) {
|
|
594
892
|
throw new ActionError({
|
|
@@ -602,12 +900,18 @@ export const server = {
|
|
|
602
900
|
const settings = await repo.getSettings();
|
|
603
901
|
const tz = settings.timezone || 'UTC';
|
|
604
902
|
const now = Date.now();
|
|
605
|
-
const {
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
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] });
|
|
611
915
|
const locale = siteLocale(settings);
|
|
612
916
|
const dayFmt = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz });
|
|
613
917
|
const timeFmt = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
|
|
@@ -619,7 +923,231 @@ export const server = {
|
|
|
619
923
|
title: e.title,
|
|
620
924
|
cal: e.cal,
|
|
621
925
|
}));
|
|
622
|
-
|
|
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 };
|
|
623
1151
|
},
|
|
624
1152
|
}),
|
|
625
1153
|
},
|
|
@@ -628,7 +1156,7 @@ export const server = {
|
|
|
628
1156
|
search: defineAction({
|
|
629
1157
|
input: z.object({ query: z.string().min(1), page: z.number().int().min(1).default(1) }),
|
|
630
1158
|
handler: async ({ query, page }, ctx) => {
|
|
631
|
-
requireAdmin(ctx);
|
|
1159
|
+
await requireAdmin(ctx);
|
|
632
1160
|
const key = env('UNSPLASH_ACCESS_KEY');
|
|
633
1161
|
if (!key) throw new ActionError({ code: 'PRECONDITION_FAILED', message: 'Unsplash is not configured.' });
|
|
634
1162
|
const res = await fetch(
|
|
@@ -669,7 +1197,7 @@ export const server = {
|
|
|
669
1197
|
track: defineAction({
|
|
670
1198
|
input: z.object({ downloadLocation: z.string().url() }),
|
|
671
1199
|
handler: async ({ downloadLocation }, ctx) => {
|
|
672
|
-
requireAdmin(ctx);
|
|
1200
|
+
await requireAdmin(ctx);
|
|
673
1201
|
const key = env('UNSPLASH_ACCESS_KEY');
|
|
674
1202
|
if (!key || !downloadLocation.startsWith('https://api.unsplash.com/')) return { ok: false };
|
|
675
1203
|
await fetch(downloadLocation, { headers: { Authorization: `Client-ID ${key}` } }).catch(() => {});
|