@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.
Files changed (289) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/cli/common.mjs +2 -2
  3. package/cli/legacy.mjs +1 -1
  4. package/cli/main.mjs +82 -8
  5. package/cli/mobile-doctor.mjs +430 -0
  6. package/cli/mobile.mjs +315 -0
  7. package/cli/native-sections.mjs +168 -0
  8. package/cli/preview.mjs +8 -4
  9. package/cli/sections.mjs +188 -0
  10. package/cli/themes.mjs +52 -8
  11. package/docs/mobile.md +527 -0
  12. package/docs/sections.md +203 -0
  13. package/docs/theme-authoring.md +19 -3
  14. package/integration.mjs +76 -5
  15. package/mobile-template/client/AGENTS.md +97 -0
  16. package/mobile-template/client/README.md +65 -0
  17. package/mobile-template/client/app/_layout.tsx +56 -0
  18. package/mobile-template/client/app/index.tsx +21 -0
  19. package/mobile-template/client/app.json +21 -0
  20. package/mobile-template/client/components/LanguageSwitch.tsx +31 -0
  21. package/mobile-template/client/eas.json +21 -0
  22. package/mobile-template/client/env.example +3 -0
  23. package/mobile-template/client/gitignore +42 -0
  24. package/mobile-template/client/package.json +38 -0
  25. package/mobile-template/client/sections/index.ts +11 -0
  26. package/mobile-template/client/tsconfig.json +7 -0
  27. package/mobile-template/editor/AGENTS.md +66 -0
  28. package/mobile-template/editor/README.md +73 -0
  29. package/mobile-template/editor/app/_layout.tsx +76 -0
  30. package/mobile-template/editor/app/auth.tsx +13 -0
  31. package/mobile-template/editor/app/index.tsx +55 -0
  32. package/mobile-template/editor/app.json +21 -0
  33. package/mobile-template/editor/components/Screen.tsx +51 -0
  34. package/mobile-template/editor/components/SignIn.tsx +36 -0
  35. package/mobile-template/editor/components/TextTabs.tsx +27 -0
  36. package/mobile-template/editor/eas.json +21 -0
  37. package/mobile-template/editor/env.example +5 -0
  38. package/mobile-template/editor/gitignore +42 -0
  39. package/mobile-template/editor/lib/appearance.tsx +12 -0
  40. package/mobile-template/editor/lib/config.ts +17 -0
  41. package/mobile-template/editor/lib/format.ts +24 -0
  42. package/mobile-template/editor/lib/tools.ts +16 -0
  43. package/mobile-template/editor/package.json +40 -0
  44. package/mobile-template/editor/tools/README.md +2 -0
  45. package/mobile-template/editor/tsconfig.json +7 -0
  46. package/mobile-template/recipes/booking/files/app/book/[resource].tsx +57 -0
  47. package/mobile-template/recipes/booking/files/app/book/details.tsx +105 -0
  48. package/mobile-template/recipes/booking/files/app/book/index.tsx +38 -0
  49. package/mobile-template/recipes/booking/recipe.json +17 -0
  50. package/mobile-template/recipes/editor-bookings/files/app/bookings/index.tsx +113 -0
  51. package/mobile-template/recipes/editor-bookings/files/tools/bookings.ts +1 -0
  52. package/mobile-template/recipes/editor-bookings/recipe.json +8 -0
  53. package/mobile-template/recipes/editor-content/files/app/content/[kind]/[id].tsx +13 -0
  54. package/mobile-template/recipes/editor-content/files/app/content/index.tsx +76 -0
  55. package/mobile-template/recipes/editor-content/files/components/DocumentDetail.tsx +55 -0
  56. package/mobile-template/recipes/editor-content/files/tools/content.ts +1 -0
  57. package/mobile-template/recipes/editor-content/recipe.json +8 -0
  58. package/mobile-template/recipes/editor-media/files/app/media/index.tsx +73 -0
  59. package/mobile-template/recipes/editor-media/files/tools/media.ts +1 -0
  60. package/mobile-template/recipes/editor-media/recipe.json +8 -0
  61. package/mobile-template/recipes/editor-sections/files/app/sections/[pageId]/[sectionId].tsx +12 -0
  62. package/mobile-template/recipes/editor-sections/files/app/sections/index.tsx +48 -0
  63. package/mobile-template/recipes/editor-sections/files/components/SectionEditorPane.tsx +77 -0
  64. package/mobile-template/recipes/editor-sections/files/tools/sections.ts +1 -0
  65. package/mobile-template/recipes/editor-sections/recipe.json +8 -0
  66. package/mobile-template/recipes/editor-translations/files/app/translations/index.tsx +63 -0
  67. package/mobile-template/recipes/editor-translations/files/app/translations/review.tsx +14 -0
  68. package/mobile-template/recipes/editor-translations/files/components/ReviewPane.tsx +72 -0
  69. package/mobile-template/recipes/editor-translations/files/tools/translations.ts +1 -0
  70. package/mobile-template/recipes/editor-translations/recipe.json +8 -0
  71. package/mobile-template/recipes/editor-writer/files/app/write/[kind]/[id].tsx +132 -0
  72. package/mobile-template/recipes/editor-writer/files/app/write/new.tsx +61 -0
  73. package/mobile-template/recipes/editor-writer/files/tools/writer.ts +1 -0
  74. package/mobile-template/recipes/editor-writer/recipe.json +8 -0
  75. package/mobile-template/recipes/events/files/app/events/[slug].tsx +41 -0
  76. package/mobile-template/recipes/events/files/app/events/index.tsx +50 -0
  77. package/mobile-template/recipes/events/files/lib/addToCalendar.ts +27 -0
  78. package/mobile-template/recipes/events/recipe.json +15 -0
  79. package/mobile-template/recipes/news/files/app/news/[slug].tsx +25 -0
  80. package/mobile-template/recipes/news/files/app/news/index.tsx +50 -0
  81. package/mobile-template/recipes/news/recipe.json +13 -0
  82. package/mobile-template/recipes/push/files/app/notifications.tsx +64 -0
  83. package/mobile-template/recipes/push/files/components/PushSetup.tsx +15 -0
  84. package/mobile-template/recipes/push/files/lib/push.ts +15 -0
  85. package/mobile-template/recipes/push/recipe.json +20 -0
  86. package/package.json +17 -13
  87. package/section-library/README.md +16 -0
  88. package/section-library/contact/Contact.astro +49 -0
  89. package/section-library/contact/section.json +106 -0
  90. package/section-library/cta-band/CtaBand.astro +28 -0
  91. package/section-library/cta-band/section.json +51 -0
  92. package/section-library/feature-grid/FeatureGrid.astro +54 -0
  93. package/section-library/feature-grid/section.json +107 -0
  94. package/section-library/gallery/Gallery.astro +29 -0
  95. package/section-library/gallery/section.json +51 -0
  96. package/section-library/hero-banner/HeroBanner.astro +42 -0
  97. package/section-library/hero-banner/section.json +81 -0
  98. package/section-library/latest-posts/LatestPosts.astro +22 -0
  99. package/section-library/latest-posts/section.json +37 -0
  100. package/section-library/pricing/Pricing.astro +40 -0
  101. package/section-library/pricing/section.json +117 -0
  102. package/section-library/questions/Questions.astro +38 -0
  103. package/section-library/questions/section.json +69 -0
  104. package/section-library/service-times/ServiceTimes.astro +42 -0
  105. package/section-library/service-times/section.json +127 -0
  106. package/section-library/team/Team.astro +45 -0
  107. package/section-library/team/section.json +88 -0
  108. package/section-library/upcoming-events/UpcomingEvents.astro +54 -0
  109. package/section-library/upcoming-events/section.json +51 -0
  110. package/src/actions/index.ts +205 -174
  111. package/src/components/AiSettings.tsx +45 -5
  112. package/src/components/DesignManager.tsx +112 -16
  113. package/src/components/MobileApp.tsx +392 -0
  114. package/src/components/ThemeSections.astro +40 -21
  115. package/src/components/ThemeSectionsEditor.tsx +757 -229
  116. package/src/components/booking/BookingFlow.astro +6 -3
  117. package/src/components/calendar/BookingsHead.astro +51 -0
  118. package/src/components/calendar/CalendarBoard.tsx +618 -0
  119. package/src/components/calendar/MiniMonth.tsx +54 -0
  120. package/src/components/calendar/NewEventDialog.tsx +44 -0
  121. package/src/components/calendar/QuickAdd.tsx +263 -0
  122. package/src/components/calendar/RoomHolds.tsx +203 -0
  123. package/src/components/calendar/WhereItLands.tsx +51 -0
  124. package/src/components/editor/EventPanel.tsx +23 -2
  125. package/src/components/editor/PostEditor.tsx +206 -14
  126. package/src/components/import/ImportExport.tsx +4 -1
  127. package/src/components/import/runner.ts +1 -0
  128. package/src/components/sections/SectionFields.tsx +560 -0
  129. package/src/components/sections/SectionPicker.tsx +120 -0
  130. package/src/components/sections/TranslationPanel.tsx +143 -0
  131. package/src/components/sections/checks.ts +64 -0
  132. package/src/layouts/Admin.astro +11 -7
  133. package/src/layouts/Site.astro +136 -21
  134. package/src/lib/agent-discovery.ts +11 -7
  135. package/src/lib/agent.ts +35 -2
  136. package/src/lib/ai-schema.ts +5 -0
  137. package/src/lib/ai.ts +5 -0
  138. package/src/lib/api.ts +12 -0
  139. package/src/lib/backup/content.ts +6 -1
  140. package/src/lib/booking/agent.ts +3 -2
  141. package/src/lib/booking/approval.ts +70 -0
  142. package/src/lib/booking/holds.ts +120 -0
  143. package/src/lib/booking/service.ts +37 -3
  144. package/src/lib/calendar/agent-schema.ts +9 -1
  145. package/src/lib/calendar/agent.ts +16 -1
  146. package/src/lib/calendar/board.ts +171 -0
  147. package/src/lib/calendar/config.ts +3 -0
  148. package/src/lib/calendar/grid.ts +169 -0
  149. package/src/lib/calendar/migration.ts +16 -0
  150. package/src/lib/calendar/render.ts +9 -7
  151. package/src/lib/calendar/schedule.ts +166 -0
  152. package/src/lib/content-service.ts +203 -0
  153. package/src/lib/design-service.ts +153 -5
  154. package/src/lib/drafts.ts +3 -3
  155. package/src/lib/editor/auth.ts +264 -0
  156. package/src/lib/editor/bookings.ts +60 -0
  157. package/src/lib/editor/config.ts +25 -0
  158. package/src/lib/editor/documents.ts +227 -0
  159. package/src/lib/editor/http.ts +48 -0
  160. package/src/lib/editor/me.ts +30 -0
  161. package/src/lib/editor/migration.ts +57 -0
  162. package/src/lib/editor/next.ts +11 -0
  163. package/src/lib/editor/openapi.ts +124 -0
  164. package/src/lib/editor/schemas.ts +91 -0
  165. package/src/lib/editor/sections.ts +96 -0
  166. package/src/lib/editor/store.ts +184 -0
  167. package/src/lib/editor/token-route.ts +74 -0
  168. package/src/lib/editor/translations.ts +163 -0
  169. package/src/lib/email/accounts.ts +2 -1
  170. package/src/lib/email/types.ts +2 -1
  171. package/src/lib/home-source.ts +34 -0
  172. package/src/lib/i18n.ts +34 -0
  173. package/src/lib/import/apply.ts +31 -2
  174. package/src/lib/import/bundle.ts +4 -0
  175. package/src/lib/import/export.ts +1 -0
  176. package/src/lib/import/ghost.ts +1 -0
  177. package/src/lib/media/upload.ts +83 -0
  178. package/src/lib/mobile/associations.ts +25 -0
  179. package/src/lib/mobile/calendar.ts +26 -14
  180. package/src/lib/mobile/config.mjs +59 -0
  181. package/src/lib/mobile/content.ts +18 -13
  182. package/src/lib/mobile/embed.ts +83 -0
  183. package/src/lib/mobile/http.ts +14 -6
  184. package/src/lib/mobile/locale.ts +18 -0
  185. package/src/lib/mobile/openapi.ts +111 -0
  186. package/src/lib/mobile/pages.ts +71 -0
  187. package/src/lib/mobile/route.ts +3 -3
  188. package/src/lib/mobile/schemas.ts +60 -0
  189. package/src/lib/mobile/site.ts +41 -0
  190. package/src/lib/push/api.ts +158 -0
  191. package/src/lib/push/config.ts +20 -0
  192. package/src/lib/push/expo.ts +76 -0
  193. package/src/lib/push/migration.ts +60 -0
  194. package/src/lib/push/service.ts +302 -0
  195. package/src/lib/push/store.ts +144 -0
  196. package/src/lib/push/types.ts +43 -0
  197. package/src/lib/repo/migrations.ts +14 -0
  198. package/src/lib/repo/sql.ts +65 -20
  199. package/src/lib/repo/types.ts +10 -4
  200. package/src/lib/roles.ts +5 -0
  201. package/src/lib/scheduler.ts +6 -1
  202. package/src/lib/schema.ts +76 -1
  203. package/src/lib/section-ai.ts +55 -0
  204. package/src/lib/section-targets.ts +62 -0
  205. package/src/lib/sections-agent.ts +146 -0
  206. package/src/lib/sections.ts +242 -0
  207. package/src/lib/site-chrome.ts +34 -0
  208. package/src/lib/theme-assets.ts +16 -0
  209. package/src/lib/theme.ts +33 -4
  210. package/src/lib/translate.ts +97 -0
  211. package/src/lib/translations.ts +39 -0
  212. package/src/middleware.ts +38 -2
  213. package/src/pages/.well-known/apple-app-site-association.ts +6 -0
  214. package/src/pages/.well-known/assetlinks.json.ts +6 -0
  215. package/src/pages/[...slug].astro +8 -3
  216. package/src/pages/admin/app-auth.astro +159 -0
  217. package/src/pages/admin/c/[collection]/[id].astro +9 -0
  218. package/src/pages/admin/calendar/bookings.astro +32 -26
  219. package/src/pages/admin/calendar/calendars.astro +4 -263
  220. package/src/pages/admin/calendar/events/[id].astro +9 -0
  221. package/src/pages/admin/calendar/events/index.astro +41 -83
  222. package/src/pages/admin/calendar/index.astro +43 -0
  223. package/src/pages/admin/calendar/resources/[id].astro +27 -5
  224. package/src/pages/admin/calendar/resources/index.astro +16 -17
  225. package/src/pages/admin/calendar/resources/link.astro +52 -12
  226. package/src/pages/admin/calendar/settings.astro +458 -0
  227. package/src/pages/admin/integrations.astro +5 -11
  228. package/src/pages/admin/login.astro +6 -2
  229. package/src/pages/admin/logout.ts +5 -2
  230. package/src/pages/admin/mobile.astro +18 -0
  231. package/src/pages/admin/pages/[id].astro +62 -0
  232. package/src/pages/admin/section-preview.astro +61 -0
  233. package/src/pages/admin/users.astro +107 -11
  234. package/src/pages/api/agent/docs.ts +6 -2
  235. package/src/pages/api/agent/index.ts +1 -1
  236. package/src/pages/api/ai/settings.ts +7 -4
  237. package/src/pages/api/design/ai.ts +15 -0
  238. package/src/pages/api/design/index.ts +12 -42
  239. package/src/pages/api/design/translate.ts +15 -0
  240. package/src/pages/api/live/thumb.ts +1 -1
  241. package/src/pages/api/mcp.ts +2 -2
  242. package/src/pages/api/media/upload.ts +10 -78
  243. package/src/pages/api/push/admin.ts +27 -0
  244. package/src/pages/api/v1/admin/bookings/[id]/[action].ts +14 -0
  245. package/src/pages/api/v1/admin/bookings/index.ts +6 -0
  246. package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +17 -0
  247. package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +20 -0
  248. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +26 -0
  249. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +18 -0
  250. package/src/pages/api/v1/admin/documents/index.ts +13 -0
  251. package/src/pages/api/v1/admin/me.ts +6 -0
  252. package/src/pages/api/v1/admin/media/index.ts +32 -0
  253. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/index.ts +14 -0
  254. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/publish.ts +14 -0
  255. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/[action].ts +22 -0
  256. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/index.ts +14 -0
  257. package/src/pages/api/v1/admin/sections/index.ts +6 -0
  258. package/src/pages/api/v1/auth/revoke.ts +5 -0
  259. package/src/pages/api/v1/auth/token.ts +5 -0
  260. package/src/pages/api/v1/devices/[token].ts +20 -0
  261. package/src/pages/api/v1/devices/index.ts +17 -0
  262. package/src/pages/api/v1/openapi.json.ts +7 -0
  263. package/src/pages/api/v1/pages/[...slug].ts +7 -0
  264. package/src/pages/api/v1/pages/home.ts +7 -0
  265. package/src/pages/api/v1/push/topics.ts +7 -0
  266. package/src/pages/api/v1/site.ts +7 -0
  267. package/src/pages/api/v1/theme.ts +7 -0
  268. package/src/pages/blog/[slug].astro +8 -2
  269. package/src/pages/blog/index.astro +11 -4
  270. package/src/pages/book/[resource].ics.ts +7 -1
  271. package/src/pages/embed/section/[...path].astro +42 -0
  272. package/src/pages/events/[slug].astro +7 -2
  273. package/src/pages/index.astro +11 -5
  274. package/src/pages/sitemap.xml.ts +8 -0
  275. package/src/styles/admin.css +117 -0
  276. package/src/styles/cards.css +5 -1
  277. package/src/styles/editor.css +57 -0
  278. package/src/styles/mobile-app.css +151 -0
  279. package/src/styles/schedule.css +325 -0
  280. package/src/styles/sections.css +21 -12
  281. package/src/styles/theme-editor.css +255 -0
  282. package/src/theme-sdk/contract.d.mts +2193 -89
  283. package/src/theme-sdk/contract.mjs +77 -20
  284. package/src/theme-sdk/fields.d.mts +88 -0
  285. package/src/theme-sdk/fields.mjs +330 -0
  286. package/src/theme-sdk/index.ts +14 -2
  287. package/src/theme-sdk/tokens.d.mts +11 -0
  288. package/src/virtual.d.ts +4 -0
  289. package/src/pages/admin/calendar/index.ts +0 -4
