@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
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Admin from '../../../layouts/Admin.astro';
|
|
3
|
+
import Modal from '../../../components/Modal.astro';
|
|
4
|
+
import { getRepo } from '../../../lib/repo';
|
|
5
|
+
import { ApprovalRules, slugify, type ApprovalMode, type CalendarVisibility } from '../../../lib/schema';
|
|
6
|
+
import { CALENDAR, bookingsEnabled, calendarFeedUrl, calendarPageUrl } from '../../../lib/calendar/config';
|
|
7
|
+
import { safeColor } from '../../../lib/calendar/events';
|
|
8
|
+
import { normalizeCalendarSrc } from '../../../lib/calendar/src';
|
|
9
|
+
import { loadFeed } from '../../../lib/calendar/ics';
|
|
10
|
+
import { siteApproval, APPROVAL_LABEL } from '../../../lib/booking/approval';
|
|
11
|
+
import { siteOrigin } from '../../../lib/seo';
|
|
12
|
+
|
|
13
|
+
// Calendar settings — one page behind the ⚙ on Schedule and Bookings:
|
|
14
|
+
// calendars (groups + colors), subscribed calendars (read-only, synced in),
|
|
15
|
+
// share links (out to Google/Apple/Outlook), booking approval, defaults.
|
|
16
|
+
// Replaces the old Calendars page and the duplicate subscribe dialogs.
|
|
17
|
+
if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
18
|
+
|
|
19
|
+
const repo = await getRepo(Astro.locals);
|
|
20
|
+
const settings = await repo.getSettings();
|
|
21
|
+
const origin = siteOrigin(settings, Astro.url);
|
|
22
|
+
const back = (notice?: string, hash = '') => Astro.redirect(`/admin/calendar/settings${notice ? `?notice=${encodeURIComponent(notice)}` : ''}${hash}`);
|
|
23
|
+
const VIS: CalendarVisibility[] = ['public', 'unlisted', 'private'];
|
|
24
|
+
const token = () => {
|
|
25
|
+
const bytes = crypto.getRandomValues(new Uint8Array(18));
|
|
26
|
+
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
27
|
+
};
|
|
28
|
+
async function freeSlug(raw: string, selfId?: string) {
|
|
29
|
+
const base = slugify(raw) || 'calendar';
|
|
30
|
+
let slug = base;
|
|
31
|
+
for (let n = 2; ; n++) {
|
|
32
|
+
const hit = await repo.getCalendarBySlug(slug);
|
|
33
|
+
if (!hit || hit.id === selfId) return slug;
|
|
34
|
+
slug = `${base}-${n}`;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const int = (v: FormDataEntryValue | null, max: number) => Math.max(0, Math.min(max, Math.floor(Number(v) || 0)));
|
|
38
|
+
|
|
39
|
+
if (Astro.request.method === 'POST') {
|
|
40
|
+
const form = await Astro.request.formData();
|
|
41
|
+
// an edit dialog's Delete button overrides the hidden op (last value wins)
|
|
42
|
+
const ops = form.getAll('op');
|
|
43
|
+
const op = String(ops[ops.length - 1]);
|
|
44
|
+
const id = String(form.get('id') || '');
|
|
45
|
+
const name = String(form.get('name') || '').trim().slice(0, 120);
|
|
46
|
+
const visibility = VIS.find((v) => v === form.get('visibility')) ?? 'public';
|
|
47
|
+
const color = safeColor(String(form.get('color') || '#4f46e5'));
|
|
48
|
+
const description = String(form.get('description') || '').slice(0, 1000);
|
|
49
|
+
|
|
50
|
+
if (op === 'defaults') {
|
|
51
|
+
const defaultCalendarId = String(form.get('defaultCalendarId') || '');
|
|
52
|
+
const ok = !defaultCalendarId || (await repo.getCalendar(defaultCalendarId))?.kind === 'native';
|
|
53
|
+
await repo.updateSettings({ calendar: {
|
|
54
|
+
...settings.calendar,
|
|
55
|
+
weekStart: form.get('weekStart') === '1' ? 1 : 0,
|
|
56
|
+
defaultCalendarId: ok ? defaultCalendarId : '',
|
|
57
|
+
// the checkbox only exists when bookings are on; otherwise keep the old value
|
|
58
|
+
showBookings: bookingsEnabled() ? form.has('showBookings') : settings.calendar.showBookings,
|
|
59
|
+
} });
|
|
60
|
+
return back('Defaults saved.', '#defaults');
|
|
61
|
+
}
|
|
62
|
+
if (op === 'approval') {
|
|
63
|
+
const mode = (['auto', 'rules', 'manual'] as const).find((m) => m === form.get('approval')) ?? 'auto';
|
|
64
|
+
const approvalRules = ApprovalRules.parse({
|
|
65
|
+
longerThan: form.has('ruleLonger') ? int(form.get('longerThanHours'), 24 * 60) * 60 : 0,
|
|
66
|
+
firstTime: form.has('ruleFirst'),
|
|
67
|
+
moreSeats: form.has('ruleSeats') ? int(form.get('moreSeats'), 100000) : 0,
|
|
68
|
+
deadlineHours: form.has('deadline') ? int(form.get('deadlineHours'), 24 * 60) : 0,
|
|
69
|
+
deadlineAction: form.get('deadlineAction') === 'decline' ? 'decline' : 'approve',
|
|
70
|
+
});
|
|
71
|
+
await repo.updateSettings({ calendar: { ...settings.calendar, approval: mode, approvalRules } });
|
|
72
|
+
let reset = 0;
|
|
73
|
+
if (form.get('apply') === 'all') {
|
|
74
|
+
for (const l of await repo.listBookingLinks()) {
|
|
75
|
+
if (l.approval !== 'default') { await repo.updateBookingLink(l.id, { approval: 'default' }); reset++; }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return back(reset ? `Approval saved — ${reset} booking link${reset === 1 ? '' : 's'} now follow it.` : 'Approval saved.', '#bookings');
|
|
79
|
+
}
|
|
80
|
+
if (op === 'create' || op === 'subscribe') {
|
|
81
|
+
if (!name) return back('Give the calendar a name.');
|
|
82
|
+
let feedUrl = '';
|
|
83
|
+
if (op === 'subscribe') {
|
|
84
|
+
feedUrl = normalizeCalendarSrc(String(form.get('feedUrl') || '')) ?? '';
|
|
85
|
+
if (!feedUrl) return back('Paste a Google Calendar link, a calendar ID, or an .ics link.', '#subscribed');
|
|
86
|
+
try { await loadFeed(feedUrl, Date.now()); } catch (err) { return back(err instanceof Error ? err.message : 'Couldn’t load that calendar.', '#subscribed'); }
|
|
87
|
+
}
|
|
88
|
+
await repo.createCalendar({ name, slug: await freeSlug(String(form.get('slug') || name)), color, description, visibility, kind: op === 'subscribe' ? 'feed' : 'native', feedUrl });
|
|
89
|
+
return back(op === 'subscribe' ? `Subscribed to “${name}” — its events now show on your Schedule.` : `Created “${name}”.`, op === 'subscribe' ? '#subscribed' : '#calendars');
|
|
90
|
+
}
|
|
91
|
+
const cal = id ? await repo.getCalendar(id) : null;
|
|
92
|
+
if (!cal) return back('That calendar no longer exists.');
|
|
93
|
+
const hash = cal.kind === 'feed' ? '#subscribed' : '#calendars';
|
|
94
|
+
if (op === 'update') {
|
|
95
|
+
let feedUrl = cal.feedUrl;
|
|
96
|
+
if (cal.kind === 'feed' && form.has('feedUrl') && String(form.get('feedUrl')) !== cal.feedUrl) {
|
|
97
|
+
feedUrl = normalizeCalendarSrc(String(form.get('feedUrl'))) ?? '';
|
|
98
|
+
if (!feedUrl) return back('Paste a Google Calendar link, a calendar ID, or an .ics link.', hash);
|
|
99
|
+
}
|
|
100
|
+
await repo.updateCalendar(cal.id, {
|
|
101
|
+
name: name || cal.name, slug: await freeSlug(String(form.get('slug') || name || cal.slug), cal.id),
|
|
102
|
+
color, description, visibility, feedUrl,
|
|
103
|
+
});
|
|
104
|
+
return back(`Saved “${name || cal.name}”.`, hash);
|
|
105
|
+
}
|
|
106
|
+
if (op === 'rotate') {
|
|
107
|
+
await repo.updateCalendar(cal.id, { feedToken: token() });
|
|
108
|
+
return back('Link reset — the old one stopped working.', '#share');
|
|
109
|
+
}
|
|
110
|
+
if (op === 'move') {
|
|
111
|
+
const ids = (await repo.listCalendars()).map((c) => c.id);
|
|
112
|
+
const i = ids.indexOf(cal.id);
|
|
113
|
+
const j = form.get('dir') === 'up' ? i - 1 : i + 1;
|
|
114
|
+
if (j >= 0 && j < ids.length) [ids[i], ids[j]] = [ids[j], ids[i]];
|
|
115
|
+
await repo.reorderCalendars(ids);
|
|
116
|
+
return back(undefined, hash);
|
|
117
|
+
}
|
|
118
|
+
if (op === 'delete') {
|
|
119
|
+
await repo.deleteCalendar(cal.id);
|
|
120
|
+
if (settings.calendar.defaultCalendarId === cal.id) await repo.updateSettings({ calendar: { ...settings.calendar, defaultCalendarId: '' } });
|
|
121
|
+
return back(cal.kind === 'feed' ? `Removed “${cal.name}”.` : `Deleted “${cal.name}”. Its events are still there, just without a calendar.`, hash);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const [calendars, events, links] = await Promise.all([repo.listCalendars(), repo.listEvents(), bookingsEnabled() ? repo.listBookingLinks() : Promise.resolve([])]);
|
|
126
|
+
const natives = calendars.filter((c) => c.kind === 'native');
|
|
127
|
+
const feeds = calendars.filter((c) => c.kind === 'feed');
|
|
128
|
+
const counts = new Map<string, number>();
|
|
129
|
+
for (const e of events) for (const cid of e.calendarIds) counts.set(cid, (counts.get(cid) ?? 0) + 1);
|
|
130
|
+
const notice = Astro.url.searchParams.get('notice') ?? '';
|
|
131
|
+
const shareFor = (c: (typeof calendars)[number]) =>
|
|
132
|
+
origin + calendarFeedUrl(c.slug) + (c.visibility === 'private' ? `?t=${encodeURIComponent(c.feedToken)}` : '');
|
|
133
|
+
const hostOf = (u: string) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return u; } };
|
|
134
|
+
const VIS_LABEL: Record<CalendarVisibility, string> = {
|
|
135
|
+
public: 'Public — listed on the calendar page',
|
|
136
|
+
unlisted: 'Unlisted — event pages and the share link work, not listed',
|
|
137
|
+
private: 'Private — admins and the private share link only',
|
|
138
|
+
};
|
|
139
|
+
const visWord = (v: CalendarVisibility) => v[0].toUpperCase() + v.slice(1);
|
|
140
|
+
const site = siteApproval(settings);
|
|
141
|
+
const rules = site.rules;
|
|
142
|
+
const own = links.filter((l) => l.approval !== 'default').length;
|
|
143
|
+
const APPROVAL_NOTE: Record<ApprovalMode, string> = {
|
|
144
|
+
auto: 'Booked means confirmed. The person gets a confirmation email and it shows on your Schedule right away.',
|
|
145
|
+
rules: 'Confirm most bookings at once, but hold unusual ones for you to approve.',
|
|
146
|
+
manual: 'Every booking waits under Needs approval until you accept or decline it.',
|
|
147
|
+
};
|
|
148
|
+
const I = {
|
|
149
|
+
up: 'M6 15l6-6 6 6', down: 'M6 9l6 6 6-6',
|
|
150
|
+
ext: 'M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5',
|
|
151
|
+
};
|
|
152
|
+
const sections = [
|
|
153
|
+
['calendars', 'Calendars'], ['subscribed', 'Subscribed calendars'], ['share', 'Share links'],
|
|
154
|
+
...(bookingsEnabled() ? [['bookings', 'Bookings']] : []), ['defaults', 'Defaults'],
|
|
155
|
+
] as const;
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
<Admin title="Calendar settings" section="calendar:schedule">
|
|
159
|
+
<div class="page-head">
|
|
160
|
+
<div>
|
|
161
|
+
<p class="sub cs-crumb"><a href="/admin/calendar">Schedule</a> ›</p>
|
|
162
|
+
<h1>Calendar settings</h1>
|
|
163
|
+
<p class="sub">Used by Schedule{bookingsEnabled() ? ' and Bookings' : ''}. Changes here apply everywhere.</p>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
{notice && <p class="notice" role="status">{notice}</p>}
|
|
168
|
+
|
|
169
|
+
<div class="cs">
|
|
170
|
+
<nav class="cs-nav" aria-label="Sections">
|
|
171
|
+
{sections.map(([k, l]) => <a href={`#${k}`}>{l}</a>)}
|
|
172
|
+
</nav>
|
|
173
|
+
|
|
174
|
+
<div class="cs-body">
|
|
175
|
+
<!-- Calendars -->
|
|
176
|
+
<section class="cs-sect" id="calendars">
|
|
177
|
+
<header>
|
|
178
|
+
<div><h2>Calendars</h2><p>Group your events, like Services or Youth. Each one has a color and its own share link.</p></div>
|
|
179
|
+
<button class="btn" type="button" data-modal="cal-new">+ New calendar</button>
|
|
180
|
+
</header>
|
|
181
|
+
{natives.length === 0 ? (
|
|
182
|
+
<p class="cs-empty">No calendars yet. Events work without them; add some to color-code the Schedule and filter the public calendar.</p>
|
|
183
|
+
) : natives.map((c, i) => (
|
|
184
|
+
<div class="cs-row" id={`cal-${c.id}`}>
|
|
185
|
+
<i class="cal-dot lg" style={`background:${safeColor(c.color)}`} aria-hidden="true" />
|
|
186
|
+
<div class="cs-main">
|
|
187
|
+
<b>{c.name}</b>
|
|
188
|
+
<small>{visWord(c.visibility)} · {counts.get(c.id) ?? 0} event{counts.get(c.id) === 1 ? '' : 's'}{settings.calendar.defaultCalendarId === c.id ? ' · default for new events' : ''}{c.description ? ` · ${c.description}` : ''}</small>
|
|
189
|
+
</div>
|
|
190
|
+
<div class="cs-actions">
|
|
191
|
+
{c.visibility !== 'private' && (
|
|
192
|
+
<a class="icon-btn" href={calendarPageUrl(c.slug)} target="_blank" rel="noopener" title="View on site" aria-label="View on site">
|
|
193
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={I.ext} /></svg>
|
|
194
|
+
</a>
|
|
195
|
+
)}
|
|
196
|
+
<form method="post" class="cal-move">
|
|
197
|
+
<input type="hidden" name="op" value="move" /><input type="hidden" name="id" value={c.id} />
|
|
198
|
+
<button class="icon-btn" name="dir" value="up" disabled={i === 0} title="Move up" aria-label="Move up"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d={I.up} /></svg></button>
|
|
199
|
+
<button class="icon-btn" name="dir" value="down" disabled={i === natives.length - 1} title="Move down" aria-label="Move down"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d={I.down} /></svg></button>
|
|
200
|
+
</form>
|
|
201
|
+
<button class="btn sm" type="button" data-modal={`cal-edit-${c.id}`}>Edit</button>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
))}
|
|
205
|
+
</section>
|
|
206
|
+
|
|
207
|
+
<!-- Subscribed -->
|
|
208
|
+
<section class="cs-sect" id="subscribed">
|
|
209
|
+
<header>
|
|
210
|
+
<div><h2>Subscribed calendars</h2><p>Calendars from Google, Apple or Outlook, kept in sync. They show on your Schedule and can’t be edited here.</p></div>
|
|
211
|
+
<button class="btn" type="button" data-modal="cal-subscribe">+ Subscribe</button>
|
|
212
|
+
</header>
|
|
213
|
+
{feeds.length === 0 ? (
|
|
214
|
+
<p class="cs-empty">None yet. Subscribe to holidays, a staff calendar, or anything with a calendar link.</p>
|
|
215
|
+
) : feeds.map((c) => (
|
|
216
|
+
<div class="cs-row" id={`cal-${c.id}`}>
|
|
217
|
+
<i class="cal-dot lg" style={`background:${safeColor(c.color)}`} aria-hidden="true" />
|
|
218
|
+
<div class="cs-main">
|
|
219
|
+
<b>{c.name}</b>
|
|
220
|
+
<small>{hostOf(c.feedUrl)} · refreshed every few minutes · {visWord(c.visibility)}</small>
|
|
221
|
+
</div>
|
|
222
|
+
<div class="cs-actions">
|
|
223
|
+
<a class="btn sm ghost" href={`/admin/calendar/events?cal=${c.id}`}>Make an editable copy…</a>
|
|
224
|
+
<button class="btn sm" type="button" data-modal={`cal-edit-${c.id}`}>Edit</button>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
))}
|
|
228
|
+
</section>
|
|
229
|
+
|
|
230
|
+
<!-- Share links -->
|
|
231
|
+
<section class="cs-sect" id="share">
|
|
232
|
+
<header>
|
|
233
|
+
<div><h2>Share links</h2><p>Paste one into Google Calendar, Apple Calendar or Outlook to follow your calendar. It updates on its own.</p></div>
|
|
234
|
+
</header>
|
|
235
|
+
<div class="cs-row">
|
|
236
|
+
<i class="cal-dot lg" style="background:var(--accent)" aria-hidden="true" />
|
|
237
|
+
<div class="cs-main"><b>Everything public</b><small>Every public calendar in one link</small></div>
|
|
238
|
+
<div class="cs-share">
|
|
239
|
+
<input type="text" class="mono" readonly value={origin + calendarFeedUrl()} aria-label="Share link for every public calendar" />
|
|
240
|
+
<button class="btn sm" type="button" data-copy={origin + calendarFeedUrl()}>Copy</button>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
{natives.filter((c) => c.visibility !== 'private' || c.feedToken).map((c) => (
|
|
244
|
+
<div class="cs-row">
|
|
245
|
+
<i class="cal-dot lg" style={`background:${safeColor(c.color)}`} aria-hidden="true" />
|
|
246
|
+
<div class="cs-main"><b>{c.name}</b><small>{c.visibility === 'private' ? 'Private link — keep it secret' : 'Just this calendar'}</small></div>
|
|
247
|
+
<div class="cs-share">
|
|
248
|
+
<input type="text" class="mono" readonly value={shareFor(c)} aria-label={`Share link for ${c.name}`} />
|
|
249
|
+
<button class="btn sm" type="button" data-copy={shareFor(c)}>Copy</button>
|
|
250
|
+
{c.visibility === 'private' && (
|
|
251
|
+
<form method="post">
|
|
252
|
+
<input type="hidden" name="op" value="rotate" /><input type="hidden" name="id" value={c.id} />
|
|
253
|
+
<button class="btn sm ghost" type="submit" data-confirm="Reset this link? Anyone following the old one stops getting updates.">Reset link</button>
|
|
254
|
+
</form>
|
|
255
|
+
)}
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
))}
|
|
259
|
+
</section>
|
|
260
|
+
|
|
261
|
+
<!-- Bookings: approval -->
|
|
262
|
+
{bookingsEnabled() && (
|
|
263
|
+
<section class="cs-sect" id="bookings">
|
|
264
|
+
<header>
|
|
265
|
+
<div><h2>Booking approval</h2><p>What happens when someone books a room or person. Each booking link can follow this or pick its own.</p></div>
|
|
266
|
+
</header>
|
|
267
|
+
<form method="post" class="cs-form" data-approval>
|
|
268
|
+
<input type="hidden" name="op" value="approval" />
|
|
269
|
+
<div class="cs-choices" role="radiogroup" aria-label="Approval">
|
|
270
|
+
{(['auto', 'rules', 'manual'] as const).map((m) => (
|
|
271
|
+
<label class="cs-choice">
|
|
272
|
+
<input type="radio" name="approval" value={m} checked={site.mode === m} />
|
|
273
|
+
<span><b>{APPROVAL_LABEL[m]}</b><small>{APPROVAL_NOTE[m]}</small></span>
|
|
274
|
+
</label>
|
|
275
|
+
))}
|
|
276
|
+
</div>
|
|
277
|
+
<fieldset class="cs-rules" data-rules hidden={site.mode !== 'rules'}>
|
|
278
|
+
<legend>Ask me first when a booking is…</legend>
|
|
279
|
+
<label class="cs-check"><input type="checkbox" name="ruleLonger" checked={rules.longerThan > 0} /> longer than <input type="number" name="longerThanHours" min="1" max="1440" value={rules.longerThan ? Math.round(rules.longerThan / 60) : 4} /> hours</label>
|
|
280
|
+
<label class="cs-check"><input type="checkbox" name="ruleFirst" checked={rules.firstTime} /> the first booking from that email address</label>
|
|
281
|
+
<label class="cs-check"><input type="checkbox" name="ruleSeats" checked={rules.moreSeats > 0} /> for more than <input type="number" name="moreSeats" min="1" max="100000" value={rules.moreSeats || 20} /> people</label>
|
|
282
|
+
</fieldset>
|
|
283
|
+
<div class="cs-deadline" data-deadline hidden={site.mode === 'auto'}>
|
|
284
|
+
<label class="cs-check"><input type="checkbox" name="deadline" checked={rules.deadlineHours > 0} /> If I haven’t answered in <input type="number" name="deadlineHours" min="1" max="1440" value={rules.deadlineHours || 48} /> hours,</label>
|
|
285
|
+
<select name="deadlineAction" aria-label="What happens then">
|
|
286
|
+
<option value="approve" selected={rules.deadlineAction === 'approve'}>approve it</option>
|
|
287
|
+
<option value="decline" selected={rules.deadlineAction === 'decline'}>decline it</option>
|
|
288
|
+
</select>
|
|
289
|
+
</div>
|
|
290
|
+
<div class="cs-foot">
|
|
291
|
+
<small>{links.length - own} of {links.length} booking link{links.length === 1 ? '' : 's'} follow this setting{own ? `; ${own} use their own.` : '.'}</small>
|
|
292
|
+
{own > 0 && <label class="cs-check"><input type="checkbox" name="apply" value="all" /> Make all {links.length} follow it</label>}
|
|
293
|
+
<button class="btn primary" type="submit">Save approval</button>
|
|
294
|
+
</div>
|
|
295
|
+
</form>
|
|
296
|
+
</section>
|
|
297
|
+
)}
|
|
298
|
+
|
|
299
|
+
<!-- Defaults -->
|
|
300
|
+
<section class="cs-sect" id="defaults">
|
|
301
|
+
<header><div><h2>Defaults</h2><p>How the Schedule looks and where new events go.</p></div></header>
|
|
302
|
+
<form method="post" class="cs-form">
|
|
303
|
+
<input type="hidden" name="op" value="defaults" />
|
|
304
|
+
<label class="cs-field"><span>Weeks start on</span>
|
|
305
|
+
<select name="weekStart">
|
|
306
|
+
<option value="0" selected={settings.calendar.weekStart === 0}>Sunday</option>
|
|
307
|
+
<option value="1" selected={settings.calendar.weekStart === 1}>Monday</option>
|
|
308
|
+
</select>
|
|
309
|
+
</label>
|
|
310
|
+
<label class="cs-field"><span>New events go in</span>
|
|
311
|
+
<select name="defaultCalendarId">
|
|
312
|
+
<option value="">No calendar</option>
|
|
313
|
+
{natives.map((c) => <option value={c.id} selected={settings.calendar.defaultCalendarId === c.id}>{c.name}</option>)}
|
|
314
|
+
</select>
|
|
315
|
+
</label>
|
|
316
|
+
{bookingsEnabled() && (
|
|
317
|
+
<label class="cs-check"><input type="checkbox" name="showBookings" checked={settings.calendar.showBookings} /> Show bookings and blocked time on the Schedule</label>
|
|
318
|
+
)}
|
|
319
|
+
<div class="cs-foot"><span /><button class="btn primary" type="submit">Save defaults</button></div>
|
|
320
|
+
</form>
|
|
321
|
+
</section>
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
|
|
325
|
+
{calendars.map((c) => (
|
|
326
|
+
<Modal id={`cal-edit-${c.id}`} title={`Edit ${c.name}`}>
|
|
327
|
+
<form method="post">
|
|
328
|
+
<input type="hidden" name="op" value="update" /><input type="hidden" name="id" value={c.id} />
|
|
329
|
+
<div class="field-row">
|
|
330
|
+
<label class="field"><span>Name</span><input type="text" name="name" value={c.name} required /></label>
|
|
331
|
+
<label class="field"><span>Color</span><input type="color" name="color" value={safeColor(c.color)} /></label>
|
|
332
|
+
</div>
|
|
333
|
+
<label class="field"><span>URL name</span><input class="mono" type="text" name="slug" value={c.slug} /></label>
|
|
334
|
+
{c.kind === 'feed' && <label class="field"><span>Calendar link</span><input class="mono" type="text" name="feedUrl" value={c.feedUrl} /></label>}
|
|
335
|
+
<label class="field"><span>Description</span><input type="text" name="description" value={c.description} placeholder="Shown on the calendar page filter" /></label>
|
|
336
|
+
<label class="field">
|
|
337
|
+
<span>Visibility</span>
|
|
338
|
+
<select name="visibility">{VIS.map((v) => <option value={v} selected={c.visibility === v}>{VIS_LABEL[v]}</option>)}</select>
|
|
339
|
+
</label>
|
|
340
|
+
<div class="modal-foot">
|
|
341
|
+
<button class="btn danger ghost" type="submit" name="op" value="delete" formnovalidate data-confirm={c.kind === 'feed' ? `Remove “${c.name}”? Its events disappear from your Schedule.` : `Delete “${c.name}”? Events stay, they just lose this calendar.`}>{c.kind === 'feed' ? 'Remove' : 'Delete'}</button>
|
|
342
|
+
<span class="spacer" />
|
|
343
|
+
<button class="btn ghost" type="button" data-close>Cancel</button>
|
|
344
|
+
<button class="btn primary" type="submit">Save</button>
|
|
345
|
+
</div>
|
|
346
|
+
</form>
|
|
347
|
+
</Modal>
|
|
348
|
+
))}
|
|
349
|
+
|
|
350
|
+
<Modal id="cal-new" title="New calendar" open={Astro.url.searchParams.has('new')}>
|
|
351
|
+
<form method="post">
|
|
352
|
+
<input type="hidden" name="op" value="create" />
|
|
353
|
+
<div class="field-row">
|
|
354
|
+
<label class="field"><span>Name</span><input type="text" name="name" placeholder="Youth" required autofocus /></label>
|
|
355
|
+
<label class="field"><span>Color</span><input type="color" name="color" value="#4f46e5" /></label>
|
|
356
|
+
</div>
|
|
357
|
+
<label class="field"><span>Description</span><input type="text" name="description" placeholder="Optional" /></label>
|
|
358
|
+
<label class="field">
|
|
359
|
+
<span>Visibility</span>
|
|
360
|
+
<select name="visibility">{VIS.map((v) => <option value={v}>{VIS_LABEL[v]}</option>)}</select>
|
|
361
|
+
</label>
|
|
362
|
+
<div class="modal-foot">
|
|
363
|
+
<button class="btn ghost" type="button" data-close>Cancel</button>
|
|
364
|
+
<button class="btn primary" type="submit">Create calendar</button>
|
|
365
|
+
</div>
|
|
366
|
+
</form>
|
|
367
|
+
</Modal>
|
|
368
|
+
|
|
369
|
+
<Modal id="cal-subscribe" title="Subscribe to a calendar" open={Astro.url.searchParams.has('subscribe')}>
|
|
370
|
+
<form method="post">
|
|
371
|
+
<input type="hidden" name="op" value="subscribe" />
|
|
372
|
+
<p class="hint">Follow a Google, Apple or Outlook calendar. Its events show on your Schedule, read-only, and stay in sync.</p>
|
|
373
|
+
<div class="field-row">
|
|
374
|
+
<label class="field"><span>Name</span><input type="text" name="name" placeholder="Staff calendar" required /></label>
|
|
375
|
+
<label class="field"><span>Color</span><input type="color" name="color" value="#2f9e6e" /></label>
|
|
376
|
+
</div>
|
|
377
|
+
<label class="field"><span>Calendar link</span><input class="mono" type="text" name="feedUrl" placeholder="Google Calendar link, calendar ID, or .ics link" required /></label>
|
|
378
|
+
<label class="field">
|
|
379
|
+
<span>Visibility</span>
|
|
380
|
+
<select name="visibility">{VIS.map((v) => <option value={v}>{VIS_LABEL[v]}</option>)}</select>
|
|
381
|
+
</label>
|
|
382
|
+
<div class="modal-foot">
|
|
383
|
+
<button class="btn ghost" type="button" data-close>Cancel</button>
|
|
384
|
+
<button class="btn primary" type="submit">Subscribe</button>
|
|
385
|
+
</div>
|
|
386
|
+
</form>
|
|
387
|
+
</Modal>
|
|
388
|
+
</Admin>
|
|
389
|
+
|
|
390
|
+
<style>
|
|
391
|
+
.cs-crumb { margin: 0 0 2px; }
|
|
392
|
+
.cs-crumb a { color: var(--muted); }
|
|
393
|
+
.cs { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: 24px; align-items: start; }
|
|
394
|
+
.cs-nav { position: sticky; top: 64px; display: flex; flex-direction: column; gap: 2px; font-size: 13px; }
|
|
395
|
+
.cs-nav a { padding: 6px 10px; border-radius: 8px; color: var(--text-2); }
|
|
396
|
+
.cs-nav a:hover { background: var(--hover); color: var(--text); }
|
|
397
|
+
.cs-body { display: flex; flex-direction: column; gap: 18px; min-width: 0; }
|
|
398
|
+
.cs-sect { background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-lg); overflow: hidden; scroll-margin-top: 64px; }
|
|
399
|
+
.cs-sect > header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; padding: 14px 16px; border-bottom: 1px solid var(--line); }
|
|
400
|
+
.cs-sect h2 { font-size: 14.5px; margin: 0; }
|
|
401
|
+
.cs-sect header p { margin: 2px 0 0; color: var(--muted); font-size: 12.5px; max-width: 560px; }
|
|
402
|
+
.cs-empty { color: var(--muted); font-size: 13px; margin: 0; padding: 14px 16px; }
|
|
403
|
+
.cs-row { display: grid; grid-template-columns: 14px minmax(0, 1fr) auto; gap: 12px; align-items: center; padding: 11px 16px; border-bottom: 1px solid var(--line); }
|
|
404
|
+
.cs-row:last-child { border-bottom: 0; }
|
|
405
|
+
.cs-main { display: flex; flex-direction: column; min-width: 0; }
|
|
406
|
+
.cs-main b { font-weight: 600; font-size: 13.5px; }
|
|
407
|
+
.cs-main small { color: var(--muted); font-size: 12px; }
|
|
408
|
+
.cs-actions, .cs-share { display: flex; align-items: center; gap: 6px; }
|
|
409
|
+
.cs-share input { width: min(340px, 38vw); font-size: 12px; padding: 4px 8px; border: 1px solid var(--line); border-radius: 7px; background: var(--bg); color: var(--text-2); }
|
|
410
|
+
.cs-form { padding: 14px 16px; display: flex; flex-direction: column; gap: 12px; }
|
|
411
|
+
.cs-choices { display: flex; flex-direction: column; gap: 8px; }
|
|
412
|
+
.cs-choice { display: grid; grid-template-columns: 18px 1fr; gap: 10px; padding: 11px 13px; border: 1px solid var(--line); border-radius: 10px; background: var(--bg); cursor: pointer; }
|
|
413
|
+
.cs-choice:has(input:checked) { border-color: var(--accent-ring); background: var(--accent-soft); }
|
|
414
|
+
.cs-choice input { accent-color: var(--accent); margin-top: 2px; }
|
|
415
|
+
.cs-choice b { display: block; font-size: 13.5px; }
|
|
416
|
+
.cs-choice small { color: var(--muted); font-size: 12px; }
|
|
417
|
+
.cs-rules { border: 1px solid var(--line); border-radius: 10px; padding: 10px 13px 12px; margin: 0; display: flex; flex-direction: column; gap: 8px; }
|
|
418
|
+
.cs-rules legend { color: var(--muted); font-size: 12px; padding: 0 4px; }
|
|
419
|
+
.cs-check { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: 13px; color: var(--text-2); }
|
|
420
|
+
.cs-check input[type='checkbox'] { accent-color: var(--accent); }
|
|
421
|
+
.cs-check input[type='number'] { width: 70px; padding: 3px 6px; border: 1px solid var(--line-strong); border-radius: 7px; background: var(--bg); color: var(--text); font: inherit; }
|
|
422
|
+
.cs-deadline { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
|
423
|
+
.cs-deadline select, .cs-field select { padding: 4px 8px; border: 1px solid var(--line-strong); border-radius: 7px; background: var(--bg); color: var(--text); font: inherit; font-size: 13px; }
|
|
424
|
+
.cs-field { display: grid; grid-template-columns: 160px auto; align-items: center; gap: 10px; font-size: 13px; color: var(--text-2); justify-content: start; }
|
|
425
|
+
.cs-foot { display: flex; align-items: center; gap: 12px; justify-content: space-between; flex-wrap: wrap; border-top: 1px solid var(--line); padding-top: 12px; }
|
|
426
|
+
.cs-foot small { color: var(--muted); font-size: 12px; }
|
|
427
|
+
[hidden] { display: none !important; }
|
|
428
|
+
@media (max-width: 860px) {
|
|
429
|
+
.cs { grid-template-columns: 1fr; }
|
|
430
|
+
.cs-nav { position: static; flex-direction: row; overflow-x: auto; scrollbar-width: none; margin: 0 -4px; }
|
|
431
|
+
.cs-nav a { white-space: nowrap; }
|
|
432
|
+
.cs-row { grid-template-columns: 14px minmax(0, 1fr); }
|
|
433
|
+
.cs-actions, .cs-share { grid-column: 2; flex-wrap: wrap; }
|
|
434
|
+
.cs-share input { width: 100%; }
|
|
435
|
+
.cs-field { grid-template-columns: 1fr; gap: 4px; }
|
|
436
|
+
}
|
|
437
|
+
</style>
|
|
438
|
+
|
|
439
|
+
<script>
|
|
440
|
+
document.addEventListener('click', async (e) => {
|
|
441
|
+
const b = e.target instanceof Element ? e.target.closest<HTMLButtonElement>('[data-copy]') : null;
|
|
442
|
+
if (b) {
|
|
443
|
+
await navigator.clipboard.writeText(b.dataset.copy ?? '');
|
|
444
|
+
const label = b.textContent;
|
|
445
|
+
b.textContent = 'Copied';
|
|
446
|
+
setTimeout(() => (b.textContent = label), 1200);
|
|
447
|
+
}
|
|
448
|
+
const c = e.target instanceof Element ? e.target.closest<HTMLElement>('[data-confirm]') : null;
|
|
449
|
+
if (c && !confirm(c.dataset.confirm)) e.preventDefault();
|
|
450
|
+
});
|
|
451
|
+
// approval: rules only for "unless…", the deadline only when bookings can wait
|
|
452
|
+
const form = document.querySelector<HTMLFormElement>('[data-approval]');
|
|
453
|
+
form?.addEventListener('change', () => {
|
|
454
|
+
const mode = new FormData(form).get('approval');
|
|
455
|
+
form.querySelector<HTMLElement>('[data-rules]')!.hidden = mode !== 'rules';
|
|
456
|
+
form.querySelector<HTMLElement>('[data-deadline]')!.hidden = mode === 'auto';
|
|
457
|
+
});
|
|
458
|
+
</script>
|
|
@@ -4,12 +4,11 @@ import Modal from '../../components/Modal.astro';
|
|
|
4
4
|
import { getRepo } from '../../lib/repo';
|
|
5
5
|
import { API_SCOPES, WEBHOOK_EVENTS, type ApiScope } from '../../lib/schema';
|
|
6
6
|
import { validateSession, SESSION_COOKIE, OWNER_ENV } from '../../lib/auth';
|
|
7
|
-
import { CALENDAR, bookingsEnabled
|
|
7
|
+
import { CALENDAR, bookingsEnabled } from '../../lib/calendar/config';
|
|
8
8
|
import { issueApiKey } from '../../lib/api-keys';
|
|
9
9
|
import { attemptDelivery, generateWebhookSecret, validateWebhookUrl } from '../../lib/webhooks';
|
|
10
10
|
import { resolveConfig } from '../../lib/email/config';
|
|
11
11
|
import { env } from '../../lib/env';
|
|
12
|
-
import { siteOrigin } from '../../lib/seo';
|
|
13
12
|
|
|
14
13
|
// Site-wide integrations. Owners manage keys and webhook subscriptions;
|
|
15
14
|
// calendar-specific feeds and API help appear only when enabled.
|
|
@@ -18,7 +17,6 @@ const sessionId = (await validateSession(Astro.cookies.get(SESSION_COOKIE)?.valu
|
|
|
18
17
|
const me = sessionId && sessionId !== OWNER_ENV ? await repo.getUser(sessionId) : null;
|
|
19
18
|
const isOwner = sessionId === OWNER_ENV || me?.role === 'owner';
|
|
20
19
|
const settings = await repo.getSettings();
|
|
21
|
-
const origin = siteOrigin(settings, Astro.url);
|
|
22
20
|
let notice = '';
|
|
23
21
|
let error = '';
|
|
24
22
|
let newKey = '';
|
|
@@ -72,7 +70,7 @@ if (Astro.request.method === 'POST') {
|
|
|
72
70
|
}
|
|
73
71
|
}
|
|
74
72
|
|
|
75
|
-
const [keys, hooks
|
|
73
|
+
const [keys, hooks] = await Promise.all([repo.listApiKeys(), repo.listWebhooks()]);
|
|
76
74
|
const logs = new Map(await Promise.all(hooks.map(async (h) => [h.id, await repo.listDeliveries(h.id, 5)] as const)));
|
|
77
75
|
const emailConfig = resolveConfig(await repo.email.config(), Astro.locals);
|
|
78
76
|
const provider = emailConfig.error ? 'none' : emailConfig.provider;
|
|
@@ -86,10 +84,6 @@ const SCOPE_LABEL: Record<string, string> = {
|
|
|
86
84
|
'bookings:read': 'Read bookings and guest details', 'bookings:write': 'Create, confirm and cancel bookings',
|
|
87
85
|
'resources:read': 'Read resources and links', 'resources:write': 'Edit resources and links',
|
|
88
86
|
};
|
|
89
|
-
const feeds = [
|
|
90
|
-
{ label: 'Everything public', url: origin + calendarFeedUrl() },
|
|
91
|
-
...calendars.filter((c) => c.kind === 'native' && c.visibility !== 'private').map((c) => ({ label: c.name, url: origin + calendarFeedUrl(c.slug) })),
|
|
92
|
-
];
|
|
93
87
|
---
|
|
94
88
|
|
|
95
89
|
<Admin title="API & webhooks" section="integrations">
|
|
@@ -186,9 +180,9 @@ const feeds = [
|
|
|
186
180
|
) : <p class="hint">Sending as <code>{emailConfig.config.from}</code>. Guests get confirmations, updates and a reminder the day before.</p>}
|
|
187
181
|
</section>
|
|
188
182
|
{CALENDAR.enabled && <section class="card card-pad">
|
|
189
|
-
<div class="card-head"><b>
|
|
190
|
-
|
|
191
|
-
<p
|
|
183
|
+
<div class="card-head"><b>Calendar share links</b></div>
|
|
184
|
+
<p class="hint">The .ics links people paste into Google, Apple or Outlook live in one place.</p>
|
|
185
|
+
<p><a href="/admin/calendar/settings#share">Calendar settings → Share links →</a></p>
|
|
192
186
|
</section>}
|
|
193
187
|
<section class="card card-pad">
|
|
194
188
|
<div class="card-head"><b>Quick start</b></div>
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { safeNext } from '../../lib/editor/next';
|
|
2
3
|
import { checkPassword, createSessionToken, verifyPassword, validateSession, SESSION_COOKIE } from '../../lib/auth';
|
|
3
4
|
import { getRepo } from '../../lib/repo';
|
|
4
5
|
import { BRAND } from '../../lib/brand';
|
|
5
6
|
import ThemeScript from '../../components/ThemeScript.astro';
|
|
6
7
|
import '../../styles/admin.css';
|
|
7
8
|
|
|
9
|
+
// ?next= returns to the editor-app sign-in page only (never another site)
|
|
10
|
+
const next = safeNext(Astro.url.searchParams.get('next'), Astro.url);
|
|
8
11
|
if ((await validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals))) {
|
|
9
|
-
return Astro.redirect('/admin');
|
|
12
|
+
return Astro.redirect(next ?? '/admin');
|
|
10
13
|
}
|
|
11
14
|
|
|
12
15
|
let error = '';
|
|
@@ -40,7 +43,7 @@ if (Astro.request.method === 'POST') {
|
|
|
40
43
|
maxAge: 7 * 24 * 60 * 60,
|
|
41
44
|
secure: Astro.url.protocol === 'https:',
|
|
42
45
|
});
|
|
43
|
-
return Astro.redirect('/admin');
|
|
46
|
+
return Astro.redirect(next ?? '/admin');
|
|
44
47
|
}
|
|
45
48
|
}
|
|
46
49
|
---
|
|
@@ -67,6 +70,7 @@ if (Astro.request.method === 'POST') {
|
|
|
67
70
|
</svg>
|
|
68
71
|
<div class="brand-name">{BRAND.name}</div>
|
|
69
72
|
</div>
|
|
73
|
+
{next && !error && <p class="hint login-for-app">Sign in to connect the app to your account.</p>}
|
|
70
74
|
{error && <div class="flash error">{error}</div>}
|
|
71
75
|
<form method="post">
|
|
72
76
|
<label class="field">
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro';
|
|
2
2
|
import { SESSION_COOKIE } from '../../lib/auth';
|
|
3
|
+
import { safeNext } from '../../lib/editor/next';
|
|
3
4
|
|
|
4
|
-
export const GET: APIRoute = ({ cookies, redirect }) => {
|
|
5
|
+
export const GET: APIRoute = ({ cookies, redirect, url }) => {
|
|
5
6
|
cookies.delete(SESSION_COOKIE, { path: '/' });
|
|
6
|
-
|
|
7
|
+
// "Switch account" on the editor-app sign-in page comes back to it
|
|
8
|
+
const next = safeNext(url.searchParams.get('next'), url);
|
|
9
|
+
return redirect(next ? `/admin/login?next=${encodeURIComponent(next)}` : '/admin/login');
|
|
7
10
|
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Admin from '../../layouts/Admin.astro';
|
|
3
|
+
import MobileApp from '../../components/MobileApp';
|
|
4
|
+
import { getRepo } from '../../lib/repo';
|
|
5
|
+
import { requireSession } from '../../lib/api';
|
|
6
|
+
const repo = await getRepo(Astro.locals);
|
|
7
|
+
try { await requireSession(Astro.request, Astro.cookies, repo, true); }
|
|
8
|
+
catch { return new Response('Owner access is required.', { status: 403 }); }
|
|
9
|
+
---
|
|
10
|
+
<Admin title="Mobile app" section="mobile">
|
|
11
|
+
<div class="page-head">
|
|
12
|
+
<div>
|
|
13
|
+
<h1>Mobile app</h1>
|
|
14
|
+
<p class="sub">Notifications for people who installed your app.</p>
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
<MobileApp client:only="react" />
|
|
18
|
+
</Admin>
|