@acms-subir/core 0.3.3 → 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 (66) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/cli/preview.mjs +8 -4
  3. package/cli/sections.mjs +4 -2
  4. package/docs/mobile.md +1 -1
  5. package/docs/theme-authoring.md +19 -3
  6. package/integration.mjs +21 -4
  7. package/package.json +14 -12
  8. package/src/actions/index.ts +186 -3
  9. package/src/components/booking/BookingFlow.astro +6 -3
  10. package/src/components/calendar/BookingsHead.astro +51 -0
  11. package/src/components/calendar/CalendarBoard.tsx +618 -0
  12. package/src/components/calendar/MiniMonth.tsx +54 -0
  13. package/src/components/calendar/NewEventDialog.tsx +44 -0
  14. package/src/components/calendar/QuickAdd.tsx +263 -0
  15. package/src/components/calendar/RoomHolds.tsx +203 -0
  16. package/src/components/calendar/WhereItLands.tsx +51 -0
  17. package/src/components/editor/EventPanel.tsx +23 -2
  18. package/src/components/editor/PostEditor.tsx +32 -4
  19. package/src/layouts/Admin.astro +5 -6
  20. package/src/layouts/Site.astro +29 -2
  21. package/src/lib/backup/content.ts +6 -1
  22. package/src/lib/booking/agent.ts +3 -2
  23. package/src/lib/booking/approval.ts +70 -0
  24. package/src/lib/booking/holds.ts +120 -0
  25. package/src/lib/booking/service.ts +37 -3
  26. package/src/lib/calendar/agent-schema.ts +9 -1
  27. package/src/lib/calendar/agent.ts +16 -1
  28. package/src/lib/calendar/board.ts +171 -0
  29. package/src/lib/calendar/config.ts +3 -0
  30. package/src/lib/calendar/grid.ts +169 -0
  31. package/src/lib/calendar/migration.ts +16 -0
  32. package/src/lib/calendar/render.ts +9 -7
  33. package/src/lib/calendar/schedule.ts +166 -0
  34. package/src/lib/content-service.ts +5 -0
  35. package/src/lib/drafts.ts +1 -1
  36. package/src/lib/mobile/calendar.ts +10 -6
  37. package/src/lib/mobile/http.ts +6 -0
  38. package/src/lib/repo/migrations.ts +3 -0
  39. package/src/lib/repo/sql.ts +28 -16
  40. package/src/lib/repo/types.ts +2 -2
  41. package/src/lib/scheduler.ts +3 -1
  42. package/src/lib/schema.ts +49 -0
  43. package/src/lib/sections.ts +33 -8
  44. package/src/lib/site-chrome.ts +34 -0
  45. package/src/lib/theme-assets.ts +16 -0
  46. package/src/lib/theme.ts +17 -3
  47. package/src/middleware.ts +6 -2
  48. package/src/pages/admin/calendar/bookings.astro +32 -26
  49. package/src/pages/admin/calendar/calendars.astro +4 -263
  50. package/src/pages/admin/calendar/events/index.astro +41 -83
  51. package/src/pages/admin/calendar/index.astro +43 -0
  52. package/src/pages/admin/calendar/resources/[id].astro +27 -5
  53. package/src/pages/admin/calendar/resources/index.astro +16 -17
  54. package/src/pages/admin/calendar/resources/link.astro +52 -12
  55. package/src/pages/admin/calendar/settings.astro +458 -0
  56. package/src/pages/admin/integrations.astro +5 -11
  57. package/src/pages/book/[resource].ics.ts +7 -1
  58. package/src/styles/admin.css +48 -0
  59. package/src/styles/cards.css +5 -1
  60. package/src/styles/editor.css +36 -0
  61. package/src/styles/schedule.css +325 -0
  62. package/src/styles/sections.css +19 -16
  63. package/src/theme-sdk/fields.mjs +5 -1
  64. package/src/theme-sdk/index.ts +5 -1
  65. package/src/virtual.d.ts +3 -0
  66. package/src/pages/admin/calendar/index.ts +0 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.4
4
+
5
+ ### Patch Changes
6
+
7
+ - d29c1c8: Calendar admin: a real calendar and fewer tabs.
8
+
9
+ - **Schedule** (`/admin/calendar`): a month and week grid of events, drafts, subscribed calendars and bookings. Click a day (or drag across days) to add an event with a live preview, drag an event to move it — one date of a repeating event or the whole series — with Undo, open a day's full list, and show or hide each calendar. Phones get a compact month with dots and a day list.
10
+ - **Where it lands**: the New event dialog and the event editor's When section show a mini month with busy days and what else is on the chosen day.
11
+ - **Fewer tabs**: the Calendar section is now Schedule and Bookings. Events became Schedule's List view; Calendars, subscribed calendars, share links and defaults moved to one **Calendar settings** page; Resources became **Bookings → Rooms & people**. Old URLs redirect. One name per thing (subscribed calendar, share link, needs approval) and one "New ▾" per page.
12
+ - **Booking approval**: a site-wide setting (Calendar settings → Booking approval, or `acms({ calendar: { bookings: { approval } } })`) — automatically confirm (default), automatically unless a rule flags it (longer than N hours, first booking from an email, more than N people), or approve each booking. Each link can follow it or choose its own, with an optional deadline that approves or declines unanswered requests.
13
+ - **Weeks start on** Sunday or Monday (Calendar settings → Defaults), used by the Schedule and the public calendar.
14
+
15
+ Migration v40 adds `settings.calendar` and booking link approval columns; existing links keep their behavior.
16
+
17
+ - d29c1c8: Events can hold bookable rooms. Tick the rooms or people an event uses (event settings → Rooms, or the Rooms line in quick add), and they can't be booked while it happens, with optional setup and cleanup minutes, or for the whole day. Holds are worked out from the event, so moving, repeating, skipping a date, cancelling or deleting it frees or moves the room automatically. Every booking path respects them: booking pages, embeds, the mobile API and agent tools.
18
+
19
+ - Each room shows whether it's free at the event's time. Overlapping bookings show inline and again in a publish dialog, where you keep each one (share the room) or cancel it and email the guest.
20
+ - Drafts hold rooms only when "Hold now" is on. Picking rooms fills the event's place when it's empty.
21
+ - Holds show in Bookings → Day view, on each room's page ("Held by events"), in the Schedule peek and in the room's staff calendar feed. Staff bookings over a hold need "Book anyway".
22
+ - Agent and REST event tools accept `rooms: { resources, span, setup, cleanup }`.
23
+
24
+ Migration v41 adds `events.room_hold`.
25
+
26
+ - d29c1c8: Fixes and theme features from a real site build.
27
+
28
+ - `acms section add <name> --native --force` now only replaces the app's copy in `mobile/sections/`; it never rewrites the theme's web component or `theme.json`.
29
+ - `settings.update` accepts `timezone` and `locale` (validated like Settings → General) and rejects unknown keys instead of silently dropping them.
30
+ - Core section styles are wrapped in `:where()`, so any theme class beats them without doubled selectors. Embed cards drop their outer margin at the start or end of a section or page.
31
+ - Themes can replace the site header and footer with `templates/header.astro` / `templates/footer.astro`. They receive a resolved `site` prop (`ChromeProps`): localized links, the current page, language names like "Español", social icons, and search/scheme/menu hooks.
32
+ - Section image fields (and defaults) can point at theme files (`assets/hero.jpg`); they resolve to the built URL on the site and in the mobile API.
33
+ - Post sources (`PostItem`) include `authors` (name, URL, avatar) and an automatic excerpt when the post has none.
34
+ - `/api/v1/events` `from`/`to` accept ISO date-times with a timezone as well as site-local days; the client types document both.
35
+ - `acms theme dev` serves the project's `public/` folder and prints the preview's admin URL and password.
36
+
37
+ - 8dbe45e: Fix fresh installs failing to build: pin every TipTap package to the same version so a new TipTap release can't mix versions.
38
+
3
39
  ## 0.3.3