@@ -1,265 +1,6 @@
1
1
  ---
2
- import Admin from '../../../layouts/Admin.astro';
3
- import Modal from '../../../components/Modal.astro';
4
- import { getRepo } from '../../../lib/repo';
5
- import { slugify, type CalendarVisibility } from '../../../lib/schema';
6
- import { CALENDAR, 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 { siteOrigin } from '../../../lib/seo';
11
-
12
- // Calendars: create/edit native calendars and live feed subscriptions, set
13
- // who can see each one, and copy the subscribe links people paste into
14
- // Google Calendar, Apple Calendar or Outlook.
15
- if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
16
-
17
- const repo = await getRepo(Astro.locals);
18
- const settings = await repo.getSettings();
19
- const origin = siteOrigin(settings, Astro.url);
20
- const back = (notice?: string) => Astro.redirect(`/admin/calendar/calendars${notice ? `?notice=${encodeURIComponent(notice)}` : ''}`);
21
- const VIS: CalendarVisibility[] = ['public', 'unlisted', 'private'];
22
- const token = () => {
23
- const bytes = crypto.getRandomValues(new Uint8Array(18));
24
- return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
25
- };
26
- async function freeSlug(raw: string, selfId?: string) {
27
- const base = slugify(raw);
28
- let slug = base;
29
- for (let n = 2; ; n++) {
30
- const hit = await repo.getCalendarBySlug(slug);
31
- if (!hit || hit.id === selfId) return slug;
32
- slug = `${base}-${n}`;
33
- }
34
- }
35
-
36
- if (Astro.request.method === 'POST') {
37
- const form = await Astro.request.formData();
38
- // the edit modal's Delete button overrides the hidden op (it comes later
39
- // in the form, so it's the last value)
40
- const ops = form.getAll('op');
41
- const op = String(ops[ops.length - 1]);
42
- const id = String(form.get('id') || '');
43
- const name = String(form.get('name') || '').trim().slice(0, 120);
44
- const visibility = VIS.find((v) => v === form.get('visibility')) ?? 'public';
45
- const color = safeColor(String(form.get('color') || '#4f46e5'));
46
- const description = String(form.get('description') || '').slice(0, 1000);
47
-
48
- if (op === 'create' || op === 'subscribe') {
49
- if (!name) return back('Give the calendar a name.');
50
- let feedUrl = '';
51
- if (op === 'subscribe') {
52
- feedUrl = normalizeCalendarSrc(String(form.get('feedUrl') || '')) ?? '';
53
- if (!feedUrl) return back('Paste a Google Calendar link, a calendar ID, or an .ics feed URL.');
54
- try { await loadFeed(feedUrl, Date.now()); } catch (err) { return back(err instanceof Error ? err.message : 'Couldn’t load that feed.'); }
55
- }
56
- await repo.createCalendar({ name, slug: await freeSlug(String(form.get('slug') || name)), color, description, visibility, kind: op === 'subscribe' ? 'feed' : 'native', feedUrl });
57
- return back(`Created “${name}”.`);
58
- }
59
- const cal = id ? await repo.getCalendar(id) : null;
60
- if (!cal) return back('That calendar no longer exists.');
61
- if (op === 'update') {
62
- let feedUrl = cal.feedUrl;
63
- if (cal.kind === 'feed' && form.has('feedUrl') && String(form.get('feedUrl')) !== cal.feedUrl) {
64
- feedUrl = normalizeCalendarSrc(String(form.get('feedUrl'))) ?? '';
65
- if (!feedUrl) return back('Paste a Google Calendar link, a calendar ID, or an .ics feed URL.');
66
- }
67
- await repo.updateCalendar(cal.id, {
68
- name: name || cal.name, slug: await freeSlug(String(form.get('slug') || name || cal.slug), cal.id),
69
- color, description, visibility, feedUrl,
70
- });
71
- return back(`Saved “${name || cal.name}”.`);
72
- }
73
- if (op === 'rotate') {
74
- await repo.updateCalendar(cal.id, { feedToken: token() });
75
- return back('New private link created — the old one stopped working.');
76
- }
77
- if (op === 'move') {
78
- const ids = (await repo.listCalendars()).map((c) => c.id);
79
- const i = ids.indexOf(cal.id);
80
- const j = form.get('dir') === 'up' ? i - 1 : i + 1;
81
- if (j >= 0 && j < ids.length) [ids[i], ids[j]] = [ids[j], ids[i]];
82
- await repo.reorderCalendars(ids);
83
- return back();
84
- }
85
- if (op === 'delete') {
86
- await repo.deleteCalendar(cal.id);
87
- return back(`Deleted “${cal.name}”. Its events are still there, just unfiled.`);
88
- }
89
- }
90
-
91
- const [calendars, events] = await Promise.all([repo.listCalendars(), repo.listEvents()]);
92
- const counts = new Map<string, number>();
93
- for (const e of events) for (const id of e.calendarIds) counts.set(id, (counts.get(id) ?? 0) + 1);
94
- const notice = Astro.url.searchParams.get('notice') ?? '';
95
- const feedFor = (c: (typeof calendars)[number]) =>
96
- origin + calendarFeedUrl(c.slug) + (c.visibility === 'private' ? `?t=${encodeURIComponent(c.feedToken)}` : '');
97
- const hostOf = (u: string) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return u; } };
98
- const VIS_LABEL: Record<CalendarVisibility, string> = {
99
- public: 'Public — listed on the calendar page',
100
- unlisted: 'Unlisted — event pages and feed work, not listed',
101
- private: 'Private — admins and the secret feed link only',
102
- };
103
- const I = {
104
- up: 'M6 15l6-6 6 6', down: 'M6 9l6 6 6-6', copy: 'M9 9h10v10H9zM5 15V5h10',
105
- 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',
106
- };
2
+ // The Calendars page moved into Calendar settings (⚙ on Schedule).
3
+ // Kept as a redirect so bookmarks, emails and old links keep working.
4
+ const hash = Astro.url.searchParams.has('subscribe') ? '?subscribe=1#subscribed' : '#calendars';
5
+ return Astro.redirect(`/admin/calendar/settings${hash}`, 301);
107
6
  ---
