@acms-subir/core 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/cli/preview.mjs +8 -4
  3. package/cli/sections.mjs +4 -2
  4. package/docs/mobile.md +1 -1
  5. package/docs/theme-authoring.md +19 -3
  6. package/integration.mjs +21 -4
  7. package/package.json +14 -12
  8. package/src/actions/index.ts +186 -3
  9. package/src/components/booking/BookingFlow.astro +6 -3
  10. package/src/components/calendar/BookingsHead.astro +51 -0
  11. package/src/components/calendar/CalendarBoard.tsx +618 -0
  12. package/src/components/calendar/MiniMonth.tsx +54 -0
  13. package/src/components/calendar/NewEventDialog.tsx +44 -0
  14. package/src/components/calendar/QuickAdd.tsx +263 -0
  15. package/src/components/calendar/RoomHolds.tsx +203 -0
  16. package/src/components/calendar/WhereItLands.tsx +51 -0
  17. package/src/components/editor/EventPanel.tsx +23 -2
  18. package/src/components/editor/PostEditor.tsx +32 -4
  19. package/src/components/import/ImportExport.tsx +4 -4
  20. package/src/components/import/runner.ts +20 -3
  21. package/src/layouts/Admin.astro +5 -6
  22. package/src/layouts/Site.astro +29 -2
  23. package/src/lib/backup/content.ts +6 -1
  24. package/src/lib/booking/agent.ts +3 -2
  25. package/src/lib/booking/approval.ts +70 -0
  26. package/src/lib/booking/holds.ts +120 -0
  27. package/src/lib/booking/service.ts +37 -3
  28. package/src/lib/calendar/agent-schema.ts +9 -1
  29. package/src/lib/calendar/agent.ts +16 -1
  30. package/src/lib/calendar/board.ts +171 -0
  31. package/src/lib/calendar/config.ts +3 -0
  32. package/src/lib/calendar/grid.ts +169 -0
  33. package/src/lib/calendar/migration.ts +16 -0
  34. package/src/lib/calendar/render.ts +9 -7
  35. package/src/lib/calendar/schedule.ts +166 -0
  36. package/src/lib/content-service.ts +5 -0
  37. package/src/lib/drafts.ts +1 -1
  38. package/src/lib/import/bundle.ts +2 -1
  39. package/src/lib/mobile/calendar.ts +10 -6
  40. package/src/lib/mobile/http.ts +6 -0
  41. package/src/lib/repo/migrations.ts +3 -0
  42. package/src/lib/repo/sql.ts +28 -16
  43. package/src/lib/repo/types.ts +2 -2
  44. package/src/lib/scheduler.ts +3 -1
  45. package/src/lib/schema.ts +49 -0
  46. package/src/lib/sections.ts +33 -8
  47. package/src/lib/site-chrome.ts +34 -0
  48. package/src/lib/theme-assets.ts +16 -0
  49. package/src/lib/theme.ts +17 -3
  50. package/src/middleware.ts +6 -2
  51. package/src/pages/admin/calendar/bookings.astro +32 -26
  52. package/src/pages/admin/calendar/calendars.astro +4 -263
  53. package/src/pages/admin/calendar/events/index.astro +41 -83
  54. package/src/pages/admin/calendar/index.astro +43 -0
  55. package/src/pages/admin/calendar/resources/[id].astro +27 -5
  56. package/src/pages/admin/calendar/resources/index.astro +16 -17
  57. package/src/pages/admin/calendar/resources/link.astro +52 -12
  58. package/src/pages/admin/calendar/settings.astro +458 -0
  59. package/src/pages/admin/integrations.astro +5 -11
  60. package/src/pages/book/[resource].ics.ts +7 -1
  61. package/src/styles/admin.css +48 -0
  62. package/src/styles/cards.css +5 -1
  63. package/src/styles/editor.css +36 -0
  64. package/src/styles/schedule.css +325 -0
  65. package/src/styles/sections.css +19 -16
  66. package/src/theme-sdk/fields.mjs +5 -1
  67. package/src/theme-sdk/index.ts +5 -1
  68. package/src/virtual.d.ts +3 -0
  69. package/src/pages/admin/calendar/index.ts +0 -4
@@ -1,10 +1,12 @@
1
1
  import sanitizeHtml from 'sanitize-html';
