@acms-subir/core 0.3.3-next.20260929113209 → 0.3.4
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 +105 -0
- package/cli/common.mjs +2 -2
- package/cli/legacy.mjs +1 -1
- package/cli/main.mjs +82 -8
- package/cli/mobile-doctor.mjs +430 -0
- package/cli/mobile.mjs +315 -0
- package/cli/native-sections.mjs +168 -0
- package/cli/preview.mjs +8 -4
- package/cli/sections.mjs +188 -0
- package/cli/themes.mjs +52 -8
- package/docs/mobile.md +527 -0
- package/docs/sections.md +203 -0
- package/docs/theme-authoring.md +19 -3
- package/integration.mjs +76 -5
- package/mobile-template/client/AGENTS.md +97 -0
- package/mobile-template/client/README.md +65 -0
- package/mobile-template/client/app/_layout.tsx +56 -0
- package/mobile-template/client/app/index.tsx +21 -0
- package/mobile-template/client/app.json +21 -0
- package/mobile-template/client/components/LanguageSwitch.tsx +31 -0
- package/mobile-template/client/eas.json +21 -0
- package/mobile-template/client/env.example +3 -0
- package/mobile-template/client/gitignore +42 -0
- package/mobile-template/client/package.json +38 -0
- package/mobile-template/client/sections/index.ts +11 -0
- package/mobile-template/client/tsconfig.json +7 -0
- package/mobile-template/editor/AGENTS.md +66 -0
- package/mobile-template/editor/README.md +73 -0
- package/mobile-template/editor/app/_layout.tsx +76 -0
- package/mobile-template/editor/app/auth.tsx +13 -0
- package/mobile-template/editor/app/index.tsx +55 -0
- package/mobile-template/editor/app.json +21 -0
- package/mobile-template/editor/components/Screen.tsx +51 -0
- package/mobile-template/editor/components/SignIn.tsx +36 -0
- package/mobile-template/editor/components/TextTabs.tsx +27 -0
- package/mobile-template/editor/eas.json +21 -0
- package/mobile-template/editor/env.example +5 -0
- package/mobile-template/editor/gitignore +42 -0
- package/mobile-template/editor/lib/appearance.tsx +12 -0
- package/mobile-template/editor/lib/config.ts +17 -0
- package/mobile-template/editor/lib/format.ts +24 -0
- package/mobile-template/editor/lib/tools.ts +16 -0
- package/mobile-template/editor/package.json +40 -0
- package/mobile-template/editor/tools/README.md +2 -0
- package/mobile-template/editor/tsconfig.json +7 -0
- package/mobile-template/recipes/booking/files/app/book/[resource].tsx +57 -0
- package/mobile-template/recipes/booking/files/app/book/details.tsx +105 -0
- package/mobile-template/recipes/booking/files/app/book/index.tsx +38 -0
- package/mobile-template/recipes/booking/recipe.json +17 -0
- package/mobile-template/recipes/editor-bookings/files/app/bookings/index.tsx +113 -0
- package/mobile-template/recipes/editor-bookings/files/tools/bookings.ts +1 -0
- package/mobile-template/recipes/editor-bookings/recipe.json +8 -0
- package/mobile-template/recipes/editor-content/files/app/content/[kind]/[id].tsx +13 -0
- package/mobile-template/recipes/editor-content/files/app/content/index.tsx +76 -0
- package/mobile-template/recipes/editor-content/files/components/DocumentDetail.tsx +55 -0
- package/mobile-template/recipes/editor-content/files/tools/content.ts +1 -0
- package/mobile-template/recipes/editor-content/recipe.json +8 -0
- package/mobile-template/recipes/editor-media/files/app/media/index.tsx +73 -0
- package/mobile-template/recipes/editor-media/files/tools/media.ts +1 -0
- package/mobile-template/recipes/editor-media/recipe.json +8 -0
- package/mobile-template/recipes/editor-sections/files/app/sections/[pageId]/[sectionId].tsx +12 -0
- package/mobile-template/recipes/editor-sections/files/app/sections/index.tsx +48 -0
- package/mobile-template/recipes/editor-sections/files/components/SectionEditorPane.tsx +77 -0
- package/mobile-template/recipes/editor-sections/files/tools/sections.ts +1 -0
- package/mobile-template/recipes/editor-sections/recipe.json +8 -0
- package/mobile-template/recipes/editor-translations/files/app/translations/index.tsx +63 -0
- package/mobile-template/recipes/editor-translations/files/app/translations/review.tsx +14 -0
- package/mobile-template/recipes/editor-translations/files/components/ReviewPane.tsx +72 -0
- package/mobile-template/recipes/editor-translations/files/tools/translations.ts +1 -0
- package/mobile-template/recipes/editor-translations/recipe.json +8 -0
- package/mobile-template/recipes/editor-writer/files/app/write/[kind]/[id].tsx +132 -0
- package/mobile-template/recipes/editor-writer/files/app/write/new.tsx +61 -0
- package/mobile-template/recipes/editor-writer/files/tools/writer.ts +1 -0
- package/mobile-template/recipes/editor-writer/recipe.json +8 -0
- package/mobile-template/recipes/events/files/app/events/[slug].tsx +41 -0
- package/mobile-template/recipes/events/files/app/events/index.tsx +50 -0
- package/mobile-template/recipes/events/files/lib/addToCalendar.ts +27 -0
- package/mobile-template/recipes/events/recipe.json +15 -0
- package/mobile-template/recipes/news/files/app/news/[slug].tsx +25 -0
- package/mobile-template/recipes/news/files/app/news/index.tsx +50 -0
- package/mobile-template/recipes/news/recipe.json +13 -0
- package/mobile-template/recipes/push/files/app/notifications.tsx +64 -0
- package/mobile-template/recipes/push/files/components/PushSetup.tsx +15 -0
- package/mobile-template/recipes/push/files/lib/push.ts +15 -0
- package/mobile-template/recipes/push/recipe.json +20 -0
- package/package.json +17 -13
- package/section-library/README.md +16 -0
- package/section-library/contact/Contact.astro +49 -0
- package/section-library/contact/section.json +106 -0
- package/section-library/cta-band/CtaBand.astro +28 -0
- package/section-library/cta-band/section.json +51 -0
- package/section-library/feature-grid/FeatureGrid.astro +54 -0
- package/section-library/feature-grid/section.json +107 -0
- package/section-library/gallery/Gallery.astro +29 -0
- package/section-library/gallery/section.json +51 -0
- package/section-library/hero-banner/HeroBanner.astro +42 -0
- package/section-library/hero-banner/section.json +81 -0
- package/section-library/latest-posts/LatestPosts.astro +22 -0
- package/section-library/latest-posts/section.json +37 -0
- package/section-library/pricing/Pricing.astro +40 -0
- package/section-library/pricing/section.json +117 -0
- package/section-library/questions/Questions.astro +38 -0
- package/section-library/questions/section.json +69 -0
- package/section-library/service-times/ServiceTimes.astro +42 -0
- package/section-library/service-times/section.json +127 -0
- package/section-library/team/Team.astro +45 -0
- package/section-library/team/section.json +88 -0
- package/section-library/upcoming-events/UpcomingEvents.astro +54 -0
- package/section-library/upcoming-events/section.json +51 -0
- package/src/actions/index.ts +205 -174
- package/src/components/AiSettings.tsx +45 -5
- package/src/components/DesignManager.tsx +112 -16
- package/src/components/MobileApp.tsx +392 -0
- package/src/components/ThemeSections.astro +40 -21
- package/src/components/ThemeSectionsEditor.tsx +757 -229
- package/src/components/booking/BookingFlow.astro +6 -3
- package/src/components/calendar/BookingsHead.astro +51 -0
- package/src/components/calendar/CalendarBoard.tsx +618 -0
- package/src/components/calendar/MiniMonth.tsx +54 -0
- package/src/components/calendar/NewEventDialog.tsx +44 -0
- package/src/components/calendar/QuickAdd.tsx +263 -0
- package/src/components/calendar/RoomHolds.tsx +203 -0
- package/src/components/calendar/WhereItLands.tsx +51 -0
- package/src/components/editor/EventPanel.tsx +23 -2
- package/src/components/editor/PostEditor.tsx +206 -14
- package/src/components/import/ImportExport.tsx +4 -1
- package/src/components/import/runner.ts +1 -0
- package/src/components/sections/SectionFields.tsx +560 -0
- package/src/components/sections/SectionPicker.tsx +120 -0
- package/src/components/sections/TranslationPanel.tsx +143 -0
- package/src/components/sections/checks.ts +64 -0
- package/src/layouts/Admin.astro +11 -7
- package/src/layouts/Site.astro +136 -21
- package/src/lib/agent-discovery.ts +11 -7
- package/src/lib/agent.ts +35 -2
- package/src/lib/ai-schema.ts +5 -0
- package/src/lib/ai.ts +5 -0
- package/src/lib/api.ts +12 -0
- package/src/lib/backup/content.ts +6 -1
- package/src/lib/booking/agent.ts +3 -2
- package/src/lib/booking/approval.ts +70 -0
- package/src/lib/booking/holds.ts +120 -0
- package/src/lib/booking/service.ts +37 -3
- package/src/lib/calendar/agent-schema.ts +9 -1
- package/src/lib/calendar/agent.ts +16 -1
- package/src/lib/calendar/board.ts +171 -0
- package/src/lib/calendar/config.ts +3 -0
- package/src/lib/calendar/grid.ts +169 -0
- package/src/lib/calendar/migration.ts +16 -0
- package/src/lib/calendar/render.ts +9 -7
- package/src/lib/calendar/schedule.ts +166 -0
- package/src/lib/content-service.ts +203 -0
- package/src/lib/design-service.ts +153 -5
- package/src/lib/drafts.ts +3 -3
- package/src/lib/editor/auth.ts +264 -0
- package/src/lib/editor/bookings.ts +60 -0
- package/src/lib/editor/config.ts +25 -0
- package/src/lib/editor/documents.ts +227 -0
- package/src/lib/editor/http.ts +48 -0
- package/src/lib/editor/me.ts +30 -0
- package/src/lib/editor/migration.ts +57 -0
- package/src/lib/editor/next.ts +11 -0
- package/src/lib/editor/openapi.ts +124 -0
- package/src/lib/editor/schemas.ts +91 -0
- package/src/lib/editor/sections.ts +96 -0
- package/src/lib/editor/store.ts +184 -0
- package/src/lib/editor/token-route.ts +74 -0
- package/src/lib/editor/translations.ts +163 -0
- package/src/lib/email/accounts.ts +2 -1
- package/src/lib/email/types.ts +2 -1
- package/src/lib/home-source.ts +34 -0
- package/src/lib/i18n.ts +34 -0
- package/src/lib/import/apply.ts +31 -2
- package/src/lib/import/bundle.ts +4 -0
- package/src/lib/import/export.ts +1 -0
- package/src/lib/import/ghost.ts +1 -0
- package/src/lib/media/upload.ts +83 -0
- package/src/lib/mobile/associations.ts +25 -0
- package/src/lib/mobile/calendar.ts +26 -14
- package/src/lib/mobile/config.mjs +59 -0
- package/src/lib/mobile/content.ts +18 -13
- package/src/lib/mobile/embed.ts +83 -0
- package/src/lib/mobile/http.ts +14 -6
- package/src/lib/mobile/locale.ts +18 -0
- package/src/lib/mobile/openapi.ts +111 -0
- package/src/lib/mobile/pages.ts +71 -0
- package/src/lib/mobile/route.ts +3 -3
- package/src/lib/mobile/schemas.ts +60 -0
- package/src/lib/mobile/site.ts +41 -0
- package/src/lib/push/api.ts +158 -0
- package/src/lib/push/config.ts +20 -0
- package/src/lib/push/expo.ts +76 -0
- package/src/lib/push/migration.ts +60 -0
- package/src/lib/push/service.ts +302 -0
- package/src/lib/push/store.ts +144 -0
- package/src/lib/push/types.ts +43 -0
- package/src/lib/repo/migrations.ts +14 -0
- package/src/lib/repo/sql.ts +65 -20
- package/src/lib/repo/types.ts +10 -4
- package/src/lib/roles.ts +5 -0
- package/src/lib/scheduler.ts +6 -1
- package/src/lib/schema.ts +76 -1
- package/src/lib/section-ai.ts +55 -0
- package/src/lib/section-targets.ts +62 -0
- package/src/lib/sections-agent.ts +146 -0
- package/src/lib/sections.ts +242 -0
- package/src/lib/site-chrome.ts +34 -0
- package/src/lib/theme-assets.ts +16 -0
- package/src/lib/theme.ts +33 -4
- package/src/lib/translate.ts +97 -0
- package/src/lib/translations.ts +39 -0
- package/src/middleware.ts +38 -2
- package/src/pages/.well-known/apple-app-site-association.ts +6 -0
- package/src/pages/.well-known/assetlinks.json.ts +6 -0
- package/src/pages/[...slug].astro +8 -3
- package/src/pages/admin/app-auth.astro +159 -0
- package/src/pages/admin/c/[collection]/[id].astro +9 -0
- package/src/pages/admin/calendar/bookings.astro +32 -26
- package/src/pages/admin/calendar/calendars.astro +4 -263
- package/src/pages/admin/calendar/events/[id].astro +9 -0
- package/src/pages/admin/calendar/events/index.astro +41 -83
- package/src/pages/admin/calendar/index.astro +43 -0
- package/src/pages/admin/calendar/resources/[id].astro +27 -5
- package/src/pages/admin/calendar/resources/index.astro +16 -17
- package/src/pages/admin/calendar/resources/link.astro +52 -12
- package/src/pages/admin/calendar/settings.astro +458 -0
- package/src/pages/admin/integrations.astro +5 -11
- package/src/pages/admin/login.astro +6 -2
- package/src/pages/admin/logout.ts +5 -2
- package/src/pages/admin/mobile.astro +18 -0
- package/src/pages/admin/pages/[id].astro +62 -0
- package/src/pages/admin/section-preview.astro +61 -0
- package/src/pages/admin/users.astro +107 -11
- package/src/pages/api/agent/docs.ts +6 -2
- package/src/pages/api/agent/index.ts +1 -1
- package/src/pages/api/ai/settings.ts +7 -4
- package/src/pages/api/design/ai.ts +15 -0
- package/src/pages/api/design/index.ts +12 -42
- package/src/pages/api/design/translate.ts +15 -0
- package/src/pages/api/live/thumb.ts +1 -1
- package/src/pages/api/mcp.ts +2 -2
- package/src/pages/api/media/upload.ts +10 -78
- package/src/pages/api/push/admin.ts +27 -0
- package/src/pages/api/v1/admin/bookings/[id]/[action].ts +14 -0
- package/src/pages/api/v1/admin/bookings/index.ts +6 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +17 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +20 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +26 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +18 -0
- package/src/pages/api/v1/admin/documents/index.ts +13 -0
- package/src/pages/api/v1/admin/me.ts +6 -0
- package/src/pages/api/v1/admin/media/index.ts +32 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/index.ts +14 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/publish.ts +14 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/[action].ts +22 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/index.ts +14 -0
- package/src/pages/api/v1/admin/sections/index.ts +6 -0
- package/src/pages/api/v1/auth/revoke.ts +5 -0
- package/src/pages/api/v1/auth/token.ts +5 -0
- package/src/pages/api/v1/devices/[token].ts +20 -0
- package/src/pages/api/v1/devices/index.ts +17 -0
- package/src/pages/api/v1/openapi.json.ts +7 -0
- package/src/pages/api/v1/pages/[...slug].ts +7 -0
- package/src/pages/api/v1/pages/home.ts +7 -0
- package/src/pages/api/v1/push/topics.ts +7 -0
- package/src/pages/api/v1/site.ts +7 -0
- package/src/pages/api/v1/theme.ts +7 -0
- package/src/pages/blog/[slug].astro +8 -2
- package/src/pages/blog/index.astro +11 -4
- package/src/pages/book/[resource].ics.ts +7 -1
- package/src/pages/embed/section/[...path].astro +42 -0
- package/src/pages/events/[slug].astro +7 -2
- package/src/pages/index.astro +11 -5
- package/src/pages/sitemap.xml.ts +8 -0
- package/src/styles/admin.css +117 -0
- package/src/styles/cards.css +5 -1
- package/src/styles/editor.css +57 -0
- package/src/styles/mobile-app.css +151 -0
- package/src/styles/schedule.css +325 -0
- package/src/styles/sections.css +21 -12
- package/src/styles/theme-editor.css +255 -0
- package/src/theme-sdk/contract.d.mts +2193 -89
- package/src/theme-sdk/contract.mjs +77 -20
- package/src/theme-sdk/fields.d.mts +88 -0
- package/src/theme-sdk/fields.mjs +330 -0
- package/src/theme-sdk/index.ts +14 -2
- package/src/theme-sdk/tokens.d.mts +11 -0
- package/src/virtual.d.ts +4 -0
- package/src/pages/admin/calendar/index.ts +0 -4
package/src/actions/index.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { ActionAPIContext } from 'astro:actions';
|
|
|
7
7
|
import { getRepo } from '../lib/repo';
|
|
8
8
|
import { getMediaStore } from '../lib/media/store';
|
|
9
9
|
import { validateSession, SESSION_COOKIE } from '../lib/auth';
|
|
10
|
-
import { LinkKind, parseConfig, slugify, widgetTypes, type WidgetType } from '../lib/schema';
|
|
10
|
+
import { DocTranslations, LinkKind, parseConfig, slugify, widgetTypes, type WidgetType } from '../lib/schema';
|
|
11
11
|
import { env } from '../lib/env';
|
|
12
12
|
import { CONTENT, collectionUrl } from '../lib/content';
|
|
13
13
|
import { invalidateRedirectCache } from '../middleware';
|
|
@@ -16,20 +16,26 @@ import { normalizeCalendarSrc } from '../lib/calendar/src';
|
|
|
16
16
|
import { getCalendarEvents } from '../lib/calendar/ics';
|
|
17
17
|
import { siteLocale } from '../lib/dates';
|
|
18
18
|
import { normalizePageLocale, localeHomeUrl } from '../lib/i18n';
|
|
19
|
-
import { CALENDAR } from '../lib/calendar/config';
|
|
19
|
+
import { CALENDAR, bookingsEnabled } from '../lib/calendar/config';
|
|
20
|
+
import { holdConflicts } from '../lib/booking/holds';
|
|
21
|
+
import { setBookingStatus } from '../lib/booking/service';
|
|
20
22
|
import { eventTimeZone, featuredOccurrence, formatOccurrence, isValidTimeZone, normalizeEventTimes, safeColor } from '../lib/calendar/events';
|
|
21
23
|
import { eventsFromCsv, eventsFromIcs, eventsFromVEvents } from '../lib/calendar/import';
|
|
22
24
|
import { loadFeed } from '../lib/calendar/ics';
|
|
23
25
|
import { NATIVE_ALL } from '../lib/calendar/render';
|
|
24
26
|
import { collectViewEvents } from '../lib/calendar/data';
|
|
27
|
+
import { loadBoard } from '../lib/calendar/board';
|
|
28
|
+
import { duplicateEvent, moveOccurrence, moveSeries, quickCreate, undoMove } from '../lib/calendar/schedule';
|
|
25
29
|
import { waitUntilFrom } from '../lib/webhooks';
|
|
26
30
|
import { eventChanged, eventDeleted } from '../lib/calendar/hooks';
|
|
27
31
|
import { siteOrigin } from '../lib/seo';
|
|
28
32
|
import type { CalEvent } from '../lib/schema';
|
|
33
|
+
import { CalEvent as CalEventSchema } from '../lib/schema';
|
|
29
34
|
import { diffDesign, revertDesignChange, summarizeChanges } from '../lib/designDiff';
|
|
30
35
|
import { shareEpoch } from '../lib/drafts';
|
|
31
36
|
import { createDesignToken } from '../lib/preview';
|
|
32
37
|
import { getTheme } from '../lib/theme';
|
|
38
|
+
import { savePost, applyPostUpdate, savePage, applyPageUpdate, saveEvent, applyEventUpdate, type SaveHooks } from '../lib/content-service';
|
|
33
39
|
|
|
34
40
|
// "What changes" for the Design screen: the staged draft vs the live design
|
|
35
41
|
async function designChanges(repo: Awaited<ReturnType<typeof getRepo>>) {
|
|
@@ -65,79 +71,16 @@ const BlockInput = z.object({
|
|
|
65
71
|
|
|
66
72
|
// ---- calendar helpers ------------------------------------------------------
|
|
67
73
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
);
|
|
74
|
+
// Save/Update logic is shared with the editor-app API (lib/content-service).
|
|
75
|
+
// Its ApiErrors become ActionErrors with the same message.
|
|
76
|
+
function asActionError(error: unknown): never {
|
|
77
|
+
if (error instanceof ApiError) {
|
|
78
|
+
const code = error.status === 404 ? 'NOT_FOUND' : error.status === 403 ? 'FORBIDDEN' : error.status === 409 ? 'CONFLICT' : error.status === 401 ? 'UNAUTHORIZED' : 'BAD_REQUEST';
|
|
79
|
+
throw new ActionError({ code, message: error.message });
|
|
108
80
|
}
|
|
109
|
-
|
|
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
|
-
});
|
|
81
|
+
throw error;
|
|
140
82
|
}
|
|
83
|
+
const hooksFor = (ctx: ActionAPIContext): SaveHooks => ({ url: ctx.url, locals: ctx.locals, onRedirect: invalidateRedirectCache });
|
|
141
84
|
|
|
142
85
|
async function importEvents(
|
|
143
86
|
repo: Awaited<ReturnType<typeof getRepo>>,
|
|
@@ -178,6 +121,7 @@ export const server = {
|
|
|
178
121
|
coverAlt: z.string().optional(),
|
|
179
122
|
seoTitle: z.string().optional(),
|
|
180
123
|
seoDesc: z.string().optional(),
|
|
124
|
+
translations: DocTranslations.optional(),
|
|
181
125
|
status: StatusInput.optional(),
|
|
182
126
|
featured: z.boolean().optional(),
|
|
183
127
|
authorId: z.string().nullable().optional(),
|
|
@@ -189,38 +133,7 @@ export const server = {
|
|
|
189
133
|
handler: async ({ id, ...data }, ctx) => {
|
|
190
134
|
await requireAdmin(ctx);
|
|
191
135
|
const repo = await getRepo(ctx.locals);
|
|
192
|
-
|
|
193
|
-
if (data.collection !== undefined && !CONTENT.collections[data.collection]) {
|
|
194
|
-
throw new ActionError({ code: 'BAD_REQUEST', message: `Unknown collection "${data.collection}".` });
|
|
195
|
-
}
|
|
196
|
-
const before = await repo.getPost(id);
|
|
197
|
-
if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Post not found.' });
|
|
198
|
-
// A live (published/scheduled) post never takes content edits
|
|
199
|
-
// directly: they stage into the pending draft until the user hits
|
|
200
|
-
// Update. Status transitions stay deliberate, immediate writes.
|
|
201
|
-
const transitions = data.status !== undefined && data.status !== before.status;
|
|
202
|
-
if ((before.status === 'published' || before.status === 'scheduled') && !transitions) {
|
|
203
|
-
const { status: _status, scheduledAt: _scheduledAt, authorId: _authorId, ...staged } = data;
|
|
204
|
-
await repo.savePostDraft(id, staged);
|
|
205
|
-
return { post: (await repo.getPost(id))! };
|
|
206
|
-
}
|
|
207
|
-
await repo.updatePost(id, data);
|
|
208
|
-
// any new tag names become registry entries with slugs for archives
|
|
209
|
-
if (data.tags !== undefined) await repo.ensureTags(data.tags);
|
|
210
|
-
const post = await repo.getPost(id);
|
|
211
|
-
if (!post) throw new ActionError({ code: 'NOT_FOUND', message: 'Post not found.' });
|
|
212
|
-
// a published post whose slug or collection changed leaves a 301
|
|
213
|
-
// behind so old links keep working
|
|
214
|
-
if (before.status === 'published' && (before.slug !== post.slug || before.collection !== post.collection)) {
|
|
215
|
-
await repo
|
|
216
|
-
.createRedirect({
|
|
217
|
-
fromPath: collectionUrl(before.collection, before.slug),
|
|
218
|
-
toPath: collectionUrl(post.collection, post.slug),
|
|
219
|
-
})
|
|
220
|
-
.catch(() => {});
|
|
221
|
-
invalidateRedirectCache();
|
|
222
|
-
}
|
|
223
|
-
return { post };
|
|
136
|
+
try { return { post: await savePost(repo, id, data, hooksFor(ctx)) }; } catch (e) { asActionError(e); }
|
|
224
137
|
},
|
|
225
138
|
}),
|
|
226
139
|
// Update button: copy the pending draft onto the live columns. The
|
|
@@ -231,21 +144,7 @@ export const server = {
|
|
|
231
144
|
handler: async ({ id }, ctx) => {
|
|
232
145
|
await requireAdmin(ctx);
|
|
233
146
|
const repo = await getRepo(ctx.locals);
|
|
234
|
-
|
|
235
|
-
if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Post not found.' });
|
|
236
|
-
await repo.applyPostDraft(id);
|
|
237
|
-
const post = (await repo.getPost(id))!;
|
|
238
|
-
if (post.tags.length) await repo.ensureTags(post.tags);
|
|
239
|
-
if (before.status === 'published' && (before.slug !== post.slug || before.collection !== post.collection)) {
|
|
240
|
-
await repo
|
|
241
|
-
.createRedirect({
|
|
242
|
-
fromPath: collectionUrl(before.collection, before.slug),
|
|
243
|
-
toPath: collectionUrl(post.collection, post.slug),
|
|
244
|
-
})
|
|
245
|
-
.catch(() => {});
|
|
246
|
-
invalidateRedirectCache();
|
|
247
|
-
}
|
|
248
|
-
return { post };
|
|
147
|
+
try { return { post: await applyPostUpdate(repo, id, hooksFor(ctx)) }; } catch (e) { asActionError(e); }
|
|
249
148
|
},
|
|
250
149
|
}),
|
|
251
150
|
discardChanges: defineAction({
|
|
@@ -281,6 +180,7 @@ export const server = {
|
|
|
281
180
|
locale: z.string().optional(),
|
|
282
181
|
seoTitle: z.string().optional(),
|
|
283
182
|
seoDesc: z.string().optional(),
|
|
183
|
+
translations: DocTranslations.optional(),
|
|
284
184
|
template: z.string().optional(),
|
|
285
185
|
bodyJson: z.string().optional(),
|
|
286
186
|
bodyHtml: z.string().optional(),
|
|
@@ -289,33 +189,13 @@ export const server = {
|
|
|
289
189
|
handler: async ({ id, blocks, ...data }, ctx) => {
|
|
290
190
|
await requireAdmin(ctx);
|
|
291
191
|
const repo = await getRepo(ctx.locals);
|
|
292
|
-
if (data.slug !== undefined) data.slug = slugify(data.slug);
|
|
293
|
-
// default locale stores as '', unknown codes fall back to it
|
|
294
|
-
if (data.locale !== undefined) data.locale = normalizePageLocale(data.locale);
|
|
295
|
-
const before = await repo.getPage(id);
|
|
296
|
-
if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Page not found.' });
|
|
297
192
|
const cleanBlocks =
|
|
298
193
|
blocks === undefined
|
|
299
194
|
? undefined
|
|
300
195
|
: blocks
|
|
301
196
|
.filter((b) => widgetTypes.includes(b.type as WidgetType))
|
|
302
197
|
.map((b) => ({ ...b, type: b.type as WidgetType, config: parseConfig(b.type as WidgetType, b.config) as Record<string, unknown> }));
|
|
303
|
-
|
|
304
|
-
const transitions = data.status !== undefined && data.status !== before.status;
|
|
305
|
-
if ((before.status === 'published' || before.status === 'scheduled') && !transitions) {
|
|
306
|
-
const { status: _status, ...staged } = data;
|
|
307
|
-
await repo.savePageDraft(id, { ...staged, blocks: cleanBlocks });
|
|
308
|
-
return { page: (await repo.getPage(id))! };
|
|
309
|
-
}
|
|
310
|
-
await repo.updatePage(id, { ...data, blocks: cleanBlocks });
|
|
311
|
-
const page = await repo.getPage(id);
|
|
312
|
-
if (!page) throw new ActionError({ code: 'NOT_FOUND', message: 'Page not found.' });
|
|
313
|
-
// renaming a published slug leaves a 301 behind so old links keep working
|
|
314
|
-
if (before.status === 'published' && before.slug !== page.slug && !page.isHome) {
|
|
315
|
-
await repo.createRedirect({ fromPath: `/${before.slug}`, toPath: `/${page.slug}` }).catch(() => {});
|
|
316
|
-
invalidateRedirectCache();
|
|
317
|
-
}
|
|
318
|
-
return { page };
|
|
198
|
+
try { return { page: await savePage(repo, id, data, cleanBlocks, hooksFor(ctx)) }; } catch (e) { asActionError(e); }
|
|
319
199
|
},
|
|
320
200
|
}),
|
|
321
201
|
applyUpdate: defineAction({
|
|
@@ -323,15 +203,7 @@ export const server = {
|
|
|
323
203
|
handler: async ({ id }, ctx) => {
|
|
324
204
|
await requireAdmin(ctx);
|
|
325
205
|
const repo = await getRepo(ctx.locals);
|
|
326
|
-
|
|
327
|
-
if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Page not found.' });
|
|
328
|
-
await repo.applyPageDraft(id);
|
|
329
|
-
const page = (await repo.getPage(id))!;
|
|
330
|
-
if (before.status === 'published' && before.slug !== page.slug && !page.isHome) {
|
|
331
|
-
await repo.createRedirect({ fromPath: `/${before.slug}`, toPath: `/${page.slug}` }).catch(() => {});
|
|
332
|
-
invalidateRedirectCache();
|
|
333
|
-
}
|
|
334
|
-
return { page };
|
|
206
|
+
try { return { page: await applyPageUpdate(repo, id, hooksFor(ctx)) }; } catch (e) { asActionError(e); }
|
|
335
207
|
},
|
|
336
208
|
}),
|
|
337
209
|
discardChanges: defineAction({
|
|
@@ -524,11 +396,15 @@ export const server = {
|
|
|
524
396
|
siteLogo: z.string().optional(),
|
|
525
397
|
siteFavicon: z.string().optional(),
|
|
526
398
|
siteCover: z.string().optional(),
|
|
527
|
-
|
|
399
|
+
// IANA zone ('' = UTC) and BCP 47 locale ('' = en-US), same rules as Settings → General
|
|
400
|
+
timezone: z.string().max(100).refine((tz) => tz === '' || isValidTimeZone(tz), { message: 'Use an IANA timezone like America/New_York.' }).optional(),
|
|
401
|
+
locale: z.string().max(40).refine((l) => { if (l === '') return true; try { return Intl.getCanonicalLocales(l).length === 1; } catch { return false; } }, { message: 'Use a language tag like en-US or es.' }).optional(),
|
|
402
|
+
// unknown keys are an error, not silently dropped
|
|
403
|
+
}).strict(),
|
|
528
404
|
handler: async (data, ctx) => {
|
|
529
405
|
await requireAdmin(ctx);
|
|
530
406
|
const repo = await getRepo(ctx.locals);
|
|
531
|
-
await repo.updateSettings(data);
|
|
407
|
+
await repo.updateSettings({ ...data, ...(data.locale ? { locale: Intl.getCanonicalLocales(data.locale)[0] } : {}) });
|
|
532
408
|
return { ok: true };
|
|
533
409
|
},
|
|
534
410
|
}),
|
|
@@ -930,6 +806,54 @@ export const server = {
|
|
|
930
806
|
return { sources: normalized, upcoming, total: events.length, labels };
|
|
931
807
|
},
|
|
932
808
|
}),
|
|
809
|
+
// Schedule (admin month/week grid): every occurrence between two days
|
|
810
|
+
range: defineAction({
|
|
811
|
+
input: z.object({ from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/) }),
|
|
812
|
+
handler: async ({ from, to }, ctx) => {
|
|
813
|
+
await requireAdmin(ctx);
|
|
814
|
+
const repo = await getRepo(ctx.locals);
|
|
815
|
+
try { return await loadBoard(repo, await repo.getSettings(), from, to); }
|
|
816
|
+
catch (e) { throw new ActionError({ code: 'BAD_REQUEST', message: e instanceof Error ? e.message : 'Couldn’t load the calendar.' }); }
|
|
817
|
+
},
|
|
818
|
+
}),
|
|
819
|
+
// Calendar settings → Defaults / Bookings
|
|
820
|
+
saveSettings: defineAction({
|
|
821
|
+
input: z.object({
|
|
822
|
+
weekStart: z.union([z.literal(0), z.literal(1)]).optional(),
|
|
823
|
+
defaultCalendarId: z.string().max(100).optional(),
|
|
824
|
+
showBookings: z.boolean().optional(),
|
|
825
|
+
approval: z.enum(['auto', 'rules', 'manual']).optional(),
|
|
826
|
+
approvalRules: z.object({
|
|
827
|
+
longerThan: z.number().int().min(0).max(60 * 24 * 60),
|
|
828
|
+
firstTime: z.boolean(),
|
|
829
|
+
moreSeats: z.number().int().min(0).max(100000),
|
|
830
|
+
deadlineHours: z.number().int().min(0).max(24 * 60),
|
|
831
|
+
deadlineAction: z.enum(['approve', 'decline']),
|
|
832
|
+
}).optional(),
|
|
833
|
+
// reset every booking link to "use the site default"
|
|
834
|
+
applyToLinks: z.boolean().optional(),
|
|
835
|
+
}),
|
|
836
|
+
handler: async ({ applyToLinks, ...patch }, ctx) => {
|
|
837
|
+
await requireAdmin(ctx);
|
|
838
|
+
const repo = await getRepo(ctx.locals);
|
|
839
|
+
const settings = await repo.getSettings();
|
|
840
|
+
if (patch.defaultCalendarId) {
|
|
841
|
+
const cal = await repo.getCalendar(patch.defaultCalendarId);
|
|
842
|
+
if (!cal || cal.kind !== 'native') throw new ActionError({ code: 'BAD_REQUEST', message: 'Pick one of your calendars.' });
|
|
843
|
+
}
|
|
844
|
+
const calendar = { ...settings.calendar, ...Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined)) };
|
|
845
|
+
await repo.updateSettings({ calendar });
|
|
846
|
+
let updated = 0;
|
|
847
|
+
if (applyToLinks) {
|
|
848
|
+
for (const l of await repo.listBookingLinks()) {
|
|
849
|
+
if (l.approval === 'default') continue;
|
|
850
|
+
await repo.updateBookingLink(l.id, { approval: 'default' });
|
|
851
|
+
updated++;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
return { calendar: (await repo.getSettings()).calendar, updated };
|
|
855
|
+
},
|
|
856
|
+
}),
|
|
933
857
|
},
|
|
934
858
|
|
|
935
859
|
events: {
|
|
@@ -946,6 +870,7 @@ export const server = {
|
|
|
946
870
|
coverAlt: z.string().optional(),
|
|
947
871
|
seoTitle: z.string().optional(),
|
|
948
872
|
seoDesc: z.string().optional(),
|
|
873
|
+
translations: DocTranslations.optional(),
|
|
949
874
|
status: StatusInput.optional(),
|
|
950
875
|
featured: z.boolean().optional(),
|
|
951
876
|
template: z.string().optional(),
|
|
@@ -961,26 +886,13 @@ export const server = {
|
|
|
961
886
|
calendarIds: z.array(z.string()).max(50).optional(),
|
|
962
887
|
fields: z.record(z.string().max(2000)).optional(),
|
|
963
888
|
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(),
|
|
889
|
+
roomHold: z.object({ resourceIds: z.array(z.string()).max(50), span: z.enum(['event', 'day']), before: z.number().int().min(0).max(1440), after: z.number().int().min(0).max(1440), whileDraft: z.boolean(), keep: z.array(z.string()).max(500).default([]) }).optional(),
|
|
964
890
|
scheduledAt: nullableTs.optional(),
|
|
965
891
|
}),
|
|
966
892
|
handler: async ({ id, ...data }, ctx) => {
|
|
967
893
|
await requireAdmin(ctx);
|
|
968
894
|
const repo = await getRepo(ctx.locals);
|
|
969
|
-
|
|
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 };
|
|
895
|
+
try { return { event: await saveEvent(repo, id, data, hooksFor(ctx)) }; } catch (e) { asActionError(e); }
|
|
984
896
|
},
|
|
985
897
|
}),
|
|
986
898
|
applyUpdate: defineAction({
|
|
@@ -988,13 +900,7 @@ export const server = {
|
|
|
988
900
|
handler: async ({ id }, ctx) => {
|
|
989
901
|
await requireAdmin(ctx);
|
|
990
902
|
const repo = await getRepo(ctx.locals);
|
|
991
|
-
|
|
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 };
|
|
903
|
+
try { return { event: await applyEventUpdate(repo, id, hooksFor(ctx)) }; } catch (e) { asActionError(e); }
|
|
998
904
|
},
|
|
999
905
|
}),
|
|
1000
906
|
discardChanges: defineAction({
|
|
@@ -1017,6 +923,131 @@ export const server = {
|
|
|
1017
923
|
return { ok: true };
|
|
1018
924
|
},
|
|
1019
925
|
}),
|
|
926
|
+
// Event editor → Rooms: which rooms/people are free at this event's time
|
|
927
|
+
// (for every room, so the picker can label them), and which existing
|
|
928
|
+
// bookings the selected rooms would overlap. Works on unsaved values.
|
|
929
|
+
roomStatus: defineAction({
|
|
930
|
+
input: z.object({
|
|
931
|
+
eventId: z.string().default(''),
|
|
932
|
+
title: z.string().max(300).default(''),
|
|
933
|
+
start: z.string().regex(/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2})?$/),
|
|
934
|
+
end: z.string().max(20).default(''),
|
|
935
|
+
allDay: z.boolean(),
|
|
936
|
+
timezone: z.string().max(100).default(''),
|
|
937
|
+
rrule: z.string().max(500).default(''),
|
|
938
|
+
exdates: z.array(z.string()).max(500).default([]),
|
|
939
|
+
roomHold: z.object({ resourceIds: z.array(z.string()).max(50), span: z.enum(['event', 'day']), before: z.number().int().min(0).max(1440), after: z.number().int().min(0).max(1440), whileDraft: z.boolean(), keep: z.array(z.string()).max(500).default([]) }),
|
|
940
|
+
}),
|
|
941
|
+
handler: async (input, ctx) => {
|
|
942
|
+
await requireAdmin(ctx);
|
|
943
|
+
const repo = await getRepo(ctx.locals);
|
|
944
|
+
const settings = await repo.getSettings();
|
|
945
|
+
if (!bookingsEnabled()) return { rooms: [], holds: '' };
|
|
946
|
+
let times: { start: string; end: string };
|
|
947
|
+
try { times = normalizeEventTimes({ start: input.start, end: input.end, allDay: input.allDay }); }
|
|
948
|
+
catch { throw new ActionError({ code: 'BAD_REQUEST', message: 'Pick a valid date and time.' }); }
|
|
949
|
+
const resources = await repo.listResources();
|
|
950
|
+
const now = Date.now();
|
|
951
|
+
// a stand-in event holding every room, so each one gets a status
|
|
952
|
+
const probe = CalEventSchema.parse({
|
|
953
|
+
id: input.eventId || 'probe', slug: 'probe', title: input.title || 'Event', ...times, allDay: input.allDay,
|
|
954
|
+
timezone: input.timezone, rrule: input.rrule, exdates: input.exdates, status: 'published', startAt: 0, endAt: 0, createdAt: 0, updatedAt: 0,
|
|
955
|
+
roomHold: { ...input.roomHold, resourceIds: resources.map((r) => r.id), keep: input.roomHold.keep },
|
|
956
|
+
});
|
|
957
|
+
const { conflicts, holds } = await holdConflicts(repo, settings, probe, { now });
|
|
958
|
+
const tz = settings.timezone || 'UTC';
|
|
959
|
+
const locale = siteLocale(settings);
|
|
960
|
+
const day = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz });
|
|
961
|
+
const time = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
|
|
962
|
+
const range = (a: number, b: number) => { try { return time.formatRange(a, b); } catch { return `${time.format(a)} – ${time.format(b)}`; } };
|
|
963
|
+
const occurrences = new Set(holds.map((h) => h.eventStart)).size;
|
|
964
|
+
const first = holds[0];
|
|
965
|
+
const holdsLabel = first ? (input.roomHold.span === 'day' ? `Holds all day on ${day.format(first.eventStart)}` : `Holds ${range(first.start, first.end)} on ${day.format(first.eventStart)}`) + (occurrences > 1 ? ` and ${occurrences - 1} more date${occurrences === 2 ? '' : 's'}` : '') : '';
|
|
966
|
+
return {
|
|
967
|
+
holds: holdsLabel,
|
|
968
|
+
rooms: resources.map((r) => {
|
|
969
|
+
const mine = conflicts.filter((c) => c.booking.resourceId === r.id);
|
|
970
|
+
return {
|
|
971
|
+
id: r.id, name: r.name, kind: r.kind, capacity: r.capacity,
|
|
972
|
+
datesWithBookings: new Set(mine.map((c) => c.hold.eventStart)).size, dates: occurrences,
|
|
973
|
+
conflicts: mine.slice(0, 20).map((c) => ({
|
|
974
|
+
id: c.booking.id, name: c.booking.kind === 'block' ? `Blocked${c.booking.notes ? ` · ${c.booking.notes}` : ''}` : c.booking.name,
|
|
975
|
+
block: c.booking.kind === 'block', status: c.booking.status,
|
|
976
|
+
when: `${day.format(c.booking.start)} · ${range(c.booking.start, c.booking.end)}`,
|
|
977
|
+
})),
|
|
978
|
+
};
|
|
979
|
+
}),
|
|
980
|
+
};
|
|
981
|
+
},
|
|
982
|
+
}),
|
|
983
|
+
// Event editor → Rooms → "Cancel and email": frees the room for the event
|
|
984
|
+
cancelBookingsForEvent: defineAction({
|
|
985
|
+
input: z.object({ ids: z.array(z.string()).min(1).max(100), message: z.string().max(2000).default('') }),
|
|
986
|
+
handler: async ({ ids, message }, ctx) => {
|
|
987
|
+
await requireAdmin(ctx);
|
|
988
|
+
const repo = await getRepo(ctx.locals);
|
|
989
|
+
const settings = await repo.getSettings();
|
|
990
|
+
const opts = { origin: siteOrigin(settings, ctx.url), locals: ctx.locals, waitUntil: waitUntilFrom(ctx.locals) };
|
|
991
|
+
let cancelled = 0;
|
|
992
|
+
for (const id of ids) {
|
|
993
|
+
const b = await repo.getBooking(id);
|
|
994
|
+
if (!b || (b.status !== 'pending' && b.status !== 'confirmed')) continue;
|
|
995
|
+
try { await setBookingStatus(repo, b, 'cancelled', opts, message.trim() || undefined); cancelled++; } catch { /* changed meanwhile */ }
|
|
996
|
+
}
|
|
997
|
+
return { cancelled };
|
|
998
|
+
},
|
|
999
|
+
}),
|
|
1000
|
+
// Schedule: click a day → title, time, calendar → saved (draft or published)
|
|
1001
|
+
quickCreate: defineAction({
|
|
1002
|
+
input: z.object({
|
|
1003
|
+
title: z.string().max(300),
|
|
1004
|
+
start: z.string().regex(/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2})?$/),
|
|
1005
|
+
end: z.string().regex(/^(\d{4}-\d{2}-\d{2}(T\d{2}:\d{2})?)?$/).default(''),
|
|
1006
|
+
allDay: z.boolean(),
|
|
1007
|
+
calendarId: z.string().max(100).default(''),
|
|
1008
|
+
rrule: z.string().max(500).default(''),
|
|
1009
|
+
location: z.string().max(300).default(''),
|
|
1010
|
+
publish: z.boolean().default(false),
|
|
1011
|
+
roomIds: z.array(z.string()).max(50).default([]),
|
|
1012
|
+
}),
|
|
1013
|
+
handler: async (input, ctx) => {
|
|
1014
|
+
await requireAdmin(ctx);
|
|
1015
|
+
const repo = await getRepo(ctx.locals);
|
|
1016
|
+
try { const e = await quickCreate(repo, input, hooksFor(ctx)); return { id: e.id, status: e.status }; } catch (e) { asActionError(e); }
|
|
1017
|
+
},
|
|
1018
|
+
}),
|
|
1019
|
+
// Schedule: drag to another day. `occurrence` + mode 'one' moves a single
|
|
1020
|
+
// repeat; otherwise the whole event moves.
|
|
1021
|
+
move: defineAction({
|
|
1022
|
+
input: z.object({ id: z.string(), days: z.number().int(), mode: z.enum(['series', 'one']).default('series'), occurrence: z.string().max(20).default('') }),
|
|
1023
|
+
handler: async ({ id, days, mode, occurrence }, ctx) => {
|
|
1024
|
+
await requireAdmin(ctx);
|
|
1025
|
+
const repo = await getRepo(ctx.locals);
|
|
1026
|
+
try {
|
|
1027
|
+
const r = mode === 'one' ? await moveOccurrence(repo, id, occurrence, days, hooksFor(ctx)) : await moveSeries(repo, id, days, hooksFor(ctx));
|
|
1028
|
+
return { id: r.event.id, undo: r.undo };
|
|
1029
|
+
} catch (e) { asActionError(e); }
|
|
1030
|
+
},
|
|
1031
|
+
}),
|
|
1032
|
+
undoMove: defineAction({
|
|
1033
|
+
input: z.union([
|
|
1034
|
+
z.object({ kind: z.literal('series'), id: z.string(), days: z.number().int() }),
|
|
1035
|
+
z.object({ kind: z.literal('split'), id: z.string(), seriesId: z.string(), occurrence: z.string().max(20) }),
|
|
1036
|
+
]),
|
|
1037
|
+
handler: async (undo, ctx) => {
|
|
1038
|
+
await requireAdmin(ctx);
|
|
1039
|
+
const repo = await getRepo(ctx.locals);
|
|
1040
|
+
try { await undoMove(repo, undo, hooksFor(ctx)); return { ok: true }; } catch (e) { asActionError(e); }
|
|
1041
|
+
},
|
|
1042
|
+
}),
|
|
1043
|
+
duplicate: defineAction({
|
|
1044
|
+
input: z.object({ id: z.string() }),
|
|
1045
|
+
handler: async ({ id }, ctx) => {
|
|
1046
|
+
await requireAdmin(ctx);
|
|
1047
|
+
const repo = await getRepo(ctx.locals);
|
|
1048
|
+
try { return { id: (await duplicateEvent(repo, id)).id }; } catch (e) { asActionError(e); }
|
|
1049
|
+
},
|
|
1050
|
+
}),
|
|
1020
1051
|
// editor pickers (event card): every event with its next date
|
|
1021
1052
|
list: defineAction({
|
|
1022
1053
|
input: z.object({ publishedOnly: z.boolean().default(false) }),
|
|
@@ -16,8 +16,8 @@ import { useEffect, useId, useRef, useState } from 'react';
|
|
|
16
16
|
type Provider = 'openai' | 'custom';
|
|
17
17
|
|
|
18
18
|
interface PublicSettings {
|
|
19
|
-
ai: { enabled: boolean; provider: Provider; baseUrl: string; model: string; hasApiKey: boolean };
|
|
20
|
-
agent: { enabled: boolean; hasKey: boolean; keyPrefix: string; allowPublish: boolean };
|
|
19
|
+
ai: { enabled: boolean; provider: Provider; baseUrl: string; model: string; hasApiKey: boolean; glossary?: string };
|
|
20
|
+
agent: { enabled: boolean; hasKey: boolean; keyPrefix: string; allowPublish: boolean; allowSections?: boolean };
|
|
21
21
|
agentUrl: string;
|
|
22
22
|
docsUrl: string;
|
|
23
23
|
}
|
|
@@ -58,7 +58,7 @@ const sameUrl = (a: string, b: string) => {
|
|
|
58
58
|
// only link to same-site paths or http(s) URLs from the server response
|
|
59
59
|
const safeHref = (u: string) => (u.startsWith('/') && !u.startsWith('//')) || /^https?:\/\//i.test(u) ? u : '';
|
|
60
60
|
|
|
61
|
-
type Busy = 'save' | 'test' | 'generate' | 'revoke' | 'saveAgent';
|
|
61
|
+
type Busy = 'glossary' | 'save' | 'test' | 'generate' | 'revoke' | 'saveAgent';
|
|
62
62
|
|
|
63
63
|
export default function AiSettings() {
|
|
64
64
|
const uid = useId();
|
|
@@ -77,6 +77,9 @@ export default function AiSettings() {
|
|
|
77
77
|
// agent form
|
|
78
78
|
const [agentEnabled, setAgentEnabled] = useState(false);
|
|
79
79
|
const [allowPublish, setAllowPublish] = useState(false);
|
|
80
|
+
const [allowSections, setAllowSections] = useState(false);
|
|
81
|
+
const [glossary, setGlossary] = useState('');
|
|
82
|
+
const [glossaryMsg, setGlossaryMsg] = useState('');
|
|
80
83
|
const [token, setToken] = useState('');
|
|
81
84
|
const [copied, setCopied] = useState('');
|
|
82
85
|
|
|
@@ -96,15 +99,27 @@ export default function AiSettings() {
|
|
|
96
99
|
setProvider(s.ai.provider);
|
|
97
100
|
setBaseUrl(s.ai.provider === 'openai' ? OPENAI_URL : s.ai.baseUrl);
|
|
98
101
|
setModel(s.ai.model);
|
|
102
|
+
setGlossary(s.ai.glossary ?? '');
|
|
99
103
|
setApiKey('');
|
|
100
104
|
setClearApiKey(false);
|
|
101
105
|
}
|
|
102
106
|
if (part !== 'ai') {
|
|
103
107
|
setAgentEnabled(s.agent.enabled);
|
|
104
108
|
setAllowPublish(s.agent.allowPublish);
|
|
109
|
+
setAllowSections(Boolean(s.agent.allowSections));
|
|
105
110
|
}
|
|
106
111
|
}
|
|
107
112
|
|
|
113
|
+
async function saveGlossary() {
|
|
114
|
+
setBusy('glossary');
|
|
115
|
+
setGlossaryMsg('');
|
|
116
|
+
try {
|
|
117
|
+
adopt(await api<PublicSettings>('/api/ai/settings', { action: 'saveGlossary', glossary }), 'ai');
|
|
118
|
+
setGlossaryMsg('Saved. New AI translations follow it.');
|
|
119
|
+
} catch (e) { setGlossaryMsg(e instanceof Error ? e.message : 'Couldn’t save.'); }
|
|
120
|
+
finally { setBusy(null); }
|
|
121
|
+
}
|
|
122
|
+
|
|
108
123
|
async function load() {
|
|
109
124
|
setLoadError('');
|
|
110
125
|
try {
|
|
@@ -150,7 +165,7 @@ export default function AiSettings() {
|
|
|
150
165
|
const keyWillDrop = data.ai.hasApiKey && destinationChanged && !apiKey.trim() && !clearApiKey;
|
|
151
166
|
const formDirty =
|
|
152
167
|
enabled !== data.ai.enabled || destinationChanged || model.trim() !== data.ai.model || !!apiKey || clearApiKey;
|
|
153
|
-
const agentDirty = agentEnabled !== data.agent.enabled || allowPublish !== data.agent.allowPublish;
|
|
168
|
+
const agentDirty = agentEnabled !== data.agent.enabled || allowPublish !== data.agent.allowPublish || allowSections !== Boolean(data.agent.allowSections);
|
|
154
169
|
const isCustom = provider !== 'openai';
|
|
155
170
|
|
|
156
171
|
async function save() {
|
|
@@ -238,7 +253,7 @@ export default function AiSettings() {
|
|
|
238
253
|
setAgentMsg({ ok: '', err: '' });
|
|
239
254
|
await run('saveAgent', async () => {
|
|
240
255
|
try {
|
|
241
|
-
adopt(await api<PublicSettings>('/api/ai/settings', { action: 'saveAgent', enabled: agentEnabled, allowPublish }), 'agent');
|
|
256
|
+
adopt(await api<PublicSettings>('/api/ai/settings', { action: 'saveAgent', enabled: agentEnabled, allowPublish, allowSections }), 'agent');
|
|
242
257
|
setAgentMsg({ ok: 'Agent access saved.', err: '' });
|
|
243
258
|
} catch (e) {
|
|
244
259
|
setAgentMsg({ ok: '', err: errText(e) });
|
|
@@ -412,6 +427,26 @@ export default function AiSettings() {
|
|
|
412
427
|
</div>
|
|
413
428
|
</section>
|
|
414
429
|
|
|
430
|
+
{/* ---- translation glossary ---- */}
|
|
431
|
+
<section className="ais-card" aria-labelledby={id('gl-h')}>
|
|
432
|
+
<header className="ais-head">
|
|
433
|
+
<div className="ais-grow">
|
|
434
|
+
<h2 id={id('gl-h')}>Translation glossary</h2>
|
|
435
|
+
<p>When AI drafts another language, these words always come out your way. One per line: <code>Youth Night = Noche de Jóvenes</code>, or just a name to keep as is (<code>Riverside</code>).</p>
|
|
436
|
+
</div>
|
|
437
|
+
</header>
|
|
438
|
+
<label className="field" htmlFor={id('gl')}>
|
|
439
|
+
<span>Terms</span>
|
|
440
|
+
<textarea id={id('gl')} rows={6} className="mono" value={glossary} onChange={(e) => setGlossary(e.target.value)} placeholder={'Youth Night = Noche de Jóvenes\nSunday Worship = Culto dominical\nRiverside'} />
|
|
441
|
+
</label>
|
|
442
|
+
<div className="ais-actions">
|
|
443
|
+
<button type="button" className="btn primary" disabled={busy !== null || glossary.trim() === (data?.ai.glossary ?? '').trim()} onClick={() => void saveGlossary()}>
|
|
444
|
+
{busy === 'glossary' ? 'Saving…' : 'Save glossary'}
|
|
445
|
+
</button>
|
|
446
|
+
{glossaryMsg && <span className="hint" aria-live="polite">{glossaryMsg}</span>}
|
|
447
|
+
</div>
|
|
448
|
+
</section>
|
|
449
|
+
|
|
415
450
|
{/* ---- agent ---- */}
|
|
416
451
|
<section className="ais-card" aria-labelledby={id('ag-h')}>
|
|
417
452
|
<header className="ais-head">
|
|
@@ -427,6 +462,7 @@ export default function AiSettings() {
|
|
|
427
462
|
<ul className="ais-perms" aria-label="What the agent key can do">
|
|
428
463
|
<li className="yes">Read all posts and pages, including unpublished drafts</li>
|
|
429
464
|
<li className="yes">Create drafts and edit drafts of posts and pages</li>
|
|
465
|
+
<li className={allowSections ? 'yes' : 'no'}>Page sections — {allowSections ? 'can stage layout and content changes for your review' : 'read only unless you turn it on below'}</li>
|
|
430
466
|
<li className={allowPublish ? 'yes' : 'no'}>Publish — {allowPublish ? 'allowed' : 'off unless you turn it on below'}</li>
|
|
431
467
|
<li className="no">Never delete anything or change settings</li>
|
|
432
468
|
</ul>
|
|
@@ -486,6 +522,10 @@ export default function AiSettings() {
|
|
|
486
522
|
<input type="checkbox" checked={allowPublish} onChange={(e) => setAllowPublish(e.target.checked)} />
|
|
487
523
|
<span>Let the agent publish <span className="hint">(off by default, so drafts wait for you to review)</span></span>
|
|
488
524
|
</label>
|
|
525
|
+
<label className="check-row">
|
|
526
|
+
<input type="checkbox" checked={allowSections} onChange={(e) => setAllowSections(e.target.checked)} />
|
|
527
|
+
<span>Let the agent edit page sections <span className="hint">(homepage layouts and section content; changes stay in the design draft)</span></span>
|
|
528
|
+
</label>
|
|
489
529
|
<div className="ais-actions">
|
|
490
530
|
<button type="button" className="btn primary" onClick={() => void saveAgent()} disabled={busy !== null || !agentDirty}>
|
|
491
531
|
{busy === 'saveAgent' ? 'Saving…' : 'Save agent access'}
|