@acms-subir/core 0.3.5 → 0.3.7
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 +23 -0
- package/cli/remote.mjs +1 -1
- package/docs/theme-authoring.md +5 -2
- package/integration.mjs +38 -2
- package/package.json +1 -1
- package/src/actions/index.ts +148 -2
- package/src/components/DesignManager.tsx +34 -3
- package/src/components/EventPage.astro +25 -5
- package/src/components/calendar/BookingsHead.astro +4 -3
- package/src/components/calendar/RoomHolds.tsx +13 -8
- package/src/components/editor/EventPanel.tsx +9 -383
- package/src/components/editor/LivePane.tsx +16 -3
- package/src/components/editor/PostEditor.tsx +59 -46
- package/src/components/editor/PreviewOverlay.tsx +7 -1
- package/src/components/editor/event/EventSheet.tsx +136 -0
- package/src/components/editor/event/LocationField.tsx +266 -0
- package/src/components/editor/event/OrganizeCard.tsx +87 -0
- package/src/components/editor/event/RegistrationCard.tsx +114 -0
- package/src/components/editor/event/RegistrationFormEditor.tsx +142 -0
- package/src/components/editor/event/Section.tsx +27 -0
- package/src/components/editor/event/WhenCard.tsx +217 -0
- package/src/components/editor/event/dates.ts +28 -0
- package/src/components/editor/event/registration.ts +39 -0
- package/src/components/editor/extensions/form.tsx +60 -0
- package/src/components/editor/extensions/slash.tsx +5 -0
- package/src/components/editor/frameNav.ts +20 -0
- package/src/components/forms/Canvas.tsx +149 -0
- package/src/components/forms/FormBuildPage.tsx +155 -0
- package/src/components/forms/FormBuilder.tsx +283 -0
- package/src/components/forms/FormHeader.astro +31 -0
- package/src/components/forms/InsertMenu.tsx +77 -0
- package/src/components/forms/Inspector.tsx +232 -0
- package/src/components/forms/Outline.tsx +93 -0
- package/src/components/forms/ResponsesTable.tsx +123 -0
- package/src/components/forms/TypeIcon.tsx +24 -0
- package/src/components/forms/dnd.ts +3 -0
- package/src/components/forms/meta.ts +84 -0
- package/src/components/forms/useFormHistory.ts +41 -0
- package/src/components/import/ImportExport.tsx +1 -1
- package/src/components/site/SiteLogo.astro +23 -0
- package/src/layouts/Admin.astro +3 -2
- package/src/layouts/Site.astro +5 -4
- package/src/lib/backup/content.ts +10 -5
- package/src/lib/booking/service.ts +6 -2
- package/src/lib/calendar/agent-schema.ts +7 -1
- package/src/lib/calendar/agent.ts +28 -3
- package/src/lib/calendar/api.ts +2 -0
- package/src/lib/calendar/ics-write.ts +2 -0
- package/src/lib/calendar/ics.ts +4 -0
- package/src/lib/calendar/import.ts +3 -3
- package/src/lib/calendar/schedule.ts +6 -2
- package/src/lib/calendar/seo.ts +4 -1
- package/src/lib/content-service.ts +30 -4
- package/src/lib/design-service.ts +2 -2
- package/src/lib/designDiff.ts +2 -2
- package/src/lib/drafts.ts +11 -2
- package/src/lib/dynamicBlocks.ts +14 -3
- package/src/lib/editor/documents.ts +12 -3
- package/src/lib/editor/schemas.ts +4 -3
- package/src/lib/forms/events.ts +40 -0
- package/src/lib/forms/export.ts +62 -0
- package/src/lib/forms/fields.ts +50 -0
- package/src/lib/forms/http.ts +29 -0
- package/src/lib/forms/render.ts +11 -0
- package/src/lib/forms/schema.ts +56 -0
- package/src/lib/forms/service.ts +38 -0
- package/src/lib/forms/store.ts +78 -0
- package/src/lib/forms/validate.ts +71 -0
- package/src/lib/import/bundle.ts +1 -0
- package/src/lib/import/export.ts +1 -1
- package/src/lib/maps/config.ts +64 -0
- package/src/lib/maps/migration.ts +12 -0
- package/src/lib/maps/parse.ts +180 -0
- package/src/lib/maps/places.ts +109 -0
- package/src/lib/maps/providers/geoapify.ts +36 -0
- package/src/lib/maps/providers/google.ts +54 -0
- package/src/lib/maps/providers/http.ts +96 -0
- package/src/lib/maps/providers/index.ts +37 -0
- package/src/lib/maps/providers/locationiq.ts +35 -0
- package/src/lib/maps/providers/mapbox.ts +38 -0
- package/src/lib/maps/providers/osm.ts +59 -0
- package/src/lib/maps/providers/pelias.ts +51 -0
- package/src/lib/maps/service.ts +281 -0
- package/src/lib/maps/types.ts +35 -0
- package/src/lib/mobile/bookings.ts +1 -1
- package/src/lib/mobile/schemas.ts +3 -3
- package/src/lib/mobile/site.ts +1 -1
- package/src/lib/repo/migrations.ts +11 -0
- package/src/lib/repo/sql.ts +104 -14
- package/src/lib/repo/types.ts +22 -2
- package/src/lib/schema.ts +39 -0
- package/src/lib/site-chrome.ts +1 -0
- package/src/lib/umami.ts +8 -1
- package/src/pages/admin/calendar/bookings.astro +12 -7
- package/src/pages/admin/calendar/events/[id].astro +5 -2
- package/src/pages/admin/calendar/events/index.astro +3 -0
- package/src/pages/admin/calendar/resources/[id].astro +11 -0
- package/src/pages/admin/calendar/settings.astro +140 -3
- package/src/pages/admin/forms/[id]/share.astro +121 -0
- package/src/pages/admin/forms/[id]/submissions.astro +143 -0
- package/src/pages/admin/forms/[id].astro +27 -0
- package/src/pages/admin/forms/index.astro +198 -0
- package/src/pages/admin/integrations.astro +57 -0
- package/src/pages/api/agent/index.ts +1 -1
- package/src/pages/api/calendar/docs.ts +2 -0
- package/src/pages/api/calendar/events/[id].ts +1 -1
- package/src/pages/api/calendar/events/index.ts +1 -1
- package/src/pages/api/mcp.ts +1 -1
- package/src/pages/book/manage/[token].astro +4 -4
- package/src/pages/events/[slug].astro +24 -10
- package/src/pages/forms/[id].astro +27 -0
- package/src/styles/admin.css +2 -2
- package/src/styles/editor.css +275 -34
- package/src/styles/form-block.css +38 -0
- package/src/styles/forms-admin.css +184 -0
- package/src/styles/forms.css +281 -0
- package/src/styles/site.css +43 -0
- package/src/virtual.d.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.7
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Bound analytics and timezone formatter caches so changing keys cannot accumulate indefinitely in long-lived Node servers. Add cache retention regression tests and an isolated production Node/Docker memory test in the framework repository.
|
|
8
|
+
- Event settings now live on the edit page, in one settings column above the event's content: When, Where, Registration, Organize and Status sections, each with its label and help on the left and roomy controls on the right, plus a sticky index showing each section's current value (a jump bar in Split mode and on phones). Rooms and people to hold are a checklist table, repeats read as one sentence, and Cancelled has its own Status section. The settings sidebar for events is just URL & SEO. Button and RSVP merge into one Registration choice (None / Link / RSVP).
|
|
9
|
+
|
|
10
|
+
Events get maps and saved places. The Where field searches saved places, reads pasted Google, Apple and OpenStreetMap links or Google embed code, and — with a maps provider you configure (`acms({ maps: { provider } })` plus `MAPS_API_KEY`, or `MAPS_URL` for self-hosted Photon/Nominatim/Pelias) — suggests addresses and adds a static map image to event pages. Nothing runs without configuration. Saved places (Calendar settings → Places) fill in an event's location, can be the default for new events, rooms can belong to one, and editing a place updates its upcoming events. Adds migration v43.
|
|
11
|
+
|
|
12
|
+
- Forms. Build reusable forms and templates under Content → Forms, publish them at `/forms/:id` or place them on any page with the Form block, and collect responses.
|
|
13
|
+
|
|
14
|
+
- **Builder:** questions on the left, the live form in the middle, and settings for the selected question on the right. Edit labels and help text right on the form, add questions from a searchable menu (or press `/`), drag to reorder, and use undo/redo. Drafts save automatically, and visitors only see changes after you click Publish changes. On phones, editing happens in a question list with a bottom sheet.
|
|
15
|
+
- **Question types:** short and long text, multiple choice and checkboxes (with an optional "Other" answer), dropdown, email, phone, website, number, date, agree checkbox, and section headings. Fields can be half width, so two sit side by side. Forms saved before this release keep working unchanged.
|
|
16
|
+
- **Responses:** a table with one column per question, search, date range, a side drawer with every answer (including questions you've since removed), bulk export and delete, and a CSV with one row per response.
|
|
17
|
+
- **Share:** public link, embed code, the pages that use the form, and a switch to stop accepting responses. In the post editor, the Form block shows a preview of the actual form.
|
|
18
|
+
- **Event registration:** an event's built-in RSVP form opens in the same builder, full screen. Name, email and seats are always asked; start from a starter or a saved event template, and save your questions as a template for other events.
|
|
19
|
+
|
|
20
|
+
## 0.3.6
|
|
21
|
+
|
|
22
|
+
### Patch Changes
|
|
23
|
+
|
|
24
|
+
- Add an optional dark-mode logo in Design, exposed as `site.logoDark` to theme headers and footers and as `logoDark` in `/api/v1/site` and the typed client. Built-in branding and the reusable `SiteLogo` component select the matching image without color filters, falling back to the regular logo. Preserve the setting through design drafts, publishing, history, backups, and portable exports. Migration v42 adds the optional setting with an empty default for existing sites.
|
|
25
|
+
|
|
3
26
|
## 0.3.5
|
|
4
27
|
|
|
5
28
|
### Patch Changes
|
package/cli/remote.mjs
CHANGED
|
@@ -27,7 +27,7 @@ export async function remoteCommand(group,command,args,flags){
|
|
|
27
27
|
if(group==='design'){
|
|
28
28
|
if(command==='pull'||command==='history'){const state=await api(url,token,'/api/design');if(command==='history')return {versions:state.versions,audit:state.audit};if(flags.output){if(flags['dry-run'])return {dryRun:true,output:resolve(flags.output),revision:state.revision};await atomicJson(resolve(flags.output),{revision:state.revision,patch:state.draft??state.live});return {output:resolve(flags.output),revision:state.revision};}return state;}
|
|
29
29
|
let body;
|
|
30
|
-
if(command==='push'){if(!flags.draft)fail('USAGE','Use design push --draft; publishing is separate.');const input=await inputJson(flags.file);const source=input.patch??input;const unknown=Object.keys(source).filter(k=>!['activeTheme','designOverrides','sectionPages','siteLogo','siteFavicon','siteCover','navItems','savedAt','shareEpoch','revision'].includes(k));if(unknown.length)fail('USAGE','Unknown draft fields.',unknown);const patch=Object.fromEntries(Object.entries(source).filter(([k])=>['activeTheme','designOverrides','sectionPages','siteLogo','siteFavicon','siteCover','navItems'].includes(k)));body={operation:'stage',revision:Number(flags.revision??input.revision),patch};}
|
|
30
|
+
if(command==='push'){if(!flags.draft)fail('USAGE','Use design push --draft; publishing is separate.');const input=await inputJson(flags.file);const source=input.patch??input;const unknown=Object.keys(source).filter(k=>!['activeTheme','designOverrides','sectionPages','siteLogo','siteLogoDark','siteFavicon','siteCover','navItems','savedAt','shareEpoch','revision'].includes(k));if(unknown.length)fail('USAGE','Unknown draft fields.',unknown);const patch=Object.fromEntries(Object.entries(source).filter(([k])=>['activeTheme','designOverrides','sectionPages','siteLogo','siteLogoDark','siteFavicon','siteCover','navItems'].includes(k)));body={operation:'stage',revision:Number(flags.revision??input.revision),patch};}
|
|
31
31
|
else if(['publish','discard','share','revoke','rollback'].includes(command))body={operation:command==='rollback'?'restore':command,revision:Number(flags.revision),...(command==='rollback'?{id:args[0]}:{}),...(command==='share'?{days:Number(flags.days??7)}:{})};
|
|
32
32
|
else fail('USAGE','Unknown design command.');
|
|
33
33
|
if(!Number.isInteger(body.revision)||body.revision<0)fail('USAGE','Supply the revision from design pull.');
|
package/docs/theme-authoring.md
CHANGED
|
@@ -36,15 +36,18 @@ Public types: ChromeProps (site), HomeProps (settings, page, posts, tags), PageP
|
|
|
36
36
|
|
|
37
37
|
Template filenames: `home.astro`, `event.astro`, `header.astro`, `footer.astro`, `post-<name>.astro`, `page-<name>.astro`.
|
|
38
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).
|
|
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`, `logoDark`, `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
|
+
Owners upload **Logo (dark mode)** beside Logo in Design → Brand and publish it with the design. `site.logoDark` is an empty string when unset; use the regular logo as the fallback. The built-in header/footer and the `SiteLogo` component below switch without image filters, following the site’s forced scheme, visitor toggle, or OS preference. Custom markup can use `html[data-scheme="dark"]` / `html[data-scheme="light"]`, falling back to `prefers-color-scheme` when the attribute is absent. `/api/v1/site` also returns `logoDark` as an absolute URL (or `""` when unset).
|
|
40
42
|
|
|
41
43
|
```astro
|
|
42
44
|
---
|
|
43
45
|
import type { ChromeProps } from '@acms-subir/core/theme';
|
|
46
|
+
import SiteLogo from '@acms-subir/core/theme/components/SiteLogo.astro';
|
|
44
47
|
const { site } = Astro.props as ChromeProps;
|
|
45
48
|
---
|
|
46
49
|
<header class="gh-head">
|
|
47
|
-
<a href={site.homeUrl}
|
|
50
|
+
<a class="brand" href={site.homeUrl}><SiteLogo title={site.title} logo={site.logo} logoDark={site.logoDark} /></a>
|
|
48
51
|
<nav>{site.links.map((l) => <a href={l.url} aria-current={l.current ? 'page' : undefined}>{l.label}</a>)}</nav>
|
|
49
52
|
{site.languages.map((l) => <a href={l.url} lang={l.code} aria-current={l.current ? 'true' : undefined}>{l.label}</a>)}
|
|
50
53
|
{site.search && <a href={site.search.url} data-search-open>{site.search.label}</a>}
|
package/integration.mjs
CHANGED
|
@@ -249,6 +249,28 @@ function resolveSearch(search) {
|
|
|
249
249
|
return { enabled };
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
// Defaults + validation for acms({ maps }). Maps are bring-your-own: the
|
|
253
|
+
// provider is named here, its key/URL come from MAPS_API_KEY / MAPS_URL at
|
|
254
|
+
// runtime (never serialized into the build). Omitted provider = picked from
|
|
255
|
+
// the environment (lib/maps/config.ts); nothing runs without configuration.
|
|
256
|
+
const MAPS_PROVIDERS = ['geoapify', 'google', 'mapbox', 'locationiq', 'stadia', 'photon', 'nominatim', 'pelias'];
|
|
257
|
+
function resolveMaps(maps) {
|
|
258
|
+
if (maps === undefined || maps === null) return { provider: '' };
|
|
259
|
+
if (typeof maps !== 'object' || Array.isArray(maps)) throw new Error('acms: maps must be an object like { provider: "geoapify" }');
|
|
260
|
+
const unknown = Object.keys(maps).filter((k) => k !== 'provider');
|
|
261
|
+
if (unknown.length > 0) {
|
|
262
|
+
throw new Error(
|
|
263
|
+
`acms: unknown maps key${unknown.length > 1 ? 's' : ''} ${unknown.map((k) => `"${k}"`).join(', ')} — ` +
|
|
264
|
+
'the only valid key is maps.provider (keys go in the MAPS_API_KEY / MAPS_URL environment variables)'
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
const provider = maps.provider ?? '';
|
|
268
|
+
if (provider !== '' && !MAPS_PROVIDERS.includes(provider)) {
|
|
269
|
+
throw new Error(`acms: maps.provider must be one of ${MAPS_PROVIDERS.map((p) => `"${p}"`).join(', ')} — got "${provider}"`);
|
|
270
|
+
}
|
|
271
|
+
return { provider };
|
|
272
|
+
}
|
|
273
|
+
|
|
252
274
|
// Defaults + validation for acms({ calendar }). The calendar module ships on:
|
|
253
275
|
// a main calendar page at `base`, event pages under `eventsBase`, ICS feeds,
|
|
254
276
|
// and the admin Calendar section. `calendar: false` (or enabled: false)
|
|
@@ -459,6 +481,13 @@ function acmsVitePlugin(userConfig) {
|
|
|
459
481
|
* embeds, and the admin Calendar section. `eventFields` adds custom fields
|
|
460
482
|
* to every event (edited in the event sidebar, exposed to templates and the
|
|
461
483
|
* API). On by default — `calendar: false` removes all of it.
|
|
484
|
+
* @param {{provider?: 'geoapify' | 'google' | 'mapbox' | 'locationiq' | 'stadia' | 'photon' | 'nominatim' | 'pelias'}} [options.maps]
|
|
485
|
+
* Maps for events (bring your own provider): address suggestions in the
|
|
486
|
+
* event editor and a static map image on event pages. Set MAPS_API_KEY
|
|
487
|
+
* (hosted providers) or MAPS_URL (self-hosted Photon/Nominatim/Pelias) in
|
|
488
|
+
* the environment. Without a provider, MAPS_URL picks photon and
|
|
489
|
+
* MAPS_API_KEY picks geoapify; with neither, maps are off — saved places and
|
|
490
|
+
* pasted map links still work.
|
|
462
491
|
* @param {false | {enabled?: boolean, operatorName?: string, contactEmail?: string, updatedAt?: string}} [options.legal]
|
|
463
492
|
* Default privacy/terms routes and footer links; published CMS pages at those slugs override the text.
|
|
464
493
|
* @returns {import('astro').AstroIntegration}
|
|
@@ -473,6 +502,7 @@ export default function acms(options = {}) {
|
|
|
473
502
|
const search = resolveSearch(options.search);
|
|
474
503
|
const mobile = resolveMobile(options.mobile);
|
|
475
504
|
const calendar = resolveCalendar(options.calendar, contentBases, i18n.locales);
|
|
505
|
+
const maps = resolveMaps(options.maps);
|
|
476
506
|
const legal = { enabled: true, operatorName: '', contactEmail: '', updatedAt: '', ...(options.legal === false ? { enabled: false } : options.legal ?? {}) };
|
|
477
507
|
if (typeof legal.enabled !== 'boolean' || ['operatorName','contactEmail','updatedAt'].some(k => typeof legal[k] !== 'string')) throw new Error('acms: invalid legal configuration');
|
|
478
508
|
if (legal.contactEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(legal.contactEmail)) throw new Error('acms: legal.contactEmail must be an email address');
|
|
@@ -486,11 +516,17 @@ export default function acms(options = {}) {
|
|
|
486
516
|
search,
|
|
487
517
|
mobile,
|
|
488
518
|
calendar,
|
|
519
|
+
maps,
|
|
489
520
|
legal,
|
|
490
521
|
};
|
|
491
522
|
const routes = [...ROUTES,
|
|
492
523
|
['/api/v1/site', 'api/v1/site.ts'],
|
|
493
524
|
['/api/v1/theme', 'api/v1/theme.ts'],
|
|
525
|
+
['/admin/forms', 'admin/forms/index.astro'],
|
|
526
|
+
['/admin/forms/[id]', 'admin/forms/[id].astro'],
|
|
527
|
+
['/admin/forms/[id]/submissions', 'admin/forms/[id]/submissions.astro'],
|
|
528
|
+
['/admin/forms/[id]/share', 'admin/forms/[id]/share.astro'],
|
|
529
|
+
['/forms/[id]', 'forms/[id].astro'],
|
|
494
530
|
['/api/v1/pages/home', 'api/v1/pages/home.ts'],
|
|
495
531
|
['/api/v1/pages/[...slug]', 'api/v1/pages/[...slug].ts'],
|
|
496
532
|
['/api/v1/openapi.json', 'api/v1/openapi.json.ts'],
|
|
@@ -567,11 +603,12 @@ export default function acms(options = {}) {
|
|
|
567
603
|
['/admin/calendar/calendars', 'admin/calendar/calendars.astro'],
|
|
568
604
|
['/admin/calendar/integrations', 'admin/calendar/integrations.astro'],
|
|
569
605
|
['/api/calendar/docs', 'api/calendar/docs.ts'],
|
|
606
|
+
[`${calendar.bookBase}/manage/[token]`, 'book/manage/[token].astro'],
|
|
607
|
+
['/admin/calendar/bookings', 'admin/calendar/bookings.astro'],
|
|
570
608
|
);
|
|
571
609
|
if (calendar.bookings) {
|
|
572
610
|
routes.push(
|
|
573
611
|
[calendar.bookBase, 'book/index.astro'],
|
|
574
|
-
[`${calendar.bookBase}/manage/[token]`, 'book/manage/[token].astro'],
|
|
575
612
|
[`${calendar.bookBase}/[resource].ics`, 'book/[resource].ics.ts'],
|
|
576
613
|
[`${calendar.bookBase}/[resource]`, 'book/[resource]/index.astro'],
|
|
577
614
|
[`${calendar.bookBase}/[resource]/[link]`, 'book/[resource]/[link].astro'],
|
|
@@ -583,7 +620,6 @@ export default function acms(options = {}) {
|
|
|
583
620
|
['/api/calendar/bookings', 'api/calendar/bookings/index.ts'],
|
|
584
621
|
['/api/calendar/bookings/[id]', 'api/calendar/bookings/[id].ts'],
|
|
585
622
|
['/api/calendar/resources', 'api/calendar/resources.ts'],
|
|
586
|
-
['/admin/calendar/bookings', 'admin/calendar/bookings.astro'],
|
|
587
623
|
['/admin/calendar/resources', 'admin/calendar/resources/index.astro'],
|
|
588
624
|
['/admin/calendar/resources/[id]', 'admin/calendar/resources/[id].astro'],
|
|
589
625
|
['/admin/calendar/resources/[id]/links/[link]', 'admin/calendar/resources/link.astro'],
|
package/package.json
CHANGED
package/src/actions/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { FormDefinition } from '../lib/forms/schema';
|
|
1
2
|
import { pickDesignDraft } from '../lib/drafts';
|
|
2
3
|
import { ApiError } from '../lib/api';
|
|
3
4
|
import { validateSectionDesign } from '../lib/design-service';
|
|
@@ -36,6 +37,8 @@ import { shareEpoch } from '../lib/drafts';
|
|
|
36
37
|
import { createDesignToken } from '../lib/preview';
|
|
37
38
|
import { getTheme } from '../lib/theme';
|
|
38
39
|
import { savePost, applyPostUpdate, savePage, applyPageUpdate, saveEvent, applyEventUpdate, type SaveHooks } from '../lib/content-service';
|
|
40
|
+
import { resolveMapLink, searchPlaces } from '../lib/maps/service';
|
|
41
|
+
import { savePlace } from '../lib/maps/places';
|
|
39
42
|
|
|
40
43
|
// "What changes" for the Design screen: the staged draft vs the live design
|
|
41
44
|
async function designChanges(repo: Awaited<ReturnType<typeof getRepo>>) {
|
|
@@ -67,6 +70,22 @@ const BlockInput = z.object({
|
|
|
67
70
|
type: z.string(),
|
|
68
71
|
config: z.record(z.any()),
|
|
69
72
|
});
|
|
73
|
+
// an event's Where. The pin fields are optional so older callers that send
|
|
74
|
+
// only name/address/url keep working (unchanged text keeps the pin).
|
|
75
|
+
const Lat = z.number().min(-90).max(90).nullable();
|
|
76
|
+
const Lng = z.number().min(-180).max(180).nullable();
|
|
77
|
+
const LocationInput = z.object({
|
|
78
|
+
name: z.string().max(300),
|
|
79
|
+
address: z.string().max(500),
|
|
80
|
+
url: z.string().max(1000),
|
|
81
|
+
lat: Lat.optional(),
|
|
82
|
+
lng: Lng.optional(),
|
|
83
|
+
placeId: z.string().max(100).optional(),
|
|
84
|
+
showMap: z.boolean().optional(),
|
|
85
|
+
mapImage: z.string().max(500).optional(),
|
|
86
|
+
mapImageDark: z.string().max(500).optional(),
|
|
87
|
+
});
|
|
88
|
+
const mapsCtx = (ctx: ActionAPIContext) => ({ locals: ctx.locals, origin: ctx.url.origin });
|
|
70
89
|
|
|
71
90
|
|
|
72
91
|
// ---- calendar helpers ------------------------------------------------------
|
|
@@ -105,6 +124,70 @@ async function importEvents(
|
|
|
105
124
|
}
|
|
106
125
|
|
|
107
126
|
export const server = {
|
|
127
|
+
forms: {
|
|
128
|
+
list: defineAction({ handler: async (_, ctx) => {
|
|
129
|
+
await requireAdmin(ctx);
|
|
130
|
+
return { forms: await (await getRepo(ctx.locals)).forms.list() };
|
|
131
|
+
} }),
|
|
132
|
+
// Static preview for the post editor's Form block: the published (live)
|
|
133
|
+
// definition rendered with the public field markup.
|
|
134
|
+
preview: defineAction({
|
|
135
|
+
input: z.object({ id: z.string().min(1).max(80) }),
|
|
136
|
+
handler: async (input, ctx) => {
|
|
137
|
+
await requireAdmin(ctx);
|
|
138
|
+
const form = await (await getRepo(ctx.locals)).forms.get(input.id);
|
|
139
|
+
if (!form || form.kind !== 'form') throw new ActionError({ code: 'NOT_FOUND', message: 'Form not found.' });
|
|
140
|
+
const { renderFields } = await import('../lib/forms/fields');
|
|
141
|
+
const d = form.definition;
|
|
142
|
+
return { name: form.name, status: form.status, hasDraft: Boolean(form.draft), title: d.title, intro: d.intro, submitLabel: d.submitLabel || 'Submit', html: renderFields(d, {}, `pv-${form.id.slice(0, 8)}`) };
|
|
143
|
+
},
|
|
144
|
+
}),
|
|
145
|
+
saveTemplate: defineAction({
|
|
146
|
+
input: z.object({ name: z.string().trim().min(1).max(120), definition: z.any(), purpose: z.enum(['event', 'general']) }),
|
|
147
|
+
handler: async (input, ctx) => {
|
|
148
|
+
await requireAdmin(ctx);
|
|
149
|
+
const parsed = FormDefinition.safeParse(input.definition);
|
|
150
|
+
if (!parsed.success) throw new ActionError({ code: 'BAD_REQUEST', message: parsed.error.issues[0]?.message || 'Invalid form.' });
|
|
151
|
+
const repo = await getRepo(ctx.locals);
|
|
152
|
+
const form = await repo.forms.create(input.name, parsed.data, 'template', input.purpose);
|
|
153
|
+
return { form };
|
|
154
|
+
},
|
|
155
|
+
}),
|
|
156
|
+
// Builder autosave + publish. `save` on a published form stages a draft;
|
|
157
|
+
// `publish` makes the definition live. The revision guards against edits
|
|
158
|
+
// from another window (409 → reload).
|
|
159
|
+
save: defineAction({
|
|
160
|
+
input: z.object({ id: z.string(), revision: z.number().int(), name: z.string().trim().min(1).max(120), definition: z.any(), op: z.enum(['save', 'publish', 'archive']) }),
|
|
161
|
+
handler: async (input, ctx) => {
|
|
162
|
+
await requireAdmin(ctx);
|
|
163
|
+
const parsed = FormDefinition.safeParse(input.definition);
|
|
164
|
+
if (!parsed.success) throw new ActionError({ code: 'BAD_REQUEST', message: parsed.error.issues[0]?.message || 'Invalid form.' });
|
|
165
|
+
const repo = await getRepo(ctx.locals);
|
|
166
|
+
const current = await repo.forms.get(input.id);
|
|
167
|
+
if (!current) throw new ActionError({ code: 'NOT_FOUND', message: 'Form not found.' });
|
|
168
|
+
// Editing a published form back to what's live drops the staged draft
|
|
169
|
+
// (re-publishing an identical definition changes nothing visitors see).
|
|
170
|
+
const backToLive = input.op === 'save' && current.status === 'published' && JSON.stringify(parsed.data) === JSON.stringify(current.definition);
|
|
171
|
+
try { await repo.forms.save(input.id, input.revision, input.name, parsed.data, backToLive ? 'publish' : input.op); } catch (e) { asActionError(e); }
|
|
172
|
+
const form = await repo.forms.get(input.id);
|
|
173
|
+
if (!form) throw new ActionError({ code: 'NOT_FOUND', message: 'Form not found.' });
|
|
174
|
+
return { revision: form.revision, status: form.status, hasDraft: Boolean(form.draft) };
|
|
175
|
+
},
|
|
176
|
+
}),
|
|
177
|
+
// "Use template" from a general template's Build page: an independent form copy.
|
|
178
|
+
useTemplate: defineAction({
|
|
179
|
+
input: z.object({ id: z.string(), name: z.string().trim().min(1).max(120).optional() }),
|
|
180
|
+
handler: async (input, ctx) => {
|
|
181
|
+
await requireAdmin(ctx);
|
|
182
|
+
const repo = await getRepo(ctx.locals);
|
|
183
|
+
const source = await repo.forms.get(input.id);
|
|
184
|
+
if (!source || source.kind !== 'template' || source.purpose !== 'general') throw new ActionError({ code: 'NOT_FOUND', message: 'Template not found.' });
|
|
185
|
+
const form = await repo.forms.create(input.name || source.name, source.draft ?? source.definition, 'form', 'general');
|
|
186
|
+
return { id: form.id };
|
|
187
|
+
},
|
|
188
|
+
}),
|
|
189
|
+
},
|
|
190
|
+
|
|
108
191
|
posts: {
|
|
109
192
|
save: defineAction({
|
|
110
193
|
input: z.object({
|
|
@@ -394,6 +477,7 @@ export const server = {
|
|
|
394
477
|
activeTheme: z.string().optional(),
|
|
395
478
|
designOverrides: z.record(z.any()).optional(),
|
|
396
479
|
siteLogo: z.string().optional(),
|
|
480
|
+
siteLogoDark: z.string().optional(),
|
|
397
481
|
siteFavicon: z.string().optional(),
|
|
398
482
|
siteCover: z.string().optional(),
|
|
399
483
|
// IANA zone ('' = UTC) and BCP 47 locale ('' = en-US), same rules as Settings → General
|
|
@@ -416,6 +500,7 @@ export const server = {
|
|
|
416
500
|
activeTheme: z.string().optional(),
|
|
417
501
|
designOverrides: z.record(z.any()).optional(),
|
|
418
502
|
siteLogo: z.string().optional(),
|
|
503
|
+
siteLogoDark: z.string().optional(),
|
|
419
504
|
siteFavicon: z.string().optional(),
|
|
420
505
|
siteCover: z.string().optional(),
|
|
421
506
|
}),
|
|
@@ -881,11 +966,11 @@ export const server = {
|
|
|
881
966
|
rrule: z.string().max(500).optional(),
|
|
882
967
|
exdates: z.array(z.string()).max(500).optional(),
|
|
883
968
|
cancelled: z.boolean().optional(),
|
|
884
|
-
location:
|
|
969
|
+
location: LocationInput.optional(),
|
|
885
970
|
cta: z.object({ label: z.string().max(120), href: z.string().max(1000), style: z.enum(['primary', 'outline']) }).optional(),
|
|
886
971
|
calendarIds: z.array(z.string()).max(50).optional(),
|
|
887
972
|
fields: z.record(z.string().max(2000)).optional(),
|
|
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(),
|
|
973
|
+
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), form: z.any().optional() }).optional(),
|
|
889
974
|
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(),
|
|
890
975
|
scheduledAt: nullableTs.optional(),
|
|
891
976
|
}),
|
|
@@ -1184,6 +1269,67 @@ export const server = {
|
|
|
1184
1269
|
}),
|
|
1185
1270
|
},
|
|
1186
1271
|
|
|
1272
|
+
// Maps (bring your own provider — lib/maps/config.ts). Keys never leave
|
|
1273
|
+
// the server; with nothing configured, search returns [] and resolve still
|
|
1274
|
+
// parses links.
|
|
1275
|
+
maps: {
|
|
1276
|
+
search: defineAction({
|
|
1277
|
+
input: z.object({ q: z.string().max(200), near: z.object({ lat: z.number().min(-90).max(90), lng: z.number().min(-180).max(180) }).optional() }),
|
|
1278
|
+
handler: async ({ q, near }, ctx) => {
|
|
1279
|
+
await requireAdmin(ctx);
|
|
1280
|
+
try { return { results: await searchPlaces(q, near, mapsCtx(ctx)) }; }
|
|
1281
|
+
catch (err) {
|
|
1282
|
+
console.warn('[acms maps] search failed:', err instanceof Error ? err.message : err);
|
|
1283
|
+
throw new ActionError({ code: 'BAD_GATEWAY', message: 'Address suggestions aren’t answering right now.' });
|
|
1284
|
+
}
|
|
1285
|
+
},
|
|
1286
|
+
}),
|
|
1287
|
+
// a pasted map link / embed code / "lat, lng" → location (short links
|
|
1288
|
+
// are expanded server-side through Google's hosts only)
|
|
1289
|
+
resolve: defineAction({
|
|
1290
|
+
input: z.object({ url: z.string().max(10_000) }),
|
|
1291
|
+
handler: async ({ url }, ctx) => {
|
|
1292
|
+
await requireAdmin(ctx);
|
|
1293
|
+
return { location: await resolveMapLink(url, mapsCtx(ctx)) };
|
|
1294
|
+
},
|
|
1295
|
+
}),
|
|
1296
|
+
},
|
|
1297
|
+
|
|
1298
|
+
places: {
|
|
1299
|
+
list: defineAction({
|
|
1300
|
+
handler: async (_input, ctx) => {
|
|
1301
|
+
await requireAdmin(ctx);
|
|
1302
|
+
return { places: await (await getRepo(ctx.locals)).listPlaces() };
|
|
1303
|
+
},
|
|
1304
|
+
}),
|
|
1305
|
+
// create (no id) or update; refreshes upcoming events that use the place
|
|
1306
|
+
save: defineAction({
|
|
1307
|
+
input: z.object({
|
|
1308
|
+
id: z.string().max(100).optional(),
|
|
1309
|
+
name: z.string().max(300),
|
|
1310
|
+
address: z.string().max(500).default(''),
|
|
1311
|
+
url: z.string().max(1000).default(''),
|
|
1312
|
+
lat: Lat.default(null),
|
|
1313
|
+
lng: Lng.default(null),
|
|
1314
|
+
notes: z.string().max(2000).default(''),
|
|
1315
|
+
isDefault: z.boolean().default(false),
|
|
1316
|
+
}),
|
|
1317
|
+
handler: async (input, ctx) => {
|
|
1318
|
+
await requireAdmin(ctx);
|
|
1319
|
+
const repo = await getRepo(ctx.locals);
|
|
1320
|
+
try { return await savePlace(repo, input, mapsCtx(ctx)); } catch (e) { asActionError(e); }
|
|
1321
|
+
},
|
|
1322
|
+
}),
|
|
1323
|
+
delete: defineAction({
|
|
1324
|
+
input: z.object({ id: z.string().max(100) }),
|
|
1325
|
+
handler: async ({ id }, ctx) => {
|
|
1326
|
+
await requireAdmin(ctx);
|
|
1327
|
+
await (await getRepo(ctx.locals)).deletePlace(id);
|
|
1328
|
+
return { ok: true };
|
|
1329
|
+
},
|
|
1330
|
+
}),
|
|
1331
|
+
},
|
|
1332
|
+
|
|
1187
1333
|
unsplash: {
|
|
1188
1334
|
search: defineAction({
|
|
1189
1335
|
input: z.object({ query: z.string().min(1), page: z.number().int().min(1).default(1) }),
|
|
@@ -137,11 +137,13 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
|
|
|
137
137
|
(settings.designOverrides as Record<string, unknown>) ?? {}
|
|
138
138
|
);
|
|
139
139
|
const [siteLogo, setSiteLogo] = useState(settings.siteLogo);
|
|
140
|
+
const [siteLogoDark, setSiteLogoDark] = useState(settings.siteLogoDark);
|
|
140
141
|
const [siteFavicon, setSiteFavicon] = useState(settings.siteFavicon);
|
|
141
142
|
const [siteCover, setSiteCover] = useState(settings.siteCover);
|
|
142
143
|
const [saveState, setSaveState] = useState<SaveState>('saved');
|
|
143
144
|
const [saveMessage, setSaveMessage] = useState('');
|
|
144
145
|
const [logoPickerOpen, setLogoPickerOpen] = useState(false);
|
|
146
|
+
const [logoDarkPickerOpen, setLogoDarkPickerOpen] = useState(false);
|
|
145
147
|
const [faviconPickerOpen, setFaviconPickerOpen] = useState(false);
|
|
146
148
|
const [coverPickerOpen, setCoverPickerOpen] = useState(false);
|
|
147
149
|
const [copied, setCopied] = useState(false);
|
|
@@ -242,8 +244,8 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
|
|
|
242
244
|
return () => ro.disconnect();
|
|
243
245
|
}, []);
|
|
244
246
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
245
|
-
const latest = useRef({ activeTheme, overrides, siteLogo, siteFavicon, siteCover });
|
|
246
|
-
latest.current = { activeTheme, overrides, siteLogo, siteFavicon, siteCover };
|
|
247
|
+
const latest = useRef({ activeTheme, overrides, siteLogo, siteLogoDark, siteFavicon, siteCover });
|
|
248
|
+
latest.current = { activeTheme, overrides, siteLogo, siteLogoDark, siteFavicon, siteCover };
|
|
247
249
|
|
|
248
250
|
const theme = themes.find((t) => t.slug === activeTheme) ?? themes[0];
|
|
249
251
|
const latestChanges = useRef(changes);
|
|
@@ -251,11 +253,12 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
|
|
|
251
253
|
|
|
252
254
|
// saves stage into settings.draftDesign — the live site is untouched
|
|
253
255
|
const doSave = async () => {
|
|
254
|
-
const { activeTheme, overrides, siteLogo, siteFavicon, siteCover } = latest.current;
|
|
256
|
+
const { activeTheme, overrides, siteLogo, siteLogoDark, siteFavicon, siteCover } = latest.current;
|
|
255
257
|
const { data, error } = await actions.settings.saveDesignDraft({
|
|
256
258
|
activeTheme,
|
|
257
259
|
designOverrides: overrides,
|
|
258
260
|
siteLogo,
|
|
261
|
+
siteLogoDark,
|
|
259
262
|
siteFavicon,
|
|
260
263
|
siteCover,
|
|
261
264
|
});
|
|
@@ -506,6 +509,24 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
|
|
|
506
509
|
</div>
|
|
507
510
|
</div>
|
|
508
511
|
|
|
512
|
+
<div className="dm-row">
|
|
513
|
+
<div className="dm-lab">
|
|
514
|
+
<span>Logo (dark mode)</span>
|
|
515
|
+
<small>Optional. Uses the regular logo when empty.</small>
|
|
516
|
+
</div>
|
|
517
|
+
<div className="dm-ctl">
|
|
518
|
+
{siteLogoDark && <img className="dm-logo" src={siteLogoDark} alt="Logo (dark mode)" />}
|
|
519
|
+
<button className="btn sm" type="button" onClick={() => setLogoDarkPickerOpen(true)}>
|
|
520
|
+
{siteLogoDark ? 'Replace…' : 'Upload…'}
|
|
521
|
+
</button>
|
|
522
|
+
{siteLogoDark && (
|
|
523
|
+
<button className="btn sm ghost" type="button" onClick={() => { setSiteLogoDark(''); scheduleSave(); }}>
|
|
524
|
+
Remove
|
|
525
|
+
</button>
|
|
526
|
+
)}
|
|
527
|
+
</div>
|
|
528
|
+
</div>
|
|
529
|
+
|
|
509
530
|
<div className="dm-row">
|
|
510
531
|
<div className="dm-lab">
|
|
511
532
|
<span>Favicon</span>
|
|
@@ -1021,6 +1042,16 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
|
|
|
1021
1042
|
scheduleSave();
|
|
1022
1043
|
}}
|
|
1023
1044
|
/>
|
|
1045
|
+
<ImagePicker
|
|
1046
|
+
open={logoDarkPickerOpen}
|
|
1047
|
+
unsplashEnabled={unsplashEnabled}
|
|
1048
|
+
onClose={() => setLogoDarkPickerOpen(false)}
|
|
1049
|
+
onSelect={(img) => {
|
|
1050
|
+
setSiteLogoDark(img.src);
|
|
1051
|
+
setLogoDarkPickerOpen(false);
|
|
1052
|
+
scheduleSave();
|
|
1053
|
+
}}
|
|
1054
|
+
/>
|
|
1024
1055
|
<ImagePicker
|
|
1025
1056
|
open={faviconPickerOpen}
|
|
1026
1057
|
unsplashEnabled={unsplashEnabled}
|
|
@@ -9,6 +9,7 @@ import { addToCalendarLinks, eventTimeZone, formatOccurrence, safeColor, type Oc
|
|
|
9
9
|
import { describeRepeat } from '../lib/calendar/rrule';
|
|
10
10
|
import { eventCardHtml, safeHref } from '../lib/calendar/cards';
|
|
11
11
|
import { siteLocale } from '../lib/dates';
|
|
12
|
+
import { directionsUrl, mapAttribution, MAP_HEIGHT, MAP_WIDTH } from '../lib/maps/service';
|
|
12
13
|
|
|
13
14
|
interface Props {
|
|
14
15
|
event: CalEvent;
|
|
@@ -20,7 +21,7 @@ interface Props {
|
|
|
20
21
|
origin: string;
|
|
21
22
|
preview?: boolean;
|
|
22
23
|
// RSVP state when the event takes RSVPs (null otherwise)
|
|
23
|
-
rsvp?: { going: number; left: number | null; maxSeats: number; error: string; open: boolean } | null;
|
|
24
|
+
rsvp?: { going: number; left: number | null; maxSeats: number; error: string; open: boolean; formHtml?: string; fieldsHtml?: string; values?: { name: string; email: string; seats: string } } | null;
|
|
24
25
|
}
|
|
25
26
|
const { event: e, occurrence: o, settings, calendars, html, upcoming, origin, preview = false, rsvp = null } = Astro.props;
|
|
26
27
|
const tz = eventTimeZone(e, settings);
|
|
@@ -38,6 +39,12 @@ const mapHref = e.location.url
|
|
|
38
39
|
: e.location.address
|
|
39
40
|
? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent([e.location.name, e.location.address].filter(Boolean).join(', '))}`
|
|
40
41
|
: '';
|
|
42
|
+
// static map (made when the event was saved; see lib/maps/service.ts)
|
|
43
|
+
const loc = e.location;
|
|
44
|
+
const showMap = loc.showMap !== false && Boolean(loc.mapImage) && typeof loc.lat === 'number' && typeof loc.lng === 'number';
|
|
45
|
+
const directions = showMap ? safeHref(directionsUrl(loc)) : '';
|
|
46
|
+
const mapCredit = showMap ? mapAttribution(loc.mapImage) : '';
|
|
47
|
+
const mapLabel = loc.name || loc.address || 'the location';
|
|
41
48
|
const fieldRows = CALENDAR.eventFields.filter((fd) => e.fields[fd.key]).map((fd) => ({ label: fd.label, value: e.fields[fd.key], type: fd.type }));
|
|
42
49
|
const visibleTags = e.tags.filter((t) => !t.startsWith('#'));
|
|
43
50
|
const I = {
|
|
@@ -95,6 +102,15 @@ const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.t
|
|
|
95
102
|
</div>
|
|
96
103
|
</div>
|
|
97
104
|
)}
|
|
105
|
+
{showMap && (
|
|
106
|
+
<figure class="pc-ev-map">
|
|
107
|
+
<a href={directions || undefined} target="_blank" rel="noopener" aria-label={`Directions to ${mapLabel}`}>
|
|
108
|
+
<img class:list={['pc-ev-map-img', { 'is-light': Boolean(loc.mapImageDark) }]} src={loc.mapImage} alt={`Map of ${mapLabel}`} width={MAP_WIDTH} height={MAP_HEIGHT} loading="lazy" decoding="async" />
|
|
109
|
+
{loc.mapImageDark && <img class="pc-ev-map-img is-dark" src={loc.mapImageDark} alt={`Map of ${mapLabel}`} width={MAP_WIDTH} height={MAP_HEIGHT} loading="lazy" decoding="async" />}
|
|
110
|
+
</a>
|
|
111
|
+
{mapCredit && <figcaption class="pc-ev-map-credit">{mapCredit}</figcaption>}
|
|
112
|
+
</figure>
|
|
113
|
+
)}
|
|
98
114
|
{repeats && (
|
|
99
115
|
<div class="pc-ev-fact">
|
|
100
116
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={I.repeat} /></svg>
|
|
@@ -114,15 +130,19 @@ const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.t
|
|
|
114
130
|
<details class="pc-ev-rsvpform" open={Boolean(rsvp.error)}>
|
|
115
131
|
<summary class="pc-ev-cta">Save my seat</summary>
|
|
116
132
|
<form method="post">
|
|
133
|
+
<Fragment set:html={rsvp.formHtml ?? ''}/>
|
|
134
|
+
{e.rsvp.form?.title && <h3>{e.rsvp.form.title}</h3>}
|
|
135
|
+
{e.rsvp.form?.intro && <p>{e.rsvp.form.intro}</p>}
|
|
117
136
|
{rsvp.error && <p class="pc-bk-error">{rsvp.error}</p>}
|
|
118
|
-
<label class="pc-bk-field"><span>Your name</span><input name="name" required autocomplete="name" /></label>
|
|
119
|
-
<label class="pc-bk-field"><span>Email</span><input name="email" type="email" required autocomplete="email" /></label>
|
|
137
|
+
<label class="pc-bk-field"><span>Your name</span><input name="name" required maxlength="120" autocomplete="name" value={rsvp.values?.name} /></label>
|
|
138
|
+
<label class="pc-bk-field"><span>Email</span><input name="email" type="email" required maxlength="200" autocomplete="email" value={rsvp.values?.email} /></label>
|
|
120
139
|
{rsvp.maxSeats > 1 && (
|
|
121
140
|
<label class="pc-bk-field"><span>Seats</span>
|
|
122
|
-
<select name="seats">{Array.from({ length: Math.min(rsvp.maxSeats, rsvp.left ?? rsvp.maxSeats) }, (_, i) => <option value={i + 1}>{i + 1}</option>)}</select>
|
|
141
|
+
<select name="seats">{Array.from({ length: Math.min(rsvp.maxSeats, rsvp.left ?? rsvp.maxSeats) }, (_, i) => <option value={i + 1} selected={Number(rsvp.values?.seats || 1) === i + 1}>{i + 1}</option>)}</select>
|
|
123
142
|
</label>
|
|
124
143
|
)}
|
|
125
|
-
<
|
|
144
|
+
<Fragment set:html={rsvp.fieldsHtml ?? ''}/>
|
|
145
|
+
<button class="pc-bk-submit" type="submit">{e.rsvp.form?.submitLabel || (e.rsvp.autoConfirm ? 'Confirm my seat' : 'Request a seat')}</button>
|
|
126
146
|
</form>
|
|
127
147
|
</details>
|
|
128
148
|
) : <p class="pc-ev-full">{rsvp.open ? 'This event is full.' : 'RSVPs are closed.'}</p>}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { bookingsEnabled } from '../../lib/calendar/config';
|
|
2
3
|
// Shared header for the Bookings home: Requests (what came in) and Rooms &
|
|
3
4
|
// people (what can be booked) are two tabs of one place, with the same ⚙ and
|
|
4
5
|
// "New ▾" as the Schedule. Extra page actions go in the default slot.
|
|
@@ -22,7 +23,7 @@ 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.
|
|
|
22
23
|
<div class="page-actions">
|
|
23
24
|
<slot />
|
|
24
25
|
<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
|
+
{bookingsEnabled() && <details class="newmenu">
|
|
26
27
|
<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
28
|
<div class="newmenu-pop" role="menu">
|
|
28
29
|
{inPlace
|
|
@@ -35,12 +36,12 @@ 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.
|
|
|
35
36
|
? <button type="button" role="menuitem" data-modal="new-resource">Room or person</button>
|
|
36
37
|
: <a role="menuitem" href="/admin/calendar/resources?new=1">Room or person</a>}
|
|
37
38
|
</div>
|
|
38
|
-
</details>
|
|
39
|
+
</details>}
|
|
39
40
|
</div>
|
|
40
41
|
</div>
|
|
41
42
|
<nav class="ptabs" aria-label="Bookings">
|
|
42
43
|
<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
|
+
{bookingsEnabled() && <a href="/admin/calendar/resources" aria-current={active === 'rooms' ? 'page' : undefined}>Rooms & people<small>{rooms}</small></a>}
|
|
44
45
|
</nav>
|
|
45
46
|
|
|
46
47
|
<script>
|
|
@@ -78,8 +78,8 @@ export default function RoomHolds({ times, hold, status, locationName, onChange,
|
|
|
78
78
|
};
|
|
79
79
|
const label = (r: RoomRow) => {
|
|
80
80
|
if (!r.conflicts.length) return { text: 'Free', cls: 'free' };
|
|
81
|
-
if (r.dates > 1) return { text: `
|
|
82
|
-
return { text:
|
|
81
|
+
if (r.dates > 1) return { text: `Busy on ${r.datesWithBookings} of ${r.dates} dates`, cls: 'busy' };
|
|
82
|
+
return { text: r.conflicts.length === 1 ? 'Busy' : `Busy · ${r.conflicts.length} bookings`, cls: 'busy' };
|
|
83
83
|
};
|
|
84
84
|
const conflicted = data.rooms.filter((r) => selected.has(r.id) && r.conflicts.length);
|
|
85
85
|
const draft = status === 'draft';
|
|
@@ -95,16 +95,21 @@ export default function RoomHolds({ times, hold, status, locationName, onChange,
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
return (
|
|
98
|
-
<section className="
|
|
99
|
-
<
|
|
100
|
-
|
|
101
|
-
|
|
98
|
+
<section className="rh" aria-labelledby="rh-title">
|
|
99
|
+
<div className="rh-head">
|
|
100
|
+
<b id="rh-title">Hold rooms & people</b>
|
|
101
|
+
<small>can’t be booked while this runs</small>
|
|
102
|
+
</div>
|
|
103
|
+
<div className="rh-table" role="group" aria-labelledby="rh-title">
|
|
104
|
+
<div className="rh-tr rh-th" aria-hidden="true"><span /><span>Name</span><span>Capacity</span><span>Status</span></div>
|
|
102
105
|
{data.rooms.map((r) => {
|
|
103
106
|
const l = label(r);
|
|
107
|
+
const kind = r.kind.trim().toLowerCase();
|
|
104
108
|
return (
|
|
105
|
-
<label key={r.id} className={`rh-room${selected.has(r.id) ? ' on' : ''}`}>
|
|
109
|
+
<label key={r.id} className={`rh-tr rh-room${selected.has(r.id) ? ' on' : ''}`}>
|
|
106
110
|
<input type="checkbox" checked={selected.has(r.id)} onChange={() => toggle(r)} />
|
|
107
|
-
<span className="rh-name"><b>{r.name}</b
|
|
111
|
+
<span className="rh-name"><b>{r.name}</b>{kind && kind !== 'room' && <small>· {kind}</small>}</span>
|
|
112
|
+
<span className="rh-cap">{r.capacity ? <><span className="rh-cap-l">up to </span>{r.capacity}</> : '—'}</span>
|
|
108
113
|
<span className={`rh-st ${l.cls}`}>{l.text}</span>
|
|
109
114
|
</label>
|
|
110
115
|
);
|