@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.
- package/CHANGELOG.md +42 -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/components/import/ImportExport.tsx +4 -4
- package/src/components/import/runner.ts +20 -3
- 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/import/bundle.ts +2 -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/src/lib/sections.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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) =>
|
|
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
|
|
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', '
|
|
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
|
-
<
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
<
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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>
|
|
200
|
-
<a class="btn primary" href="/admin/calendar/resources?new=1">
|
|
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>
|
|
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
|
|
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
|
|
234
|
+
<a class={!resFilter ? 'on' : ''} href={keep({ r: null })}>All rooms & 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
|
|
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>
|
|
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>
|