2
2
  import type { z } from 'zod';
3
3
  import type { ContentRepository } from './repo/types';
4
- import type { Settings } from './schema';
4
+ import { authorSlug, type Settings, type User } from './schema';
5
+ import { htmlToPlaintext } from './plaintext';
5
6
  import type { SectionInstance as SectionInstanceSchema, SectionDefinition } from '../theme-sdk/contract.mjs';
6
7
  import { normalizeData, safeUrl, SOURCE_LIMIT_MAX, isEmptyValue, safeFocus, focusKey } from '../theme-sdk/fields.mjs';
7
8
  import type { Theme } from './theme';
9
+ import { themeAssetUrl } from './theme-assets';
8
10
  import { CONTENT, collectionUrl } from './content';
9
11
  import { formatDate, siteLocale } from './dates';
10
12
  import { upcomingEvents } from './calendar/cards';
@@ -33,7 +35,9 @@ export interface EventItem {
33
35
  /** "11:00 AM – 1:00 PM EDT" */ time: string; month: string; day: string; weekday: string;
34
36
  cta: { label: string; href: string } | null;
35
37
  }
36
- export interface PostItem { title: string; url: string; excerpt: string; image: string; imageAlt: string; date: string; publishedAt: number | null; tags: string[] }
38
+ export interface PostAuthor { name: string; url: string; avatar: string }
39
+ // excerpt: the post's own, else the first ~160 characters of its text
40
+ export interface PostItem { title: string; url: string; excerpt: string; image: string; imageAlt: string; date: string; publishedAt: number | null; tags: string[]; authors: PostAuthor[] }
37
41
  export interface BookingItem { title: string; url: string; description: string; resource: string; minutes: number }
38
42
 
39
43
  /** apply a theme migration when the stored instance is older than the definition */