4
40
 
5
41
  ### Patch Changes
package/cli/preview.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdir, writeFile, cp, symlink, mkdtemp, readFile } from 'node:fs/promises';
1
+ import { mkdir, writeFile, cp, symlink, mkdtemp, readFile, stat } from 'node:fs/promises';
2
2
  import { join, resolve, dirname } from 'node:path';
3
3
  import { createRequire } from 'node:module';
4
4
  import { spawn, spawnSync } from 'node:child_process';
@@ -10,6 +10,8 @@ export async function prepareFixture(p,dir,slug){
10
10
  const req=createRequire(join(p.cwd,'package.json'));const astro=dirname(req.resolve('astro/package.json'));
11
11
  await symlink(dirname(astro),join(root,'node_modules'),'dir');
12
12
  await mkdir(join(root,'src/themes'),{recursive:true});await symlink(dir,join(root,'src/themes',slug),'dir');
13
+ // the project's public/ (videos, posters, fonts the theme links to by URL) serves as-is
14
+ const pub=join(p.cwd,'public');if(await stat(pub).then(s=>s.isDirectory(),()=>false))await symlink(pub,join(root,'public'),'dir');
13
15
  await mkdir(join(root,'src/pages/acms-theme-preview'),{recursive:true});await mkdir(join(root,'src/actions'),{recursive:true});
14
16
  await writeFile(join(root,'src/actions/index.ts'),"export { server } from '@acms-subir/core/actions';\n");
15
17
  await writeFile(join(root,'package.json'),JSON.stringify({name:'acms-isolated-preview',type:'module',private:true}));
@@ -31,8 +33,9 @@ await repo.updateSettings({activeTheme:${JSON.stringify(slug)}});
31
33
  return Astro.redirect('/');
32
34
  ---`);
33
35
  await writeFile(join(root,'src/pages/acms-theme-preview/index.astro'),`---\nconst routes=['/','/preview-about','/blog','/blog?page=2','/blog/preview-post-0','/events/preview-event','/calendar','/book','/search?q=Fixture','/404'];\n---\n<html><head><title>ACMS theme fixture controls</title></head><body><h1>Isolated theme preview</h1><p>Initialize synthetic data once, then inspect real ACMS routes. Empty lists and missing covers are intentional. Check phone, tablet and desktop layouts using the controls below.</p><a href='/acms-theme-preview/setup'>Initialize fixtures</a><nav>{routes.map(route=><p><a href={route} target='fixture'>{route}</a></p>)}</nav><p><a href='/?scheme=light' target='fixture'>Light</a> · <a href='/?scheme=dark' target='fixture'>Dark</a></p><p aria-label='Preview width'><button data-width='390px'>Phone</button> <button data-width='768px'>Tablet</button> <button data-width='100%'>Desktop</button></p><iframe id='fixture' name='fixture' title='Theme preview' src='/' style='width:100%;height:70vh;border:1px solid #999;max-width:100%'></iframe><script>document.querySelectorAll('button[data-width]').forEach(button=>button.addEventListener('click',()=>{const frame=document.getElementById('fixture');if(frame)frame.style.width=button.getAttribute('data-width')||'100%';}));</script></body></html>`);
34
- const env={PATH:process.env.PATH,HOME:process.env.HOME,TMPDIR:process.env.TMPDIR,NODE_ENV:'development',SQLITE_URL:`file:${join(root,'fixture.db')}`,SESSION_SECRET:randomBytes(32).toString('hex'),ADMIN_PASSWORD:randomBytes(24).toString('hex'),ACMS_NO_UPDATE_CHECK:'1'};
35
- return {root,astro:join(astro,'astro.js'),env};
36
+ const password=randomBytes(12).toString('hex');
37
+ const env={PATH:process.env.PATH,HOME:process.env.HOME,TMPDIR:process.env.TMPDIR,NODE_ENV:'development',SQLITE_URL:`file:${join(root,'fixture.db')}`,SESSION_SECRET:randomBytes(32).toString('hex'),ADMIN_PASSWORD:password,ACMS_NO_UPDATE_CHECK:'1'};
38
+ return {root,astro:join(astro,'astro.js'),env,password};
36
39
  }
37
40
  export async function buildFixture(p,dir,slug){
38
41
  const fixture=await prepareFixture(p,dir,slug);
@@ -42,7 +45,8 @@ export async function buildFixture(p,dir,slug){
42
45
  }
43
46
  export async function devFixture(p,dir,slug,flags){
44
47
  const f=await prepareFixture(p,dir,slug);const port=Number(flags.port??4322);if(!Number.isInteger(port)||port<1024||port>65535)fail('USAGE','Port must be 1024–65535.');
45
- console.error(`Isolated preview: http://localhost:${port}/acms-theme-preview\nWorkspace: ${f.root}\nTheme sources are linked for live reload; fixture data stays isolated.`);
48
+ // a throwaway local-only server with synthetic data, so the password is safe to show
49
+ console.error(`Isolated preview: http://localhost:${port}/acms-theme-preview\nAdmin: http://localhost:${port}/admin · password: ${f.password}\nWorkspace: ${f.root}\nTheme sources and public/ are linked for live reload; fixture data stays isolated.`);
46
50
  const child=spawn(process.execPath,[f.astro,'dev','--host','127.0.0.1','--port',String(port),...(!flags.json&&process.stdout.isTTY?['--open','/acms-theme-preview']:[])],{cwd:f.root,env:f.env,stdio:['ignore','pipe','pipe']});child.stdout.pipe(process.stderr);child.stderr.pipe(process.stderr);
47
51
  const stop=()=>child.kill('SIGTERM');process.once('SIGINT',stop);process.once('SIGTERM',stop);
48
52
  const exit=await new Promise((done,reject)=>{child.once('exit',done);child.once('error',reject);});process.removeListener('SIGINT',stop);process.removeListener('SIGTERM',stop);
package/cli/sections.mjs CHANGED
@@ -64,8 +64,10 @@ export async function sectionCommand(command, args, flags) {
64
64
  const dest = join(dir, 'sections', target);
65
65
  // the mobile app: packaged native component by default, an editable copy with --native
66
66
  const mobile = await targetApp(flags, { required: !!flags.native });
67
- // already in the theme + --native: only add the app's copy, leave the web section alone
68
- const nativeOnly = flags.native && !flags.force && (await exists(dest)) && Boolean((await readJson(join(dir, 'theme.json'))).sections?.[type]);
67
+ // already in the theme + --native: only add the app's copy, never touch the
68
+ // web section or theme.json — --force then means "replace the app copy".
69
+ // (Replacing the theme's web section takes a plain `section add --force`.)
70
+ const nativeOnly = flags.native && (await exists(dest)) && Boolean((await readJson(join(dir, 'theme.json'))).sections?.[type]);
69
71
  if ((await exists(dest)) && !flags.force && !nativeOnly) fail('EXISTS', `sections/${target} already exists.`, [], ['Use --as <type> or --force.']);
70
72
  const nativePlan = flags.native ? await libraryTwin(mobile.app, name, type, flags, false) : null;
71
73
  if (flags['dry-run']) return { dryRun: true, type, files: nativeOnly ? [] : [`sections/${target}`, 'theme.json'], ...(nativePlan ? { native: nativePlan } : {}) };
package/docs/mobile.md CHANGED
@@ -116,7 +116,7 @@ Read the app's `AGENTS.md` too: it holds the same rules, next to the code.
116
116
  | `useSite()` | name, menu, languages, features (`calendar`, `bookings`, `blog`) |
117
117
  | `usePage(slug)` | a published page: `mode` (`sections`/`content`), `sections`, `body.html`; 404 when missing |
118
118
  | `usePosts({ collection, limit })` / `usePost(slug)` | posts (infinite: `items`, `fetchNextPage`) |
119
- | `useEvents({ calendar, from, to, limit, q })` | `data.occurrences[]` (`start`, `end`, `allDay`, `event`) in a window (default 30 days, max 90) |
119
+ | `useEvents({ calendar, from, to, limit, q })` | `data.occurrences[]` (`start`, `end`, `allDay`, `event`) in a window (default 30 days, max 90). `from`/`to` take a site-local day (`'2026-10-01'`) or an ISO date-time with a zone (`new Date().toISOString()`) |
120
120
  | `useEvent(slug)` | one event |
121
121
  | `useResources()` / `useAvailability({ resource, link })` / `useBooking()` | bookings |
122
122
  | `useLocale()`, `useTheme()`, `useLayout()`, `useRefresh()`, `useOpenLink()` | language, tokens, layout, refresh, links |
@@ -32,9 +32,25 @@ const { page, posts } = Astro.props;
32
32
  {posts.map(post => <PostCard href={collectionUrl(post.collection, post.slug)} title={post.title} excerpt={post.excerpt} />)}
33
33
  ```
34
34
 
35
- Public types: HomeProps (settings, page, posts, tags), PageProps (settings, page, html), PostProps (settings, post, html, authors, related), EventProps (settings, event, occurrence, calendars, html, upcoming, origin, preview, rsvp), SectionProps (settings, page, variant, data). Public components: PostCard with card/row/feature variants, Pager with base/page/count, TagLabel with name/href/accent. Helpers include collectionUrl, postUrl, postsIndexUrl, tagUrl, formatDate, slugify, authorSlug, localeFromPath, localeHomeUrl and CONTENT. Use collectionUrl for non-default collections; postUrl is the built-in posts alias.
35
+ Public types: ChromeProps (site), HomeProps (settings, page, posts, tags), PageProps (settings, page, html), PostProps (settings, post, html, authors, related), EventProps (settings, event, occurrence, calendars, html, upcoming, origin, preview, rsvp), SectionProps (settings, page, variant, data). Public components: PostCard with card/row/feature variants, Pager with base/page/count, TagLabel with name/href/accent. Helpers include collectionUrl, postUrl, postsIndexUrl, tagUrl, formatDate, slugify, authorSlug, localeFromPath, localeHomeUrl and CONTENT. Use collectionUrl for non-default collections; postUrl is the built-in posts alias.
36
36
 
37
- Template filenames: `home.astro`, `event.astro`, `post-<name>.astro`, `page-<name>.astro`. Post/page variants are selected in the content editor. Core owns routes, auth, SEO and published-content queries. Templates only replace their documented content area. Never query private data from a public section or insert untrusted strings with set:html. The core's rendered `html`/`bodyHtml` is the intended rich-content handoff.
37
+ Template filenames: `home.astro`, `event.astro`, `header.astro`, `footer.astro`, `post-<name>.astro`, `page-<name>.astro`.
38
+
39
+ `header.astro` and `footer.astro` replace the site header and footer on every public page. They receive `ChromeProps` — one `site` prop (`SiteChrome`) with everything already resolved for the request: `title`, `tagline`, `logo`, `homeUrl`, `links` (header nav, each `{ label, url, current, external }`, already localized), `footerLinks`, `social` (with inline SVG `icon`), `readLinks`, `footerHtml`, `languages` (`{ code, label, url, current }`, where `label` is the language's own name, e.g. "Español"), `search` (`{ url, label }` or null), `schemeToggle`, `liveHeader`, `headerStyle`, `footerStyle`. Core behaviors attach through data hooks, so keep them on your own elements: `data-search-open` (opens search), `data-scheme-toggle` (light/dark), `data-menu-open` (phone menu sheet, built from `site.links`) and a hidden `data-live-header` link (shown during a live stream).
40
+
41
+ ```astro
42
+ ---
43
+ import type { ChromeProps } from '@acms-subir/core/theme';
44
+ const { site } = Astro.props as ChromeProps;
45
+ ---
46
+ <header class="gh-head">
47
+ <a href={site.homeUrl}>{site.logo ? <img src={site.logo} alt={site.title} /> : site.title}</a>
48
+ <nav>{site.links.map((l) => <a href={l.url} aria-current={l.current ? 'page' : undefined}>{l.label}</a>)}</nav>
49
+ {site.languages.map((l) => <a href={l.url} lang={l.code} aria-current={l.current ? 'true' : undefined}>{l.label}</a>)}
50
+ {site.search && <a href={site.search.url} data-search-open>{site.search.label}</a>}
51
+ <button type="button" data-menu-open aria-label="Open menu">Menu</button>
52
+ </header>
53
+ ``` Post/page variants are selected in the content editor. Core owns routes, auth, SEO and published-content queries. Templates only replace their documented content area. Never query private data from a public section or insert untrusted strings with set:html. The core's rendered `html`/`bodyHtml` is the intended rich-content handoff.
38
54
 
39
55
  ## Editable sections
40
56
 
@@ -56,7 +72,7 @@ Custom definitions live in `theme.json`:
56
72
  }
57
73
  ```
58
74
 
59
- The component receives SectionProps<Data>; `acms theme types <slug>` (also run by `acms check`) writes `section-types.d.ts` with one Data interface per section. Field kinds: text, textarea, richtext, image, url, link, select (options required), number, toggle, date, time, list (a repeater of those) and source (live events, posts or booking links). The full guide, including presets, migrations and the starter library (`acms section library` / `acms section add`), is `acms docs sections`. Instances have stable id/type/version/variant/hidden/data fields. A page layout has mode and sections, persisted by page ID in `settings.sectionPages`. Section values and order have a design draft lifecycle distinct from rich-text/widget page drafts; publishing design does not publish pending page text. Theme switches preserve the map. To migrate a custom schema, transform a pulled draft to the new version, review it, and push it with its revision; incompatible versions never auto-convert silently. Existing widget pages continue using their original renderer.
75
+ The component receives SectionProps<Data>; `acms theme types <slug>` (also run by `acms check`) writes `section-types.d.ts` with one Data interface per section. Field kinds: text, textarea, richtext, image, url, link, select (options required), number, toggle, date, time, list (a repeater of those) and source (live events, posts or booking links). An image field (or its default) may name a file the theme ships, like `assets/hero.jpg`; it resolves to that file's built URL on the website and in the mobile API. Post sources give each item `excerpt` (the post's own, else the first ~160 characters of its text) and `authors` (`{ name, url, avatar }`, public byline fields only). The full guide, including presets, migrations and the starter library (`acms section library` / `acms section add`), is `acms docs sections`. Instances have stable id/type/version/variant/hidden/data fields. A page layout has mode and sections, persisted by page ID in `settings.sectionPages`. Section values and order have a design draft lifecycle distinct from rich-text/widget page drafts; publishing design does not publish pending page text. Theme switches preserve the map. To migrate a custom schema, transform a pulled draft to the new version, review it, and push it with its revision; incompatible versions never auto-convert silently. Existing widget pages continue using their original renderer.
60
76
 
61
77
  Images in template files should be imported using relative paths so Astro bundles them. Theme CSS can reference `url(assets/name.webp)`; ACMS resolves it through the bundled asset inventory. Brand images and editable section images use the media library. Packaged assets must be local files under assets; declare them in the manifest. Use `theme pack --optimize` to run current img-subir and rewrite references before packing. Original-image backup directories are excluded.
62
78
 
package/integration.mjs CHANGED
@@ -257,7 +257,7 @@ function resolveSearch(search) {
257
257
  const EVENT_FIELD_TYPES = ['text', 'textarea', 'number', 'url', 'select'];
258
258
  function resolveCalendar(calendar, contentBases, locales) {
259
259
  const defaults = {
260
- enabled: true, base: '/calendar', eventsBase: '/events', bookings: true, bookBase: '/book',
260
+ enabled: true, base: '/calendar', eventsBase: '/events', bookings: true, bookBase: '/book', bookingApproval: 'auto',
261
261
  resourceKinds: ['Room', 'Person', 'Vehicle', 'Equipment'], defaultView: 'month', eventFields: [],
262
262
  };
263
263
  if (calendar === false) return { ...defaults, enabled: false };
@@ -275,17 +275,31 @@ function resolveCalendar(calendar, contentBases, locales) {
275
275
  }
276
276
  const enabled = calendar.enabled ?? true;
277
277
  if (typeof enabled !== 'boolean') throw new Error('acms: calendar.enabled must be true or false');
278
+ // bookings: true | false | { approval: 'auto' | 'rules' | 'manual' } — the
279
+ // object form turns bookings on and sets the starting approval default
280
+ // (Calendar settings → Bookings can change it later)
281
+ const bookingsOpt = calendar.bookings ?? true;
282
+ let bookingApproval = defaults.bookingApproval;
283
+ if (bookingsOpt && typeof bookingsOpt === 'object' && !Array.isArray(bookingsOpt)) {
284
+ const extra = Object.keys(bookingsOpt).filter((k) => k !== 'approval');
285
+ if (extra.length) throw new Error(`acms: unknown calendar.bookings key "${extra[0]}" — the only option is calendar.bookings.approval`);
286
+ bookingApproval = bookingsOpt.approval ?? defaults.bookingApproval;
287
+ if (!['auto', 'rules', 'manual'].includes(bookingApproval)) {
288
+ throw new Error(`acms: calendar.bookings.approval must be "auto", "rules", or "manual" — got "${bookingApproval}"`);
289
+ }
290
+ }
278
291
  const resolved = {
279
292
  enabled,
280
293
  base: calendar.base ?? defaults.base,
281
294
  eventsBase: calendar.eventsBase ?? defaults.eventsBase,
282
- bookings: calendar.bookings ?? true,
295
+ bookings: typeof bookingsOpt === 'object' && bookingsOpt !== null ? true : bookingsOpt,
296
+ bookingApproval,
283
297
  bookBase: calendar.bookBase ?? defaults.bookBase,
284
298
  resourceKinds: calendar.resourceKinds ?? defaults.resourceKinds,
285
299
  defaultView: calendar.defaultView ?? defaults.defaultView,
286
300
  eventFields: [],
287
301
  };
288
- if (typeof resolved.bookings !== 'boolean') throw new Error('acms: calendar.bookings must be true or false');
302
+ if (typeof resolved.bookings !== 'boolean') throw new Error('acms: calendar.bookings must be true, false, or { approval }');
289
303
  if (!Array.isArray(resolved.resourceKinds) || !resolved.resourceKinds.length || resolved.resourceKinds.some((k) => typeof k !== 'string' || !k.trim())) {
290
304
  throw new Error('acms: calendar.resourceKinds must be a non-empty array of labels, e.g. ["Room", "Person"]');
291
305
  }
@@ -385,6 +399,8 @@ export const homes = import.meta.glob('/src/themes/*/templates/home.astro', { ea
385
399
  export const postTpls = import.meta.glob('/src/themes/*/templates/post-*.astro', { eager: true });
386
400
  export const pageTpls = import.meta.glob('/src/themes/*/templates/page-*.astro', { eager: true });
387
401
  export const eventTpls = import.meta.glob('/src/themes/*/templates/event.astro', { eager: true });
402
+ export const headerTpls = import.meta.glob('/src/themes/*/templates/header.astro', { eager: true });
403
+ export const footerTpls = import.meta.glob('/src/themes/*/templates/footer.astro', { eager: true });
388
404
  export const migrations = import.meta.glob('/src/themes/*/migrations/*.mjs', { eager: true });
389
405
  `;
390
406
 
@@ -544,7 +560,8 @@ export default function acms(options = {}) {
544
560
  ['/api/v1/calendars', 'api/v1/calendars.ts'],
545
561
  ['/api/calendar/events', 'api/calendar/events/index.ts'],
546
562
  ['/api/calendar/calendars', 'api/calendar/calendars.ts'],
547
- ['/admin/calendar', 'admin/calendar/index.ts'],
563
+ ['/admin/calendar', 'admin/calendar/index.astro'],
564
+ ['/admin/calendar/settings', 'admin/calendar/settings.astro'],
548
565
  ['/admin/calendar/events', 'admin/calendar/events/index.astro'],
549
566
  ['/admin/calendar/events/[id]', 'admin/calendar/events/[id].astro'],
550
567
  ['/admin/calendar/calendars', 'admin/calendar/calendars.astro'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -61,15 +61,17 @@
61
61
  "@dnd-kit/sortable": "^10.0.0",
62
62
  "@dnd-kit/utilities": "^3.2.2",
63
63
  "@libsql/client": "^0.15.0",
64
- "@tiptap/core": "^3.30.1",
65
- "@tiptap/extension-code-block-lowlight": "^3.30.1",
64
+ "@tiptap/core": "3.30.1",
65
+ "@tiptap/extension-bubble-menu": "3.30.1",
66
+ "@tiptap/extension-code-block-lowlight": "3.30.1",
67
+ "@tiptap/extension-floating-menu": "3.30.1",
66
68
  "@tiptap/extension-highlight": "3.30.1",
67
- "@tiptap/extension-table": "^3.30.1",
68
- "@tiptap/extensions": "^3.30.1",
69
- "@tiptap/pm": "^3.30.1",
70
- "@tiptap/react": "^3.30.1",
71
- "@tiptap/starter-kit": "^3.30.1",
72
- "@tiptap/suggestion": "^3.30.1",
69
+ "@tiptap/extension-table": "3.30.1",
70
+ "@tiptap/extensions": "3.30.1",
71
+ "@tiptap/pm": "3.30.1",
72
+ "@tiptap/react": "3.30.1",
73
+ "@tiptap/starter-kit": "3.30.1",
74
+ "@tiptap/suggestion": "3.30.1",
73
75
  "franc-min": "^6.2.0",
74
76
  "hast-util-to-html": "^9.0.5",
75
77
  "lowlight": "^3.3.0",
@@ -78,12 +80,12 @@
78
80
  "pdfjs-dist": "^6.3.289",
79
81
  "prismjs": "^1.30.0",
80
82
  "sanitize-html": "^2.17.7",
83
+ "semver": "^7.7.2",
81
84
  "simple-icons": "^16.27.1",
85
+ "tar": "^7.5.1",
82
86
  "turndown": "^7.2.4",
83
87
  "zod": "^3.24.0",
84
- "zod-to-json-schema": "^3.25.2",
85
- "semver": "^7.7.2",
86
- "tar": "^7.5.1"
88
+ "zod-to-json-schema": "^3.25.2"
87
89
  },
88
90
  "peerDependencies": {
89
91
  "@astrojs/cloudflare": "^12.6.13",
@@ -16,16 +16,21 @@ import { normalizeCalendarSrc } from '../lib/calendar/src';
16
16
  import { getCalendarEvents } from '../lib/calendar/ics';
17
17
  import { siteLocale } from '../lib/dates';
18
18
  import { normalizePageLocale, localeHomeUrl } from '../lib/i18n';
19
- import { CALENDAR } from '../lib/calendar/config';
19
+ import { CALENDAR, bookingsEnabled } from '../lib/calendar/config';
20
+ import { holdConflicts } from '../lib/booking/holds';
21
+ import { setBookingStatus } from '../lib/booking/service';
20
22
  import { eventTimeZone, featuredOccurrence, formatOccurrence, isValidTimeZone, normalizeEventTimes, safeColor } from '../lib/calendar/events';
21
23
  import { eventsFromCsv, eventsFromIcs, eventsFromVEvents } from '../lib/calendar/import';
22
24
  import { loadFeed } from '../lib/calendar/ics';
23
25
  import { NATIVE_ALL } from '../lib/calendar/render';
24
26
  import { collectViewEvents } from '../lib/calendar/data';
27
+ import { loadBoard } from '../lib/calendar/board';
28
+ import { duplicateEvent, moveOccurrence, moveSeries, quickCreate, undoMove } from '../lib/calendar/schedule';
25
29
  import { waitUntilFrom } from '../lib/webhooks';
26
30
  import { eventChanged, eventDeleted } from '../lib/calendar/hooks';
27
31
  import { siteOrigin } from '../lib/seo';
28
32
  import type { CalEvent } from '../lib/schema';
33
+ import { CalEvent as CalEventSchema } from '../lib/schema';
29
34
  import { diffDesign, revertDesignChange, summarizeChanges } from '../lib/designDiff';
30
35
  import { shareEpoch } from '../lib/drafts';
31
36
  import { createDesignToken } from '../lib/preview';
@@ -391,11 +396,15 @@ export const server = {
391
396
  siteLogo: z.string().optional(),
392
397
  siteFavicon: z.string().optional(),
393
398
  siteCover: z.string().optional(),
394
- }),
399
+ // IANA zone ('' = UTC) and BCP 47 locale ('' = en-US), same rules as Settings → General
400
+ timezone: z.string().max(100).refine((tz) => tz === '' || isValidTimeZone(tz), { message: 'Use an IANA timezone like America/New_York.' }).optional(),
401
+ locale: z.string().max(40).refine((l) => { if (l === '') return true; try { return Intl.getCanonicalLocales(l).length === 1; } catch { return false; } }, { message: 'Use a language tag like en-US or es.' }).optional(),
402
+ // unknown keys are an error, not silently dropped
403
+ }).strict(),
395
404
  handler: async (data, ctx) => {
396
405
  await requireAdmin(ctx);
397
406
  const repo = await getRepo(ctx.locals);
398
- await repo.updateSettings(data);
407
+ await repo.updateSettings({ ...data, ...(data.locale ? { locale: Intl.getCanonicalLocales(data.locale)[0] } : {}) });
399
408
  return { ok: true };
400
409
  },
401
410
  }),
@@ -797,6 +806,54 @@ export const server = {
797
806
  return { sources: normalized, upcoming, total: events.length, labels };
798
807
  },
799
808
  }),
809
+ // Schedule (admin month/week grid): every occurrence between two days
810
+ range: defineAction({
811
+ input: z.object({ from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/) }),
812
+ handler: async ({ from, to }, ctx) => {
813
+ await requireAdmin(ctx);
814
+ const repo = await getRepo(ctx.locals);
815
+ try { return await loadBoard(repo, await repo.getSettings(), from, to); }
816
+ catch (e) { throw new ActionError({ code: 'BAD_REQUEST', message: e instanceof Error ? e.message : 'Couldn’t load the calendar.' }); }
817
+ },
818
+ }),
819
+ // Calendar settings → Defaults / Bookings
820
+ saveSettings: defineAction({
821
+ input: z.object({
822
+ weekStart: z.union([z.literal(0), z.literal(1)]).optional(),
823
+ defaultCalendarId: z.string().max(100).optional(),
824
+ showBookings: z.boolean().optional(),
825
+ approval: z.enum(['auto', 'rules', 'manual']).optional(),
826
+ approvalRules: z.object({
827
+ longerThan: z.number().int().min(0).max(60 * 24 * 60),
828
+ firstTime: z.boolean(),
829
+ moreSeats: z.number().int().min(0).max(100000),
830
+ deadlineHours: z.number().int().min(0).max(24 * 60),
831
+ deadlineAction: z.enum(['approve', 'decline']),
832
+ }).optional(),
833
+ // reset every booking link to "use the site default"
834
+ applyToLinks: z.boolean().optional(),
835
+ }),
836
+ handler: async ({ applyToLinks, ...patch }, ctx) => {
837
+ await requireAdmin(ctx);
838
+ const repo = await getRepo(ctx.locals);
839
+ const settings = await repo.getSettings();
840
+ if (patch.defaultCalendarId) {
841
+ const cal = await repo.getCalendar(patch.defaultCalendarId);
842
+ if (!cal || cal.kind !== 'native') throw new ActionError({ code: 'BAD_REQUEST', message: 'Pick one of your calendars.' });
843
+ }
844
+ const calendar = { ...settings.calendar, ...Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined)) };
845
+ await repo.updateSettings({ calendar });
846
+ let updated = 0;
847
+ if (applyToLinks) {
848
+ for (const l of await repo.listBookingLinks()) {
849
+ if (l.approval === 'default') continue;
850
+ await repo.updateBookingLink(l.id, { approval: 'default' });
851
+ updated++;
852
+ }
853
+ }
854
+ return { calendar: (await repo.getSettings()).calendar, updated };
855
+ },
856
+ }),
800
857
  },
801
858
 
802
859
  events: {
@@ -829,6 +886,7 @@ export const server = {
829
886
  calendarIds: z.array(z.string()).max(50).optional(),
830
887
  fields: z.record(z.string().max(2000)).optional(),
831
888
  rsvp: z.object({ enabled: z.boolean(), capacity: z.number().int().min(0).max(100000), autoConfirm: z.boolean(), maxSeats: z.number().int().min(1).max(50) }).optional(),
889
+ roomHold: z.object({ resourceIds: z.array(z.string()).max(50), span: z.enum(['event', 'day']), before: z.number().int().min(0).max(1440), after: z.number().int().min(0).max(1440), whileDraft: z.boolean(), keep: z.array(z.string()).max(500).default([]) }).optional(),
832
890
  scheduledAt: nullableTs.optional(),
833
891
  }),
834
892
  handler: async ({ id, ...data }, ctx) => {
@@ -865,6 +923,131 @@ export const server = {
865
923
  return { ok: true };
866
924
  },
867
925
  }),
926
+ // Event editor → Rooms: which rooms/people are free at this event's time
927
+ // (for every room, so the picker can label them), and which existing
928
+ // bookings the selected rooms would overlap. Works on unsaved values.
929
+ roomStatus: defineAction({
930
+ input: z.object({
931
+ eventId: z.string().default(''),
932
+ title: z.string().max(300).default(''),
933
+ start: z.string().regex(/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2})?$/),
934
+ end: z.string().max(20).default(''),
935
+ allDay: z.boolean(),
936
+ timezone: z.string().max(100).default(''),
937
+ rrule: z.string().max(500).default(''),
938
+ exdates: z.array(z.string()).max(500).default([]),
939
+ roomHold: z.object({ resourceIds: z.array(z.string()).max(50), span: z.enum(['event', 'day']), before: z.number().int().min(0).max(1440), after: z.number().int().min(0).max(1440), whileDraft: z.boolean(), keep: z.array(z.string()).max(500).default([]) }),
940
+ }),
941
+ handler: async (input, ctx) => {
942
+ await requireAdmin(ctx);
943
+ const repo = await getRepo(ctx.locals);
944
+ const settings = await repo.getSettings();
945
+ if (!bookingsEnabled()) return { rooms: [], holds: '' };
946
+ let times: { start: string; end: string };
947
+ try { times = normalizeEventTimes({ start: input.start, end: input.end, allDay: input.allDay }); }
948
+ catch { throw new ActionError({ code: 'BAD_REQUEST', message: 'Pick a valid date and time.' }); }
949
+ const resources = await repo.listResources();
950
+ const now = Date.now();
951
+ // a stand-in event holding every room, so each one gets a status
952
+ const probe = CalEventSchema.parse({
953
+ id: input.eventId || 'probe', slug: 'probe', title: input.title || 'Event', ...times, allDay: input.allDay,
954
+ timezone: input.timezone, rrule: input.rrule, exdates: input.exdates, status: 'published', startAt: 0, endAt: 0, createdAt: 0, updatedAt: 0,
955
+ roomHold: { ...input.roomHold, resourceIds: resources.map((r) => r.id), keep: input.roomHold.keep },
956
+ });
957
+ const { conflicts, holds } = await holdConflicts(repo, settings, probe, { now });
958
+ const tz = settings.timezone || 'UTC';
959
+ const locale = siteLocale(settings);
960
+ const day = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz });
961
+ const time = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
962
+ const range = (a: number, b: number) => { try { return time.formatRange(a, b); } catch { return `${time.format(a)} – ${time.format(b)}`; } };
963
+ const occurrences = new Set(holds.map((h) => h.eventStart)).size;
964
+ const first = holds[0];
965
+ const holdsLabel = first ? (input.roomHold.span === 'day' ? `Holds all day on ${day.format(first.eventStart)}` : `Holds ${range(first.start, first.end)} on ${day.format(first.eventStart)}`) + (occurrences > 1 ? ` and ${occurrences - 1} more date${occurrences === 2 ? '' : 's'}` : '') : '';
966
+ return {
967
+ holds: holdsLabel,
968
+ rooms: resources.map((r) => {
969
+ const mine = conflicts.filter((c) => c.booking.resourceId === r.id);
970
+ return {
971
+ id: r.id, name: r.name, kind: r.kind, capacity: r.capacity,
972
+ datesWithBookings: new Set(mine.map((c) => c.hold.eventStart)).size, dates: occurrences,
973
+ conflicts: mine.slice(0, 20).map((c) => ({
974
+ id: c.booking.id, name: c.booking.kind === 'block' ? `Blocked${c.booking.notes ? ` · ${c.booking.notes}` : ''}` : c.booking.name,
975
+ block: c.booking.kind === 'block', status: c.booking.status,
976
+ when: `${day.format(c.booking.start)} · ${range(c.booking.start, c.booking.end)}`,
977
+ })),
978
+ };
979
+ }),
980
+ };
981
+ },
982
+ }),
983
+ // Event editor → Rooms → "Cancel and email": frees the room for the event
984
+ cancelBookingsForEvent: defineAction({
985
+ input: z.object({ ids: z.array(z.string()).min(1).max(100), message: z.string().max(2000).default('') }),
986
+ handler: async ({ ids, message }, ctx) => {
987
+ await requireAdmin(ctx);
988
+ const repo = await getRepo(ctx.locals);
989
+ const settings = await repo.getSettings();
990
+ const opts = { origin: siteOrigin(settings, ctx.url), locals: ctx.locals, waitUntil: waitUntilFrom(ctx.locals) };
991
+ let cancelled = 0;
992
+ for (const id of ids) {
993
+ const b = await repo.getBooking(id);
994
+ if (!b || (b.status !== 'pending' && b.status !== 'confirmed')) continue;
995
+ try { await setBookingStatus(repo, b, 'cancelled', opts, message.trim() || undefined); cancelled++; } catch { /* changed meanwhile */ }
996
+ }
997
+ return { cancelled };
998
+ },
999
+ }),
1000
+ // Schedule: click a day → title, time, calendar → saved (draft or published)
1001
+ quickCreate: defineAction({
1002
+ input: z.object({
1003
+ title: z.string().max(300),
1004
+ start: z.string().regex(/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2})?$/),
1005
+ end: z.string().regex(/^(\d{4}-\d{2}-\d{2}(T\d{2}:\d{2})?)?$/).default(''),
1006
+ allDay: z.boolean(),
1007
+ calendarId: z.string().max(100).default(''),
1008
+ rrule: z.string().max(500).default(''),
1009
+ location: z.string().max(300).default(''),
1010
+ publish: z.boolean().default(false),
1011
+ roomIds: z.array(z.string()).max(50).default([]),
1012
+ }),
1013
+ handler: async (input, ctx) => {
1014
+ await requireAdmin(ctx);
1015
+ const repo = await getRepo(ctx.locals);
1016
+ try { const e = await quickCreate(repo, input, hooksFor(ctx)); return { id: e.id, status: e.status }; } catch (e) { asActionError(e); }
1017
+ },
1018
+ }),
1019
+ // Schedule: drag to another day. `occurrence` + mode 'one' moves a single
1020
+ // repeat; otherwise the whole event moves.
1021
+ move: defineAction({
1022
+ input: z.object({ id: z.string(), days: z.number().int(), mode: z.enum(['series', 'one']).default('series'), occurrence: z.string().max(20).default('') }),
1023
+ handler: async ({ id, days, mode, occurrence }, ctx) => {
1024
+ await requireAdmin(ctx);
1025
+ const repo = await getRepo(ctx.locals);
1026
+ try {
1027
+ const r = mode === 'one' ? await moveOccurrence(repo, id, occurrence, days, hooksFor(ctx)) : await moveSeries(repo, id, days, hooksFor(ctx));
1028
+ return { id: r.event.id, undo: r.undo };
1029
+ } catch (e) { asActionError(e); }
1030
+ },
1031
+ }),
1032
+ undoMove: defineAction({
1033
+ input: z.union([
1034
+ z.object({ kind: z.literal('series'), id: z.string(), days: z.number().int() }),
1035
+ z.object({ kind: z.literal('split'), id: z.string(), seriesId: z.string(), occurrence: z.string().max(20) }),
1036
+ ]),
1037
+ handler: async (undo, ctx) => {
1038
+ await requireAdmin(ctx);
1039
+ const repo = await getRepo(ctx.locals);
1040
+ try { await undoMove(repo, undo, hooksFor(ctx)); return { ok: true }; } catch (e) { asActionError(e); }
1041
+ },
1042
+ }),
1043
+ duplicate: defineAction({
1044
+ input: z.object({ id: z.string() }),
1045
+ handler: async ({ id }, ctx) => {
1046
+ await requireAdmin(ctx);
1047
+ const repo = await getRepo(ctx.locals);
1048
+ try { return { id: (await duplicateEvent(repo, id)).id }; } catch (e) { asActionError(e); }
1049
+ },
1050
+ }),
868
1051
  // editor pickers (event card): every event with its next date
869
1052
  list: defineAction({
870
1053
  input: z.object({ publishedOnly: z.boolean().default(false) }),
@@ -7,6 +7,7 @@ import type { Booking, BookingLink, Resource, Settings } from '../../lib/schema'
7
7
  import { bookingLinkUrl, resourceUrl } from '../../lib/calendar/config';
8
8
  import { describeLength, describeLink, linkLengths, openDays, slotsForDay, ymd, type AvailabilityContext } from '../../lib/booking/availability';
9
9
  import { addMonths, fmt, monthCells, slotLabel } from '../../lib/booking/view';
10
+ import { confirmsInstantly, linkApproval } from '../../lib/booking/approval';
10
11
 
11
12
  interface Props {
12
13
  resource: Resource;
@@ -21,6 +22,8 @@ interface Props {
21
22
  }
22
23
  const { resource, link, ctx, settings, url, error = '', values = {}, embed = false, reschedule = null } = Astro.props;
23
24
  const tz = ctx.resource.timezone || settings.timezone || 'UTC';
25
+ // "rules" links usually confirm at once, but may not — promise only what's certain
26
+ const instant = confirmsInstantly(linkApproval(link, settings));
24
27
  const f = fmt(settings, tz);
25
28
  const today = ymd(ctx.now, tz);
26
29
  const lengths = linkLengths(link);
@@ -51,7 +54,7 @@ const q = (patch: Record<string, string | number | undefined>) => {
51
54
  };
52
55
  const rules = [
53
56
  describeLink(link),
54
- link.autoConfirm ? 'Confirmed instantly' : 'Needs approval',
57
+ instant ? 'Confirmed instantly' : 'Needs approval',
55
58
  link.bufferBefore || link.bufferAfter ? `Includes ${[link.bufferBefore && `${link.bufferBefore} min setup`, link.bufferAfter && `${link.bufferAfter} min cleanup`].filter(Boolean).join(' + ')}` : '',
56
59
  ].filter(Boolean);
57
60
  const val = (k: string) => values[k] ?? '';
@@ -142,8 +145,8 @@ const val = (k: string) => values[k] ?? '';
142
145
  ))}
143
146
  </>
144
147
  )}
145
- <button class="pc-bk-submit" type="submit">{reschedule ? 'Move my booking' : link.autoConfirm ? 'Confirm booking' : 'Request booking'}</button>
146
- {!link.autoConfirm && !reschedule && <p class="pc-bk-fine">We’ll email you once it’s approved. Nothing is final until then.</p>}
148
+ <button class="pc-bk-submit" type="submit">{reschedule ? 'Move my booking' : instant ? 'Confirm booking' : 'Request booking'}</button>
149
+ {!instant && !reschedule && <p class="pc-bk-fine">We’ll email you once it’s approved. Nothing is final until then.</p>}
147
150
  </form>
148
151
  </section>
149
152
  )}
@@ -0,0 +1,51 @@
1
+ ---
2
+ // Shared header for the Bookings home: Requests (what came in) and Rooms &
3
+ // people (what can be booked) are two tabs of one place, with the same ⚙ and
4
+ // "New ▾" as the Schedule. Extra page actions go in the default slot.
5
+ interface Props {
6
+ active: 'requests' | 'rooms';
7
+ sub: string;
8
+ pending: number;
9
+ rooms: number;
10
+ // on the Requests page the menu opens its dialogs in place
11
+ inPlace?: boolean;
12
+ }
13
+ const { active, sub, pending, rooms, inPlace = false } = Astro.props;
14
+ const gear = 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.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';
15
+ ---
16
+
17
+ <div class="page-head bk-head">
18
+ <div>
19
+ <h1>Bookings</h1>
20
+ <p class="sub">{sub}</p>
21
+ </div>
22
+ <div class="page-actions">
23
+ <slot />
24
+ <a class="btn ico" href="/admin/calendar/settings#bookings" 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={gear} /></svg></a>
25
+ <details class="newmenu">
26
+ <summary class="btn primary">New <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 9l6 6 6-6" /></svg></summary>
27
+ <div class="newmenu-pop" role="menu">
28
+ {inPlace
29
+ ? <button type="button" role="menuitem" data-modal="bk-add">Booking <small>for someone</small></button>
30
+ : <a role="menuitem" href="/admin/calendar/bookings?add=1">Booking <small>for someone</small></a>}
31
+ {inPlace
32
+ ? <button type="button" role="menuitem" data-modal="bk-block">Block time <small>room closed, time off</small></button>
33
+ : <a role="menuitem" href="/admin/calendar/bookings?block=1">Block time <small>room closed, time off</small></a>}
34
+ {active === 'rooms'
35
+ ? <button type="button" role="menuitem" data-modal="new-resource">Room or person</button>
36
+ : <a role="menuitem" href="/admin/calendar/resources?new=1">Room or person</a>}
37
+ </div>
38
+ </details>
39
+ </div>
40
+ </div>
41
+ <nav class="ptabs" aria-label="Bookings">
42
+ <a href="/admin/calendar/bookings" aria-current={active === 'requests' ? 'page' : undefined}>Requests{pending > 0 && <small class="attn">{pending} {pending === 1 ? 'needs' : 'need'} approval</small>}</a>
43
+ <a href="/admin/calendar/resources" aria-current={active === 'rooms' ? 'page' : undefined}>Rooms &amp; people<small>{rooms}</small></a>
44
+ </nav>
45
+
46
+ <script>
47
+ document.addEventListener('click', (e) => {
48
+ const m = document.querySelector<HTMLDetailsElement>('details.newmenu[open]');
49
+ if (m && (!(e.target instanceof Node) || !m.contains(e.target) || (e.target as Element).closest('[role=menuitem]'))) m.open = false;
50
+ });
51
+ </script>