108
-
109
- <Admin title="Calendars" section="calendar:calendars">
110
- <div class="page-head">
111
- <div>
112
- <h1>Calendars</h1>
113
- <p class="sub">Group events, choose who sees them, and share subscribe links for Google, Apple and Outlook.</p>
114
- </div>
115
- <div class="page-actions">
116
- <button class="btn" type="button" data-modal="cal-subscribe">Subscribe to a feed</button>
117
- <button class="btn primary" type="button" data-modal="cal-new">New calendar</button>
118
- </div>
119
- </div>
120
-
121
- {notice && <p class="notice" role="status">{notice}</p>}
122
-
123
- <section class="card cal-allfeed">
124
- <div>
125
- <b>Everything public</b>
126
- <small>One link with every public calendar — paste it into any calendar app.</small>
127
- </div>
128
- <div class="cal-feed">
129
- <input type="text" class="mono" readonly value={origin + calendarFeedUrl()} aria-label="Subscribe link for all public calendars" />
130
- <button class="btn sm" type="button" data-copy={origin + calendarFeedUrl()}>Copy</button>
131
- </div>
132
- </section>
133
-
134
- {calendars.length === 0 ? (
135
- <div class="empty-state">
136
- <b>No calendars yet</b>
137
- <p>Events work without calendars. Add some to color-code events, filter the calendar page, and give each group its own subscribe link.</p>
138
- <div class="empty-actions">
139
- <button class="btn" type="button" data-modal="cal-subscribe">Subscribe to a feed</button>
140
- <button class="btn primary" type="button" data-modal="cal-new">New calendar</button>
141
- </div>
142
- </div>
143
- ) : (
144
- <div class="cal-list">
145
- {calendars.map((c, i) => (
146
- <article class="cal-row" id={`cal-${c.id}`}>
147
- <i class="cal-dot lg" style={`background:${safeColor(c.color)}`} aria-hidden="true" />
148
- <div class="cal-main">
149
- <div class="cal-title">
150
- <b>{c.name}</b>
151
- {c.kind === 'feed' && <span class="pill lang">Feed · {hostOf(c.feedUrl)}</span>}
152
- <span class={`cal-vis is-${c.visibility}`}>{c.visibility[0].toUpperCase() + c.visibility.slice(1)}</span>
153
- </div>
154
- <small>
155
- {c.kind === 'feed' ? 'Live mirror, refreshed every few minutes · read-only' : `${counts.get(c.id) ?? 0} event${counts.get(c.id) === 1 ? '' : 's'}`}
156
- {c.description && ` · ${c.description}`}
157
- </small>
158
- {c.visibility !== 'private' || c.feedToken ? (
159
- <div class="cal-feed">
160
- <input type="text" class="mono" readonly value={feedFor(c)} aria-label={`Subscribe link for ${c.name}`} />
161
- <button class="btn sm" type="button" data-copy={feedFor(c)}>Copy</button>
162
- {c.visibility === 'private' && (
163
- <form method="post">
164
- <input type="hidden" name="op" value="rotate" /><input type="hidden" name="id" value={c.id} />
165
- <button class="btn sm ghost" type="submit" data-confirm="Make a new private link? Anyone subscribed with the old one stops getting updates.">New link</button>
166
- </form>
167
- )}
168
- </div>
169
- ) : null}
170
- </div>
171
- <div class="cal-actions">
172
- {c.visibility !== 'private' && (
173
- <a class="icon-btn" href={calendarPageUrl(c.slug)} target="_blank" rel="noopener" title="View on site" aria-label="View on site">
174
- <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>
175
- </a>
176
- )}
177
- <form method="post" class="cal-move">
178
- <input type="hidden" name="op" value="move" /><input type="hidden" name="id" value={c.id} />
179
- <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>
180
- <button class="icon-btn" name="dir" value="down" disabled={i === calendars.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>
181
- </form>
182
- <button class="btn sm" type="button" data-modal={`cal-edit-${c.id}`}>Edit</button>
183
- </div>
184
- <Modal id={`cal-edit-${c.id}`} title={`Edit ${c.name}`}>
185
- <form method="post">
186
- <input type="hidden" name="op" value="update" /><input type="hidden" name="id" value={c.id} />
187
- <div class="field-row">
188
- <label class="field"><span>Name</span><input type="text" name="name" value={c.name} required /></label>
189
- <label class="field"><span>Color</span><input type="color" name="color" value={safeColor(c.color)} /></label>
190
- </div>
191
- <label class="field"><span>URL name</span><input class="mono" type="text" name="slug" value={c.slug} /></label>
192
- {c.kind === 'feed' && <label class="field"><span>Feed link</span><input class="mono" type="text" name="feedUrl" value={c.feedUrl} /></label>}
193
- <label class="field"><span>Description</span><input type="text" name="description" value={c.description} placeholder="Shown on the calendar page filter" /></label>
194
- <label class="field">
195
- <span>Who can see it</span>
196
- <select name="visibility">
197
- {VIS.map((v) => <option value={v} selected={c.visibility === v}>{VIS_LABEL[v]}</option>)}
198
- </select>
199
- </label>
200
- <div class="modal-foot">
201
- <button class="btn danger ghost" type="submit" name="op" value="delete" formnovalidate data-confirm={`Delete “${c.name}”? Events stay, they just lose this calendar.`}>Delete</button>
202
- <span class="spacer" />
203
- <button class="btn ghost" type="button" data-close>Cancel</button>
204
- <button class="btn primary" type="submit">Save</button>
205
- </div>
206
- </form>
207
- </Modal>
208
- </article>
209
- ))}
210
- </div>
211
- )}
212
-
213
- <Modal id="cal-new" title="New calendar">
214
- <form method="post">
215
- <input type="hidden" name="op" value="create" />
216
- <div class="field-row">
217
- <label class="field"><span>Name</span><input type="text" name="name" placeholder="Youth" required autofocus /></label>
218
- <label class="field"><span>Color</span><input type="color" name="color" value="#4f46e5" /></label>
219
- </div>
220
- <label class="field"><span>Description</span><input type="text" name="description" placeholder="Optional" /></label>
221
- <label class="field">
222
- <span>Who can see it</span>
223
- <select name="visibility">{VIS.map((v) => <option value={v}>{VIS_LABEL[v]}</option>)}</select>
224
- </label>
225
- <div class="modal-foot">
226
- <button class="btn ghost" type="button" data-close>Cancel</button>
227
- <button class="btn primary" type="submit">Create calendar</button>
228
- </div>
229
- </form>
230
- </Modal>
231
-
232
- <Modal id="cal-subscribe" title="Subscribe to a feed">
233
- <form method="post">
234
- <input type="hidden" name="op" value="subscribe" />
235
- <p class="hint">Mirror a Google, Apple or Outlook calendar. Its events show up read-only and stay in sync.</p>
236
- <div class="field-row">
237
- <label class="field"><span>Name</span><input type="text" name="name" placeholder="Staff calendar" required /></label>
238
- <label class="field"><span>Color</span><input type="color" name="color" value="#2f9e6e" /></label>
239
- </div>
240
- <label class="field"><span>Calendar link</span><input class="mono" type="text" name="feedUrl" placeholder="Google Calendar link, calendar ID, or .ics URL" required /></label>
241
- <label class="field">
242
- <span>Who can see it</span>
243
- <select name="visibility">{VIS.map((v) => <option value={v}>{VIS_LABEL[v]}</option>)}</select>
244
- </label>
245
- <div class="modal-foot">
246
- <button class="btn ghost" type="button" data-close>Cancel</button>
247
- <button class="btn primary" type="submit">Subscribe</button>
248
- </div>
249
- </form>
250
- </Modal>
251
- </Admin>
252
-
253
- <script>
254
- document.addEventListener('click', async (e) => {
255
- const b = e.target instanceof Element ? e.target.closest<HTMLButtonElement>('[data-copy]') : null;
256
- if (b) {
257
- await navigator.clipboard.writeText(b.dataset.copy ?? '');
258
- const label = b.textContent;
259
- b.textContent = 'Copied';
260
- setTimeout(() => (b.textContent = label), 1200);
261
- }
262
- const c = e.target instanceof Element ? e.target.closest<HTMLElement>('[data-confirm]') : null;
263
- if (c && !confirm(c.dataset.confirm)) e.preventDefault();
264
- });
265
- </script>
@@ -1,4 +1,6 @@
1
1
  ---
2
+ import { I18N, extraLocales, localeName } from '../../../../lib/i18n';
3
+ import { translationEngine } from '../../../../lib/translate';
2
4
  import { getRepo } from '../../../../lib/repo';
3
5
  import { createPreviewToken } from '../../../../lib/preview';
4
6
  import { env } from '../../../../lib/env';
@@ -24,6 +26,10 @@ const editable = mergeEventDraft(event);
24
26
  const [settings, calendars] = await Promise.all([repo.getSettings(), repo.listCalendars()]);
25
27
  const previewPath = `/preview/event/${event.id}`;
26
28
  const sharePath = `${previewPath}?token=${createPreviewToken('event', event.id)}`;
29
+ // other languages this document can be written in (acms({ i18n }))
30
+ const trLanguages = extraLocales().map((code) => ({ code, name: localeName(code, true) }));
31
+ const trMain = { code: I18N.defaultLocale, name: localeName(I18N.defaultLocale || 'en', true) };
32
+ const trDrafts = trLanguages.length > 0 && translationEngine((await repo.getSettings()).ai, Astro.locals) !== null;
27
33
  ---
28
34
 
29
35
  <!doctype html>
@@ -42,6 +48,9 @@ const sharePath = `${previewPath}?token=${createPreviewToken('event', event.id)}
42
48
  client:only="react"
43
49
  kind="event"
44
50
  event={editable}
51
+ languages={trLanguages}
52
+ mainLanguage={trMain}
53
+ translationDrafts={trDrafts}
45
54
  initialHTML={editable.bodyHtml}
46
55
  unsplashEnabled={Boolean(env('UNSPLASH_ACCESS_KEY'))}
47
56
  previewPath={previewPath}
@@ -1,6 +1,9 @@
1
1
  ---
2
2
  import Admin from '../../../../layouts/Admin.astro';
3
3
  import Modal from '../../../../components/Modal.astro';
4
+ import NewEventDialog from '../../../../components/calendar/NewEventDialog';
5
+ import { bookingsEnabled } from '../../../../lib/calendar/config';
6
+ import { todayIn } from '../../../../lib/calendar/board';
4
7
  import ContentList, { type ContentItem } from '../../../../components/ContentList.astro';
5
8
  import { getRepo } from '../../../../lib/repo';
6
9
  import { slugify, type Status } from '../../../../lib/schema';
@@ -16,9 +19,10 @@ import { siteLocale } from '../../../../lib/dates';
16
19
  import { waitUntilFrom } from '../../../../lib/webhooks';
17
20
  import { invalidateRedirectCache } from '../../../../middleware';
18
21
 
19
- // Events list: upcoming first, grouped by the month each event next happens.
20
- // Feed calendars (live ICS mirrors) appear as read-only rows with a "Copy"
21
- // action that turns one into an editable draft.
22
+ // Schedule → List: upcoming first, grouped by the month each event next
23
+ // happens. Subscribed calendars appear as read-only rows with a "Make an
24
+ // editable copy" action. New events open the quick-add dialog (with its mini
25
+ // month); subscribing lives in Calendar settings.
22
26
  if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
23
27
 
24
28
  const repo = await getRepo(Astro.locals);
@@ -107,23 +111,6 @@ if (Astro.request.method === 'POST') {
107
111
  }
108
112
  }
109
113
 
110
- // live mirror of an external calendar
111
- if (op === 'subscribe') {
112
- const url = normalizeCalendarSrc(String(form.get('url') || ''));
113
- const name = String(form.get('name') || '').trim() || 'Imported calendar';
114
- if (!url) return back('Paste a Google Calendar link, a calendar ID, or an .ics feed URL.');
115
- try {
116
- await loadFeed(url, now);
117
- } catch (err) {
118
- return back(err instanceof Error ? err.message : 'Couldn’t load that feed.');
119
- }
120
- const base = slugify(name);
121
- let slug = base;
122
- for (let n = 2; await repo.getCalendarBySlug(slug); n++) slug = `${base}-${n}`;
123
- await repo.createCalendar({ name, slug, kind: 'feed', feedUrl: url, color: safeColor(String(form.get('color') || '#2f9e6e')) });
124
- return back(`Subscribed to “${name}” — its events now show on your calendar.`);
125
- }
126
-
127
114
  // one-time import: .ics / .csv file, or an ICS URL, into drafts
128
115
  if (op === 'import') {
129
116
  const calendarId = String(form.get('calendarId') || '');
@@ -221,16 +208,16 @@ if ((scope === 'upcoming' || scope === 'all') && feedCals.length) {
221
208
  const uid = uids.get(`${occ.title}|${occ.location}`) ?? '';
222
209
  items.push({
223
210
  id: `feed-${c.id}-${occ.start}`,
224
- href: `/admin/calendar/calendars#cal-${c.id}`,
211
+ href: `/admin/calendar/settings#cal-${c.id}`,
225
212
  title: occ.title,
226
213
  sub: '',
227
214
  meta: [occ.location, `from ${c.name}`].filter(Boolean).join(' · '),
228
215
  when: occ.allDay ? f.range : f.range,
229
216
  at: occ.start,
230
217
  status: 'feed',
231
- statusLabel: 'Read-only · feed',
218
+ statusLabel: 'Subscribed · read-only',
232
219
  readonly: true,
233
- action: uid ? { label: 'Copy', fields: { op: 'copy', calendarId: c.id, uid } } : undefined,
220
+ action: uid ? { label: 'Make editable copy', fields: { op: 'copy', calendarId: c.id, uid } } : undefined,
234
221
  });