@@ -103,10 +107,14 @@ function resolveLink(value: unknown, refs: RefMap, lp: Lp = same): LinkValue {
103
107
  return { label: String(v.label ?? ''), href, external: /^https?:\/\//i.test(href) };
104
108
  }
105
109
 
106
- function resolveScalar(field: FieldDef, value: unknown, refs: RefMap, lp: Lp = same): unknown {
110
+ type Asset = (path: string) => string;
111
+ const noAsset: Asset = () => '';
112
+ function resolveScalar(field: FieldDef, value: unknown, refs: RefMap, lp: Lp = same, asset: Asset = noAsset): unknown {
107
113
  switch (field.type) {
108
114
  case 'richtext': return sanitizeRichText(String(value ?? ''));
109
- case 'url': case 'image': return safeUrl(value);
115
+ // images may name a file the theme ships: "assets/hero.jpg" → its built URL
116
+ case 'image': return typeof value === 'string' && /^(\.\/)?assets\//.test(value) ? asset(value) : safeUrl(value);
117
+ case 'url': return safeUrl(value);
110
118
  case 'link': return resolveLink(value, refs, lp);
111
119
  default: return value;
112
120
  }
@@ -145,9 +153,15 @@ async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value
145
153
  const collection = cfg.collection && CONTENT.collections[cfg.collection] ? cfg.collection : 'posts';
146
154
  let posts = await repo.listPublishedPosts(collection);
147
155
  if (cfg.tag) posts = posts.filter((p) => p.tags.some((t) => t.toLowerCase() === cfg.tag!.toLowerCase()));
148
- return posts.slice(0, limit).map((raw): PostItem => { const p = lang ? localizeDoc(raw, lang) : raw; return {
149
- title: p.title, url: lp(collectionUrl(p.collection, p.slug)), excerpt: p.excerpt, image: safeUrl(p.cover), imageAlt: p.coverAlt,
156
+ const shown = posts.slice(0, limit);
157
+ // public byline only: name, profile URL, avatar — never emails or roles
158
+ const users = shown.some((p) => p.authorIds.length || p.authorId) ? new Map((await repo.listUsers()).map((u) => [u.id, u])) : new Map();
159
+ return shown.map((raw): PostItem => { const p = lang ? localizeDoc(raw, lang) : raw; return {
160
+ title: p.title, url: lp(collectionUrl(p.collection, p.slug)), excerpt: p.excerpt || autoExcerpt(p.bodyHtml), image: safeUrl(p.cover), imageAlt: p.coverAlt,
150
161
  date: formatDate(p.publishedAt, settings, 'short'), publishedAt: p.publishedAt ?? null, tags: p.tags,
162
+ authors: (raw.authorIds.length ? raw.authorIds : raw.authorId ? [raw.authorId] : [])
163
+ .map((id) => users.get(id)).filter((u): u is User => Boolean(u))
164
+ .map((u) => ({ name: u.name, url: lp(`/author/${authorSlug(u)}`), avatar: safeUrl(u.avatar) })),
151
165
  }; });
152
166
  }
153
167
  if (!bookingsEnabled()) return [];
@@ -160,6 +174,15 @@ async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value
160
174
  });
161
175
  }
162
176
 
177
+ // first ~160 characters of the body, cut at a word, for posts without an excerpt
178
+ export function autoExcerpt(html: string, max = 160): string {
179
+ const text = htmlToPlaintext(html);
180
+ if (text.length <= max) return text;
181
+ const cut = text.slice(0, max + 1);
182
+ const at = cut.lastIndexOf(' ');
183
+ return `${(at > max * 0.6 ? cut.slice(0, at) : text.slice(0, max)).replace(/[\s,.;:!?—–-]+$/, '')}…`;
184
+ }
185
+
163
186
  export interface ResolveContext { repo: ContentRepository; settings: Settings; now: number }
164
187
 
165
188
  /** stored data → component props: defaults filled, refs/sources resolved, richtext sanitized */
@@ -170,6 +193,8 @@ export async function resolveSectionData(def: Definition, stored: Record<string,
170
193
  const refMap = refs.size ? await resolveRefs(refs, ctx.repo) : new Map<string, string>();
171
194
  const lang = isExtraLocale(ctx.settings.locale) ? ctx.settings.locale : '';
172
195
  const lp: Lp = lang ? (p) => localePath(p, lang) : same;
196
+ // 'ink' = DEFAULT_THEME (importing it would load every theme template)
197
+ const asset: Asset = (p) => themeAssetUrl(ctx.settings.activeTheme || 'ink', p);
173
198
  const out: Record<string, unknown> = {};
174
199
  await Promise.all(Object.entries(def.fields).map(async ([key, field]) => {
175
200
  const value = data[key];
@@ -178,13 +203,13 @@ export async function resolveSectionData(def: Definition, stored: Record<string,
178
203
  out[key] = (value as Record<string, unknown>[]).map((row) => {
179
204
  const item: Record<string, unknown> = {};
180
205
  for (const [k, sub] of Object.entries(field.fields)) {
181
- item[k] = resolveScalar(sub as FieldDef, row[k], refMap, lp);
206
+ item[k] = resolveScalar(sub as FieldDef, row[k], refMap, lp, asset);
182
207
  if (sub.type === 'image') item[`${k}Focus`] = safeFocus(row[focusKey(k)]);
183
208
  }
184
209
  return item;
185
210
  });
186
211
  } else {
187
- out[key] = resolveScalar(field, value, refMap, lp);
212
+ out[key] = resolveScalar(field, value, refMap, lp, asset);
188
213
  // images also hand over their focal point, ready for object-position
189
214
  if (field.type === 'image') out[`${key}Focus`] = safeFocus(stored[focusKey(key)]);
190
215
  }
@@ -0,0 +1,34 @@
1
+ // The `site` prop given to a theme's templates/header.astro and
2
+ // templates/footer.astro. Everything is resolved for the current request:
3
+ // links are already localized, `current` marks the page you're on, languages
4
+ // carry their own names ("Español"), and social links carry inline SVG icons.
5
+ //
6
+ // Core behaviors keep working inside a custom header through data hooks:
7
+ // [data-search-open] opens the search dialog (when site.search is set)
8
+ // [data-scheme-toggle] flips light/dark (when site.schemeToggle is true)
9
+ // [data-menu-open] opens the phone menu sheet (built from site.links)
10
+ // [data-live-header] (hidden) becomes the "Live" link during a stream
11
+ export interface ChromeLink {
12
+ label: string;
13
+ url: string;
14
+ current: boolean;
15
+ external: boolean;
16
+ }
17
+
18
+ export interface SiteChrome {
19
+ title: string;
20
+ tagline: string;
21
+ logo: string; // '' when the site has no logo
22
+ homeUrl: string;
23
+ links: ChromeLink[]; // header navigation
24
+ footerLinks: ChromeLink[];
25
+ social: (ChromeLink & { icon: string })[]; // icon: inline SVG ('' = text link)
26
+ readLinks: ChromeLink[]; // collections, search, RSS
27
+ footerHtml: string; // Settings → footer text, rendered
28
+ languages: { code: string; label: string; url: string; current: boolean }[]; // [] when single-language
29
+ search: { url: string; label: string } | null;
30
+ schemeToggle: boolean;
31
+ liveHeader: boolean;
32
+ headerStyle: string; // theme token `header`
33
+ footerStyle: string; // theme token `footer`
34
+ }
@@ -0,0 +1,16 @@
1
+ // Files a theme ships under assets/, as built URLs. Kept apart from theme.ts
2
+ // (which loads every .astro template) so section data resolution can use it
3
+ // without pulling the templates in.
4
+ import * as user from 'virtual:acms-user-themes';
5
+
6
+ const builtin = import.meta.glob(['../themes/*/assets/**/*', '!**/.img-subir-originals/**'], { eager: true, query: '?url', import: 'default' }) as Record<string, string>;
7
+
8
+ // "assets/hero.jpg" (relative to the theme folder) → the built asset URL, so
9
+ // section image fields and defaults can point at files the theme ships.
10
+ // '' when the theme has no such file. Project themes win over built-ins.
11
+ export function themeAssetUrl(slug: string, path: string): string {
12
+ const file = path.replace(/^\.\//, '');
13
+ if (!/^assets\/[^?#]+$/.test(file) || file.includes('..')) return '';
14
+ const all = { ...builtin, ...((user as { assets?: Record<string, string> }).assets ?? {}) };
15
+ return Object.entries(all).find(([p]) => p.endsWith(`/themes/${slug}/${file}`))?.[1] ?? '';
16
+ }
package/src/lib/theme.ts CHANGED
@@ -83,6 +83,10 @@ export interface Theme {
83
83
  pageTemplates: Record<string, unknown>;
84
84
  // templates/event.astro replaces the built-in event page layout
85
85
  EventTemplate: unknown | null;
86
+ // templates/header.astro / footer.astro replace the site header and footer;
87
+ // they receive a `site` prop (lib/site-chrome.ts) with links already resolved
88
+ HeaderTemplate: unknown | null;
89
+ FooterTemplate: unknown | null;
86
90
  }
87
91
 
88
92
  interface ThemeJson { sections?: Record<string, unknown>; version?: string; name?: string; description?: string; tokens?: Record<string, unknown>; post?: Record<string, unknown> }
@@ -100,6 +104,8 @@ const homes = import.meta.glob('../themes/*/templates/home.astro', { eager: true
100
104
  const postTpls = import.meta.glob('../themes/*/templates/post-*.astro', { eager: true }) as Record<string, { default: unknown }>;
101
105
  const pageTpls = import.meta.glob('../themes/*/templates/page-*.astro', { eager: true }) as Record<string, { default: unknown }>;
102
106
  const eventTpls = import.meta.glob('../themes/*/templates/event.astro', { eager: true }) as Record<string, { default: unknown }>;
107
+ const headerTpls = import.meta.glob('../themes/*/templates/header.astro', { eager: true }) as Record<string, { default: unknown }>;
108
+ const footerTpls = import.meta.glob('../themes/*/templates/footer.astro', { eager: true }) as Record<string, { default: unknown }>;
103
109
  const migrationMods = {
104
110
  ...(import.meta.glob('../themes/*/migrations/*.mjs', { eager: true }) as Record<string, { default?: unknown }>),
105
111
  ...((user as { migrations?: Record<string, { default?: unknown }> }).migrations ?? {}),
@@ -126,7 +132,9 @@ function buildThemes(
126
132
  homeMap: Record<string, { default: unknown }>,
127
133
  postMap: Record<string, { default: unknown }>,
128
134
  pageMap: Record<string, { default: unknown }>,
129
- eventMap: Record<string, { default: unknown }> = {}
135
+ eventMap: Record<string, { default: unknown }> = {},
136
+ headerMap: Record<string, { default: unknown }> = {},
137
+ footerMap: Record<string, { default: unknown }> = {}
130
138
  ): Theme[] {
131
139
  return Object.entries(jsonMap).map(([path, raw]) => {
132
140
  const slug = slugOf(path);
@@ -157,18 +165,22 @@ function buildThemes(
157
165
  postTemplates: templatesFor(slug, postMap, 'post'),
158
166
  pageTemplates: templatesFor(slug, pageMap, 'page'),
159
167
  EventTemplate: Object.entries(eventMap).find(([p]) => slugOf(p) === slug)?.[1]?.default ?? null,
168
+ HeaderTemplate: Object.entries(headerMap).find(([p]) => slugOf(p) === slug)?.[1]?.default ?? null,
169
+ FooterTemplate: Object.entries(footerMap).find(([p]) => slugOf(p) === slug)?.[1]?.default ?? null,
160
170
  };
161
171
  });
162
172
  }
163
173
 
164
- const builtins = buildThemes(jsons, csses, homes, postTpls, pageTpls, eventTpls);
174
+ const builtins = buildThemes(jsons, csses, homes, postTpls, pageTpls, eventTpls, headerTpls, footerTpls);
165
175
  const projectThemes = buildThemes(
166
176
  user.jsons as Record<string, ThemeJson>,
167
177
  user.csses,
168
178
  user.homes,
169
179
  user.postTpls,
170
180
  user.pageTpls,
171
- (user as { eventTpls?: Record<string, { default: unknown }> }).eventTpls ?? {}
181
+ (user as { eventTpls?: Record<string, { default: unknown }> }).eventTpls ?? {},
182
+ (user as { headerTpls?: Record<string, { default: unknown }> }).headerTpls ?? {},
183
+ (user as { footerTpls?: Record<string, { default: unknown }> }).footerTpls ?? {}
172
184
  );
173
185
  const projectSlugs = new Set(projectThemes.map((t) => t.slug));
174
186
 
@@ -176,6 +188,8 @@ const themes: Theme[] = [...builtins.filter((t) => !projectSlugs.has(t.slug)), .
176
188
  (a, b) => (a.slug === DEFAULT_THEME ? -1 : b.slug === DEFAULT_THEME ? 1 : a.name.localeCompare(b.name))
177
189
  );
178
190
 
191
+ export { themeAssetUrl } from './theme-assets';
192
+
179
193
  export function listThemes(): Theme[] {
180
194
  return themes;
181
195
  }
package/src/middleware.ts CHANGED
@@ -5,7 +5,7 @@ import { getRepo } from './lib/repo';
5
5
  import { CONTENT } from './lib/content';
6
6
  import { processDueDeliveries, waitUntilFrom } from './lib/webhooks';
7
7
  import { dispatchEmail } from './lib/email/service';
8
- import { sendDueReminders } from './lib/booking/service';
8
+ import { decideOverdue, sendDueReminders } from './lib/booking/service';
9
9
  import { PUSH } from './lib/push/config';
10
10
  import { processPushQueue, runPushJobs } from './lib/push/service';
11
11
  import { previewParamRedirect, hasPreviewCookie } from './lib/designPreview';
@@ -110,7 +110,11 @@ export const onRequest = defineMiddleware(async (context, next) => {
110
110
  if (wait) wait(retries); else await retries;
111
111
  // booking reminders (Node has no cron): site URL, else this request's origin
112
112
  const reminders = repo.getSettings()
113
- .then((s) => sendDueReminders(repo, { origin: s.siteUrl.replace(/\/+$/, '') || context.url.origin, locals: context.locals, now }))
113
+ .then(async (s) => {
114
+ const flow = { origin: s.siteUrl.replace(/\/+$/, '') || context.url.origin, locals: context.locals, now };
115
+ await sendDueReminders(repo, flow);
116
+ await decideOverdue(repo, flow);
117
+ })
114
118
  .catch(() => {});
115
119
  if (wait) wait(reminders); else await reminders;
116
120
  // push jobs (Node has no cron): never awaited here, errors are logged
@@ -1,5 +1,7 @@
1
1
  ---
2
2
  import Admin from '../../../layouts/Admin.astro';
3
+ import BookingsHead from '../../../components/calendar/BookingsHead.astro';
4
+ import { eventHolds } from '../../../lib/booking/holds';
3
5
  import Modal from '../../../components/Modal.astro';
4
6
  import { getRepo } from '../../../lib/repo';
5
7
  import type { Booking, BookingStatus } from '../../../lib/schema';
@@ -73,7 +75,7 @@ if (Astro.request.method === 'POST') {
73
75
  const allDay = Boolean(form.get('allDay'));
74
76
  const start = allDay ? localMidnight(date, tz) : toMs(date, String(form.get('start')));
75
77
  const end = allDay ? localMidnight(addDaysYmd(date, 1), tz) : toMs(date, String(form.get('end')));
76
- if (!r || Number.isNaN(start) || Number.isNaN(end)) throw new BookingError(400, 'Choose a resource, date and times.');
78
+ if (!r || Number.isNaN(start) || Number.isNaN(end)) throw new BookingError(400, 'Choose a room or person, a date and times.');
77
79
  const b = await createBlock(repo, r, start, end, String(form.get('note') || ''));
78
80
  if (!b) throw new BookingError(409, 'That time already has a booking — cancel or move it first.');
79
81
  return Astro.redirect(keep({ notice: `${r.name} is blocked.` }));
@@ -85,6 +87,9 @@ if (Astro.request.method === 'POST') {
85
87
  const start = toMs(String(form.get('date')), String(form.get('time')));
86
88
  if (Number.isNaN(start)) throw new BookingError(400, 'Pick a date and time.');
87
89
  const len = link.mode === 'range' && link.lengths.length ? Number(form.get('len')) || link.lengths[0] : link.mode === 'day' ? 1440 : link.duration;
90
+ // staff may book over an event's hold, but only on purpose
91
+ const held = (await eventHolds(repo, settings, start, start + len * 60_000, r.id))[0];
92
+ if (held && !form.has('overHold')) throw new BookingError(409, `${r.name} is held for “${held.title}” then. Tick “Book anyway” to add it regardless.`);
88
93
  const b = await createResourceBooking(repo, {
89
94
  resource: r, link, start, end: start + len * 60_000, source: 'admin', status: 'confirmed',
90
95
  booker: { name: String(form.get('name') || ''), email: String(form.get('email') || ''), phone: String(form.get('phone') || '') },
@@ -155,6 +160,8 @@ const dayCols = resources.filter((r) => !resFilter || r.id === resFilter).map((r
155
160
  r, items: all.filter((b) => b.resourceId === r.id && active(b) && b.start < dayEnd && b.end > dayStart),
156
161
  }));
157
162
  const H0 = 7, H1 = 22;
163
+ // rooms held by events that day (lib/booking/holds.ts), drawn under bookings
164
+ const dayHolds = view === 'day' ? await eventHolds(repo, settings, dayStart, dayEnd) : [];
158
165
  const pos = (b: Booking) => {
159
166
  const s = Math.max(b.start, dayStart), e = Math.min(b.end, dayEnd);
160
167
  const top = ((s - dayStart) / 3_600_000 - H0) / (H1 - H0) * 100;
@@ -164,7 +171,7 @@ const pos = (b: Booking) => {
164
171
 
165
172
  const selected = Astro.url.searchParams.get('b') ? await repo.getBooking(Astro.url.searchParams.get('b')!) : null;
166
173
  const provider = emailProvider(Astro.locals);
167
- const TABS = [['upcoming', 'Upcoming'], ['unconfirmed', 'Unconfirmed'], ['past', 'Past'], ['cancelled', 'Cancelled']] as const;
174
+ const TABS = [['upcoming', 'Upcoming'], ['unconfirmed', 'Needs approval'], ['past', 'Past'], ['cancelled', 'Cancelled']] as const;
168
175
  const statusLabel = (b: Booking) => (b.kind === 'block' ? 'Blocked' : b.status[0].toUpperCase() + b.status.slice(1));
169
176
  const whenOf = (b: Booking) => (b.end - b.start >= 86_400_000 - 3_600_000 && ymd(b.start, tz) !== ymd(b.end - 1, tz)
170
177
  ? `${f.day.format(b.start)} – ${f.day.format(b.end - 1)}`
@@ -174,21 +181,14 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
174
181
  ---
175
182
 
176
183
  <Admin title="Bookings" section="calendar:bookings" wide={view === 'day'}>
177
- <div class="page-head">
178
- <div>
179
- <h1>Bookings</h1>
180
- <p class="sub">{counts.unconfirmed ? `${counts.unconfirmed} waiting for approval · ` : ''}{week} in the next 7 days</p>
181
- </div>
182
- <div class="page-actions">
183
- <nav class="seg" aria-label="View">
184
- <a class={view === 'agenda' ? 'on' : ''} href={keep({ view: null })}>Agenda</a>
185
- <a class={view === 'day' ? 'on' : ''} href={keep({ view: 'day' })}>Day</a>
186
- </nav>
187
- <a class="btn" href={keep({ format: 'csv', b: null, notice: null })}>Export</a>
188
- <button class="btn" type="button" data-modal="bk-block" disabled={!resources.length}>Block time</button>
189
- <button class="btn primary" type="button" data-modal="bk-add" disabled={!activeLinks.length}>Add booking</button>
190
- </div>
191
- </div>
184
+ <BookingsHead active="requests" inPlace pending={counts.unconfirmed} rooms={resources.length}
185
+ sub={`${counts.unconfirmed ? `${counts.unconfirmed} ${counts.unconfirmed === 1 ? 'needs' : 'need'} approval · ` : ''}${week} in the next 7 days`}>
186
+ <nav class="seg" aria-label="View">
187
+ <a class={view === 'agenda' ? 'on' : ''} href={keep({ view: null })}>Agenda</a>
188
+ <a class={view === 'day' ? 'on' : ''} href={keep({ view: 'day' })}>Day</a>
189
+ </nav>
190
+ <a class="btn" href={keep({ format: 'csv', b: null, notice: null })}>Export</a>
191
+ </BookingsHead>
192
192
  {notice && <p class="notice" role="status">{notice}</p>}
193
193
  {error && <p class="notice warn" role="alert">{error}</p>}
194
194
  {provider === 'none' && <p class="notice"><span>Emails are off — set <code>EMAIL_FROM</code> plus <code>RESEND_API_KEY</code> (or a Cloudflare <code>send_email</code> binding named EMAIL) so guests get confirmations and reminders.</span></p>}
@@ -196,13 +196,13 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
196
196
  {resources.length === 0 && !all.length ? (
197
197
  <div class="empty-state">
198
198
  <b>No bookings yet</b>
199
- <p>Add a room or a person under Resources, share its booking link, and bookings land here.</p>
200
- <a class="btn primary" href="/admin/calendar/resources?new=1">New resource</a>
199
+ <p>Bookings are times people reserve with a room or person. Add one, share its booking link, and requests land here.</p>
200
+ <a class="btn primary" href="/admin/calendar/resources?new=1">Add a room or person</a>
201
201
  </div>
202
202
  ) : (
203
203
  <>
204
204
  <div class="bk-kpis kpis">
205
- <div class="kpi"><small>Waiting for approval</small><b>{counts.unconfirmed}</b></div>
205
+ <div class="kpi"><small>Needs approval</small><b>{counts.unconfirmed}</b></div>
206
206
  <div class="kpi"><small>Next 7 days</small><b>{week}</b></div>
207
207
  <div class="kpi"><small>Today</small><b>{all.filter((b) => active(b) && ymd(b.start, tz) === today).length}</b></div>
208
208
  <div class="kpi"><small>Cancelled · 30 days</small><b>{cancelled30}</b></div>
@@ -229,9 +229,9 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
229
229
  </div>
230
230
  {resources.length > 1 && (
231
231
  <details class={`fmenu ${resFilter ? 'is-set' : ''}`}>
232
- <summary>{resFilter ? resById.get(resFilter)?.name : 'All resources'} <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>
232
+ <summary>{resFilter ? resById.get(resFilter)?.name : 'All rooms & people'} <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>
233
233
  <div class="fmenu-pop">
234
- <a class={!resFilter ? 'on' : ''} href={keep({ r: null })}>All resources</a>
234
+ <a class={!resFilter ? 'on' : ''} href={keep({ r: null })}>All rooms &amp; people</a>
235
235
  {resources.map((r) => <a class={resFilter === r.id ? 'on' : ''} href={keep({ r: r.id })}>{r.name}</a>)}
236
236
  </div>
237
237
  </details>
@@ -247,6 +247,11 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
247
247
  <div class="bk-day-hours">{Array.from({ length: H1 - H0 }, (_, i) => <span>{f.time.format(dayStart + (H0 + i) * 3_600_000).replace(':00', '')}</span>)}</div>
248
248
  {dayCols.map((c) => (
249
249
  <div class="bk-day-col">
250
+ {dayHolds.filter((h) => h.resourceId === c.r.id).map((h) => (
251
+ <a class="bk-blk is-hold" style={pos({ start: h.start, end: h.end } as Booking)} href={`/admin/calendar/events/${h.eventId}`} title={`Held for ${h.title}`}>
252
+ <b>Held · {h.title}</b><small>{f.time.format(h.eventStart)} – {f.time.format(h.eventEnd)}{h.start < h.eventStart || h.end > h.eventEnd ? ' · incl. setup/cleanup' : ''}</small>
253
+ </a>
254
+ ))}
250
255
  {c.items.map((b) => (
251
256
  <a class={`bk-blk is-${b.kind === 'block' ? 'block' : b.status}`} style={pos(b)} href={keep({ b: b.id })}>
252
257
  <b>{titleOf(b)}</b><small>{whenOf(b)}</small>
@@ -260,7 +265,7 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
260
265
  <>
261
266
  {pending.length > 0 && (
262
267
  <section class="card bk-group">
263
- <div class="bk-dayh"><b>Needs a decision</b><span>{pending.length}</span></div>
268
+ <div class="bk-dayh"><b>Needs approval</b><span>{pending.length}</span></div>
264
269
  {pending.map((b) => (
265
270
  <div class={`bk-row${selected?.id === b.id ? ' is-sel' : ''}`}>
266
271
  <a class="bk-when" href={keep({ b: b.id })}>{f.day.format(b.start)}<small>{whenOf(b)}</small></a>
@@ -354,10 +359,10 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
354
359
  </>
355
360
  )}
356
361
 
357
- <Modal id="bk-block" title="Block time">
362
+ <Modal id="bk-block" title="Block time" open={Astro.url.searchParams.has('block') && resources.length > 0}>
358
363
  <form method="post">
359
364
  <input type="hidden" name="op" value="block" />
360
- <label class="field"><span>Resource</span><select name="resourceId">{resources.map((r) => <option value={r.id} selected={r.id === resFilter}>{r.name}</option>)}</select></label>
365
+ <label class="field"><span>Room or person</span><select name="resourceId">{resources.map((r) => <option value={r.id} selected={r.id === resFilter}>{r.name}</option>)}</select></label>
361
366
  <div class="field-row three">
362
367
  <label class="field"><span>Date</span><input type="date" name="date" value={day} required /></label>
363
368
  <label class="field"><span>From</span><input type="time" name="start" value="09:00" /></label>
@@ -369,7 +374,7 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
369
374
  </form>
370
375
  </Modal>
371
376
 
372
- <Modal id="bk-add" title="Add booking">
377
+ <Modal id="bk-add" title="Add booking" open={Astro.url.searchParams.has('add') && activeLinks.length > 0}>
373
378
  <form method="post">
374
379
  <input type="hidden" name="op" value="add" />
375
380
  <label class="field"><span>Booking link</span><select name="linkId">{activeLinks.map((l) => <option value={l.id}>{resById.get(l.resourceId)?.name} · {l.name}</option>)}</select></label>
@@ -383,6 +388,7 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
383
388
  <label class="field"><span>Email</span><input type="email" name="email" required /></label>
384
389
  </div>
385
390
  <label class="field"><span>Phone <em class="hint">optional</em></span><input type="tel" name="phone" /></label>
391
+ <label class="check-row"><input type="checkbox" name="overHold" /> Book anyway if an event is holding the room</label>
386
392
  <div class="modal-foot"><button class="btn ghost" type="button" data-close>Cancel</button><button class="btn primary" type="submit">Add and confirm</button></div>
387
393
  <p class="hint modal-note">Staff bookings skip notice windows but can never overlap another booking.</p>
388
394
  </form>