@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.
- package/CHANGELOG.md +36 -0
- package/cli/preview.mjs +8 -4
- package/cli/sections.mjs +4 -2
- package/docs/mobile.md +1 -1
- package/docs/theme-authoring.md +19 -3
- package/integration.mjs +21 -4
- package/package.json +14 -12
- package/src/actions/index.ts +186 -3
- package/src/components/booking/BookingFlow.astro +6 -3
- package/src/components/calendar/BookingsHead.astro +51 -0
- package/src/components/calendar/CalendarBoard.tsx +618 -0
- package/src/components/calendar/MiniMonth.tsx +54 -0
- package/src/components/calendar/NewEventDialog.tsx +44 -0
- package/src/components/calendar/QuickAdd.tsx +263 -0
- package/src/components/calendar/RoomHolds.tsx +203 -0
- package/src/components/calendar/WhereItLands.tsx +51 -0
- package/src/components/editor/EventPanel.tsx +23 -2
- package/src/components/editor/PostEditor.tsx +32 -4
- package/src/layouts/Admin.astro +5 -6
- package/src/layouts/Site.astro +29 -2
- package/src/lib/backup/content.ts +6 -1
- package/src/lib/booking/agent.ts +3 -2
- package/src/lib/booking/approval.ts +70 -0
- package/src/lib/booking/holds.ts +120 -0
- package/src/lib/booking/service.ts +37 -3
- package/src/lib/calendar/agent-schema.ts +9 -1
- package/src/lib/calendar/agent.ts +16 -1
- package/src/lib/calendar/board.ts +171 -0
- package/src/lib/calendar/config.ts +3 -0
- package/src/lib/calendar/grid.ts +169 -0
- package/src/lib/calendar/migration.ts +16 -0
- package/src/lib/calendar/render.ts +9 -7
- package/src/lib/calendar/schedule.ts +166 -0
- package/src/lib/content-service.ts +5 -0
- package/src/lib/drafts.ts +1 -1
- package/src/lib/mobile/calendar.ts +10 -6
- package/src/lib/mobile/http.ts +6 -0
- package/src/lib/repo/migrations.ts +3 -0
- package/src/lib/repo/sql.ts +28 -16
- package/src/lib/repo/types.ts +2 -2
- package/src/lib/scheduler.ts +3 -1
- package/src/lib/schema.ts +49 -0
- package/src/lib/sections.ts +33 -8
- package/src/lib/site-chrome.ts +34 -0
- package/src/lib/theme-assets.ts +16 -0
- package/src/lib/theme.ts +17 -3
- package/src/middleware.ts +6 -2
- package/src/pages/admin/calendar/bookings.astro +32 -26
- package/src/pages/admin/calendar/calendars.astro +4 -263
- package/src/pages/admin/calendar/events/index.astro +41 -83
- package/src/pages/admin/calendar/index.astro +43 -0
- package/src/pages/admin/calendar/resources/[id].astro +27 -5
- package/src/pages/admin/calendar/resources/index.astro +16 -17
- package/src/pages/admin/calendar/resources/link.astro +52 -12
- package/src/pages/admin/calendar/settings.astro +458 -0
- package/src/pages/admin/integrations.astro +5 -11
- package/src/pages/book/[resource].ics.ts +7 -1
- package/src/styles/admin.css +48 -0
- package/src/styles/cards.css +5 -1
- package/src/styles/editor.css +36 -0
- package/src/styles/schedule.css +325 -0
- package/src/styles/sections.css +19 -16
- package/src/theme-sdk/fields.mjs +5 -1
- package/src/theme-sdk/index.ts +5 -1
- package/src/virtual.d.ts +3 -0
- 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
|
|
35
|
-
|
|
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
|
-
|
|
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,
|
|
68
|
-
|
|
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 |
|
package/docs/theme-authoring.md
CHANGED
|
@@ -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`.
|
|
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:
|
|
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
|
|
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.
|
|
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
|
+
"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": "
|
|
65
|
-
"@tiptap/extension-
|
|
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": "
|
|
68
|
-
"@tiptap/extensions": "
|
|
69
|
-
"@tiptap/pm": "
|
|
70
|
-
"@tiptap/react": "
|
|
71
|
-
"@tiptap/starter-kit": "
|
|
72
|
-
"@tiptap/suggestion": "
|
|
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",
|
package/src/actions/index.ts
CHANGED
|
@@ -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
|
-
|
|
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' :
|
|
146
|
-
{!
|
|
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 & 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>
|