235
222
  }
236
223
  } catch (err) {
@@ -261,72 +248,45 @@ const tomorrowIso = `${tomorrow.y}-${String(tomorrow.m).padStart(2, '0')}-${Stri
261
248
  const subBits = [
262
249
  `${counts.upcoming} upcoming`,
263
250
  counts.drafts ? `${counts.drafts} draft${counts.drafts === 1 ? '' : 's'}` : '',
264
- feedCals.length ? `${feedCals.length} feed calendar${feedCals.length === 1 ? '' : 's'}` : '',
251
+ feedCals.length ? `${feedCals.length} subscribed calendar${feedCals.length === 1 ? '' : 's'}` : '',
265
252
  ].filter(Boolean);
266
253
  ---
267
254
 
268
- <Admin title="Events" section="calendar:events">
255
+ <Admin title="Schedule" section="calendar:schedule">
269
256
  <div class="page-head">
270
257
  <div>
271
- <h1>Events</h1>
258
+ <h1>All events</h1>
272
259
  <p class="sub">{subBits.join(' · ')} · public at <a href={CALENDAR.base} target="_blank" rel="noopener">{CALENDAR.base}</a></p>
273
260
  </div>
274
261
  <div class="page-actions">
275
- <button class="btn" type="button" data-modal="import-events">Import</button>
276
- <button class="btn primary" type="button" data-modal="new-event">New event</button>
262
+ <nav class="seg" aria-label="View">
263
+ <a href="/admin/calendar">Month</a>
264
+ <a href="/admin/calendar?view=week">Week</a>
265
+ <a class="on" href="/admin/calendar/events" aria-current="page">List</a>
266
+ </nav>
267
+ <a class="btn ico" href="/admin/calendar/settings" title="Calendar settings" aria-label="Calendar settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" /></svg></a>
268
+ <details class="newmenu">
269
+ <summary class="btn primary">New <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d={chev} /></svg></summary>
270
+ <div class="newmenu-pop" role="menu">
271
+ <button type="button" role="menuitem" data-new-event>Event <kbd>N</kbd></button>
272
+ {bookingsEnabled() && <a role="menuitem" href="/admin/calendar/bookings?block=1">Block time <small>room closed, time off</small></a>}
273
+ <button type="button" role="menuitem" data-modal="import-events">Import events… <small>.ics or .csv</small></button>
274
+ </div>
275
+ </details>
277
276
  </div>
278
277
  </div>
279
278
 
280
279
  {notice && <p class="notice" role="status">{notice}</p>}
281
280
  {feedErrors.map((e) => <p class="notice warn">Couldn’t refresh {e}</p>)}
282
281
 
283
- <Modal id="new-event" title="New event" open={Astro.url.searchParams.has('new')}>
284
- <form method="post">
285
- <input type="hidden" name="op" value="create" />
286
- <label class="field">
287
- <span>Title</span>
288
- <input type="text" name="title" placeholder="Fall Festival" required autofocus />
289
- </label>
290
- <div class="field-row">
291
- <label class="field"><span>Date</span><input type="date" name="date" value={tomorrowIso} required /></label>
292
- <label class="field"><span>Time <em class="hint">blank = all day</em></span><input type="time" name="time" value="10:00" step="300" /></label>
293
- </div>
294
- {nativeCals.length > 0 && (
295
- <label class="field">
296
- <span>Calendar</span>
297
- <select name="calendarId">
298
- <option value="">None — main calendar only</option>
299
- {nativeCals.map((c) => <option value={c.id} selected={c.id === calFilter}>{c.name}</option>)}
300
- </select>
301
- </label>
302
- )}
303
- <div class="modal-foot">
304
- <button class="btn ghost" type="button" data-close>Cancel</button>
305
- <button class="btn primary" type="submit">Create event</button>
306
- </div>
307
- <p class="hint modal-note">Creates a draft and opens the editor, where you add the image, location, button and repeat rule.</p>
308
- </form>
309
- </Modal>
282
+ <NewEventDialog client:load calendars={nativeCals.map((c) => ({ id: c.id, name: c.name, color: safeColor(c.color) }))}
283
+ defaultCalendarId={settings.calendar.defaultCalendarId || calFilter} weekStart={settings.calendar.weekStart} locale={locale}
284
+ today={todayIn(tz)} day={tomorrowIso} open={Astro.url.searchParams.has('new')} />
310
285
 
311
- <Modal id="import-events" title="Bring in events">
312
- <div class="seg block ev-import-tabs" role="tablist">
313
- <button type="button" role="tab" aria-selected="true" data-tab="subscribe">Subscribe to a calendar</button>
314
- <button type="button" role="tab" aria-selected="false" data-tab="once">Import once</button>
315
- </div>
316
- <form method="post" data-panel="subscribe">
317
- <input type="hidden" name="op" value="subscribe" />
318
- <p class="hint">Mirror a Google, Apple or Outlook calendar live. Its events show on your calendar and stay in sync, read-only.</p>
319
- <label class="field"><span>Name</span><input type="text" name="name" placeholder="Staff calendar" required /></label>
320
- <label class="field"><span>Calendar link</span><input class="mono" type="text" name="url" placeholder="Google Calendar link, calendar ID, or an .ics URL" required /></label>
321
- <label class="field"><span>Color</span><input type="color" name="color" value="#2f9e6e" /></label>
322
- <div class="modal-foot">
323
- <button class="btn ghost" type="button" data-close>Cancel</button>
324
- <button class="btn primary" type="submit">Subscribe</button>
325
- </div>
326
- </form>
327
- <form method="post" enctype="multipart/form-data" data-panel="once" hidden>
286
+ <Modal id="import-events" title="Import events" open={Astro.url.searchParams.has('import')}>
287
+ <form method="post" enctype="multipart/form-data">
328
288
  <input type="hidden" name="op" value="import" />
329
- <p class="hint">Copy events in as editable drafts. Works with .ics exports from Google, Apple and Outlook, or a spreadsheet saved as .csv with Title and Start date columns.</p>
289
+ <p class="hint">Copy events in once, as editable drafts. Works with .ics exports from Google, Apple and Outlook, or a spreadsheet saved as .csv with Title and Start date columns. To keep a calendar in sync instead, <a href="/admin/calendar/settings?subscribe=1#subscribed">subscribe to it</a>.</p>
330
290
  <label class="field"><span>File</span><input type="file" name="file" accept=".ics,.csv,text/calendar,text/csv" /></label>
331
291
  <label class="field"><span>Or a calendar link</span><input class="mono" type="text" name="url" placeholder="https://calendar.google.com/…" /></label>
332
292
  {nativeCals.length > 0 && (
@@ -348,10 +308,9 @@ const subBits = [
348
308
  {events.length === 0 && feedCals.length === 0 ? (
349
309
  <div class="empty-state">
350
310
  <b>No events yet</b>
351
- <p>Add your first event, or bring in an existing Google, Apple or Outlook calendar.</p>
311
+ <p>Events are things that happen on a date. Add one, or import a calendar file from Google, Apple or Outlook.</p>
352
312
  <div class="empty-actions">
353
- <button class="btn" type="button" data-modal="import-events">Import</button>
354
- <button class="btn primary" type="button" data-modal="new-event">New event</button>
313
+ <button class="btn primary" type="button" data-new-event>New event</button>
355
314
  </div>
356
315
  </div>
357
316
  ) : (
@@ -386,7 +345,7 @@ const subBits = [
386
345
  <ContentList items={items} noun="event" timeZone={settings.timezone || undefined} groupByMonth={true} empty="No events match the current filters." />
387
346
  {items.length === 0 && (
388
347
  <p class="hint" style="padding:24px 8px">
389
- {scope === 'upcoming' ? 'Nothing coming up.' : 'No events match the current filters.'} <a href="/admin/calendar/events?new=1">Add an event</a>
348
+ {scope === 'upcoming' ? 'Nothing coming up.' : 'No events match the current filters.'} <button class="linklike" type="button" data-new-event>Add an event</button>
390
349
  </p>
391
350
  )}
392
351
  </>
@@ -399,10 +358,9 @@ const subBits = [
399
358
  if (!(e.target instanceof Node && d.contains(e.target))) d.open = false;
400
359
  });
401
360
  });
402
- // import modal tabs
403
- const tabs = document.querySelectorAll<HTMLButtonElement>('.ev-import-tabs [data-tab]');
404
- tabs.forEach((t) => t.addEventListener('click', () => {
405
- tabs.forEach((x) => x.setAttribute('aria-selected', String(x === t)));
406
- document.querySelectorAll<HTMLElement>('#import-events [data-panel]').forEach((p) => { p.hidden = p.dataset.panel !== t.dataset.tab; });
407
- }));
361
+ // the New ▾ menu closes like the filter menus
362
+ document.addEventListener('click', (e) => {
363
+ const m = document.querySelector<HTMLDetailsElement>('details.newmenu[open]');
364
+ if (m && (!(e.target instanceof Node) || !m.contains(e.target) || (e.target as Element).closest('[role=menuitem]'))) m.open = false;
365
+ });
408
366
  </script>
@@ -0,0 +1,43 @@
1
+ ---
2
+ import Admin from '../../../layouts/Admin.astro';
3
+ import CalendarBoard from '../../../components/calendar/CalendarBoard';
4
+ import { getRepo } from '../../../lib/repo';
5
+ import { CALENDAR, bookingsEnabled } from '../../../lib/calendar/config';
6
+ import { loadBoard, todayIn } from '../../../lib/calendar/board';
7
+ import { isIsoDay, isIsoMonth, monthWeeks, weekDays } from '../../../lib/calendar/grid';
8
+ import { siteLocale } from '../../../lib/dates';
9
+
10
+ // Schedule: the Calendar section's home — a month (or week) grid of every
11
+ // event, draft, subscribed calendar and booking. ?m=YYYY-MM picks the month,
12
+ // ?view=week&d=YYYY-MM-DD a week; the list lives at /admin/calendar/events.
13
+ if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
14
+ if (Astro.url.searchParams.get('view') === 'list') return Astro.redirect('/admin/calendar/events');
15
+
16
+ const repo = await getRepo(Astro.locals);
17
+ const settings = await repo.getSettings();
18
+ const tz = settings.timezone || 'UTC';
19
+ const today = todayIn(tz);
20
+ const weekStart = settings.calendar.weekStart;
21
+ const view = Astro.url.searchParams.get('view') === 'week' ? 'week' : 'month';
22
+ const d = Astro.url.searchParams.get('d') ?? '';
23
+ const m = Astro.url.searchParams.get('m') ?? '';
24
+ const anchor = isIsoDay(d) ? d : isIsoMonth(m) ? `${m}-01` : today;
25
+ const weeks = monthWeeks(anchor.slice(0, 7), weekStart);
26
+ const [from, to] = view === 'week' ? [weekDays(anchor, weekStart)[0], weekDays(anchor, weekStart)[6]] : [weeks[0][0], weeks[weeks.length - 1][6]];
27
+ const initial = await loadBoard(repo, settings, from, to);
28
+ ---
29
+
30
+ <Admin title="Schedule" section="calendar:schedule" wide>
31
+ <CalendarBoard
32
+ client:load
33
+ initial={initial}
34
+ view={view}
35
+ anchor={anchor}
36
+ today={today}
37
+ weekStart={weekStart}
38
+ locale={siteLocale(settings)}
39
+ defaultCalendarId={settings.calendar.defaultCalendarId}
40
+ bookings={bookingsEnabled()}
41
+ listHref="/admin/calendar/events"
42
+ />
43
+ </Admin>
@@ -9,6 +9,9 @@ import { describeLink } from '../../../../lib/booking/availability';
9
9
  import { isValidTimeZone } from '../../../../lib/calendar/events';
10
10
  import { normalizeCalendarSrc } from '../../../../lib/calendar/src';
11
11
  import { siteOrigin } from '../../../../lib/seo';
12
+ import { approvalShort, linkApproval } from '../../../../lib/booking/approval';
13
+ import { eventHolds } from '../../../../lib/booking/holds';
14
+ import { fmt } from '../../../../lib/booking/view';
12
15
 
13
16
  // One resource: details, weekly hours, date overrides, calendars that block
14
17
  // time, who gets notified, the staff feed, and its booking links.
@@ -53,7 +56,7 @@ if (Astro.request.method === 'POST') {
53
56
  const base = slugify(name);
54
57
  let slug = base;
55
58
  for (let n = 2; await repo.getBookingLinkBySlug(id, slug); n++) slug = `${base}-${n}`;
56
- const l = await repo.createBookingLink({ resourceId: id, name, slug, autoConfirm: true });
59
+ const l = await repo.createBookingLink({ resourceId: id, name, slug });
57
60
  return Astro.redirect(`/admin/calendar/resources/${id}/links/${l.id}`);
58
61
  } else if (op === 'rotate') {
59
62
  await repo.updateResource(id, { feedToken: token() });
@@ -65,14 +68,17 @@ if (Astro.request.method === 'POST') {
65
68
  resource = (await repo.getResource(id))!;
66
69
  }
67
70
  const links = await repo.listBookingLinks(id);
71
+ // events holding this room in the next 90 days (lib/booking/holds.ts)
72
+ const holds = (await eventHolds(repo, settings, Date.now(), Date.now() + 90 * 86_400_000, id)).slice(0, 12);
73
+ const holdFmt = fmt(settings, resource.timezone || settings.timezone || 'UTC');
68
74
  const staffFeed = origin + resourceFeedUrl(resource.slug, resource.feedToken);
69
75
  const ovRows = [...resource.overrides, { date: '', endDate: '', ranges: [], label: '' }, { date: '', endDate: '', ranges: [], label: '' }];
70
76
  ---
71
77
 
72
- <Admin title={resource.name} section="calendar:resources">
78
+ <Admin title={resource.name} section="calendar:bookings">
73
79
  <div class="page-head">
74
80
  <div>
75
- <p class="sub"><a href="/admin/calendar/resources">Resources</a> ›</p>
81
+ <p class="sub"><a href="/admin/calendar/bookings">Bookings</a> › <a href="/admin/calendar/resources">Rooms &amp; people</a> ›</p>
76
82
  <h1>{resource.name}</h1>
77
83
  <p class="sub"><a href={resourceUrl(resource.slug)} target="_blank" rel="noopener">{origin.replace(/^https?:\/\//, '')}{resourceUrl(resource.slug)} ↗</a></p>
78
84
  </div>
@@ -89,7 +95,7 @@ const ovRows = [...resource.overrides, { date: '', endDate: '', ranges: [], labe
89
95
  {links.map((l) => (
90
96
  <a class="res-linkrow" href={`/admin/calendar/resources/${id}/links/${l.id}`}>
91
97
  <span><b>{l.name}</b>{!l.active && <span class="pill lang">Off</span>}{l.visibility === 'unlisted' && <span class="pill lang">Unlisted</span>}</span>
92
- <small>{describeLink(l)} · {l.mode === 'range' ? 'choose a length' : l.mode === 'day' ? 'whole days' : 'fixed length'} · <em class={l.autoConfirm ? 'auto' : 'manual'}>{l.autoConfirm ? 'Confirms automatically' : 'Needs approval'}</em></small>
98
+ <small>{describeLink(l)} · {l.mode === 'range' ? 'choose a length' : l.mode === 'day' ? 'whole days' : 'fixed length'} · {(() => { const a = linkApproval(l, settings); return <em class={a.mode === 'auto' ? 'auto' : 'manual'}>{approvalShort(a)}{a.inherited ? ' (site setting)' : ''}</em>; })()}</small>
93
99
  <code>{bookingLinkUrl(resource.slug, l.slug)}</code>
94
100
  </a>
95
101
  ))}
@@ -97,10 +103,26 @@ const ovRows = [...resource.overrides, { date: '', endDate: '', ranges: [], labe
97
103
  <form method="post" class="res-newlink">
98
104
  <input type="hidden" name="op" value="link" />
99
105
  <input type="text" name="linkName" placeholder="New link, e.g. “Event or reception”" required />
100
- <button class="btn" type="submit">Add link</button>
106
+ <button class="btn" type="submit">Add booking link</button>
101
107
  </form>
102
108
  </section>
103
109
 
110
+ <section class="card card-pad">
111
+ <div class="card-head"><b>Held by events</b><span class="hint">Events that use this {resource.kind.toLowerCase()} can’t be booked over</span></div>
112
+ {holds.length === 0 ? (
113
+ <p class="hint" style="margin:0">No events are holding it in the next 90 days. Tick it under <b>Rooms</b> in an event’s settings to hold it.</p>
114
+ ) : (
115
+ <div class="res-linklist">
116
+ {holds.map((h) => (
117
+ <a class="res-linkrow" href={`/admin/calendar/events/${h.eventId}`}>
118
+ <span><b>{h.title}</b></span>
119
+ <small>{holdFmt.day.format(h.start)} · {h.end - h.start >= 86_400_000 - 3_600_000 ? 'all day' : `${holdFmt.time.format(h.start)} – ${holdFmt.time.format(h.end)}`}</small>
120
+ </a>
121
+ ))}
122
+ </div>
123
+ )}
124
+ </section>
125
+
104
126
  <section class="card card-pad">
105
127
  <div class="card-head"><b>Open hours</b><span class="hint">The outer limit for every link</span></div>
106
128
  <HoursEditor hours={resource.hours} prefix="hours" form="res-form" note={`Times in ${resource.timezone || settings.timezone || 'UTC'}. Links can narrow these hours but never extend them.`} />