@acms-subir/core 0.2.0 → 0.3.0
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 +78 -0
- package/cli/common.mjs +35 -0
- package/cli/legacy.mjs +317 -0
- package/cli/main.mjs +66 -0
- package/cli/palette.mjs +4 -0
- package/cli/preview.mjs +50 -0
- package/cli/remote.mjs +55 -0
- package/cli/themes.mjs +127 -0
- package/cli.mjs +2 -369
- package/deploy/Caddyfile +8 -0
- package/deploy/Dockerfile +34 -0
- package/deploy/compose.https.yml +21 -0
- package/deploy/compose.yml +46 -0
- package/docs/theme-authoring.md +103 -0
- package/email-runner.mjs +32 -0
- package/integration.mjs +182 -2
- package/ops-check.mjs +27 -0
- package/package.json +27 -5
- package/src/actions/index.ts +575 -47
- package/src/components/AiCleaner.tsx +291 -0
- package/src/components/AiSettings.tsx +575 -0
- package/src/components/CommandPalette.tsx +200 -0
- package/src/components/ContentList.astro +246 -0
- package/src/components/DesignAccess.tsx +182 -0
- package/src/components/DesignManager.tsx +418 -46
- package/src/components/EmailSettings.tsx +521 -0
- package/src/components/EventPage.astro +178 -0
- package/src/components/LegalPage.astro +27 -0
- package/src/components/LinksManager.tsx +114 -57
- package/src/components/NavManager.tsx +42 -43
- package/src/components/ThemeScript.astro +50 -0
- package/src/components/ThemeSections.astro +57 -0
- package/src/components/ThemeSectionsEditor.tsx +513 -0
- package/src/components/booking/BookingFlow.astro +150 -0
- package/src/components/booking/HoursEditor.astro +63 -0
- package/src/components/builder/PageBuilder.tsx +10 -2
- package/src/components/editor/EventPanel.tsx +401 -0
- package/src/components/editor/FormatDock.tsx +81 -0
- package/src/components/editor/LivePane.tsx +122 -0
- package/src/components/editor/MobileToolbar.tsx +93 -0
- package/src/components/editor/PostEditor.tsx +605 -223
- package/src/components/editor/PreviewOverlay.tsx +242 -43
- package/src/components/editor/extensions/calendar.tsx +51 -7
- package/src/components/editor/extensions/events.tsx +153 -0
- package/src/components/editor/extensions/slash.tsx +7 -1
- package/src/components/site/Pager.astro +24 -0
- package/src/components/site/PostCard.astro +64 -0
- package/src/components/site/TagLabel.astro +11 -0
- package/src/layouts/Admin.astro +289 -49
- package/src/layouts/Site.astro +324 -59
- package/src/lib/agent-api.ts +13 -0
- package/src/lib/agent-discovery.ts +40 -0
- package/src/lib/agent.ts +114 -0
- package/src/lib/ai-config.ts +58 -0
- package/src/lib/ai-schema.ts +22 -0
- package/src/lib/ai.ts +90 -0
- package/src/lib/api-keys.ts +65 -0
- package/src/lib/api.ts +52 -0
- package/src/lib/auth.ts +25 -5
- package/src/lib/backup/content.ts +36 -0
- package/src/lib/backup/unzip.ts +12 -1
- package/src/lib/backup/zip.ts +1 -1
- package/src/lib/booking/agent.ts +108 -0
- package/src/lib/booking/availability.ts +221 -0
- package/src/lib/booking/emails.ts +115 -0
- package/src/lib/booking/forms.ts +77 -0
- package/src/lib/booking/service.ts +277 -0
- package/src/lib/booking/view.ts +54 -0
- package/src/lib/calendar/agent-schema.ts +48 -0
- package/src/lib/calendar/agent.ts +136 -0
- package/src/lib/calendar/api.ts +81 -0
- package/src/lib/calendar/cards.ts +102 -0
- package/src/lib/calendar/config.ts +52 -0
- package/src/lib/calendar/data.ts +130 -0
- package/src/lib/calendar/events.ts +261 -0
- package/src/lib/calendar/feeds.ts +43 -0
- package/src/lib/calendar/hooks.ts +38 -0
- package/src/lib/calendar/http.ts +30 -0
- package/src/lib/calendar/ics-write.ts +120 -0
- package/src/lib/calendar/ics.ts +15 -5
- package/src/lib/calendar/import.ts +203 -0
- package/src/lib/calendar/render.ts +120 -37
- package/src/lib/calendar/rrule.ts +149 -0
- package/src/lib/calendar/seo.ts +48 -0
- package/src/lib/design-service.ts +58 -0
- package/src/lib/designDiff.ts +145 -0
- package/src/lib/designPreview.ts +110 -12
- package/src/lib/documents/cleanup.ts +31 -0
- package/src/lib/documents/extract.ts +154 -0
- package/src/lib/documents/vendor.d.ts +4 -0
- package/src/lib/drafts.ts +60 -6
- package/src/lib/dynamicBlocks.ts +40 -8
- package/src/lib/email/accounts.ts +55 -0
- package/src/lib/email/config.ts +86 -0
- package/src/lib/email/migration.ts +24 -0
- package/src/lib/email/providers.ts +89 -0
- package/src/lib/email/service.ts +78 -0
- package/src/lib/email/store.ts +167 -0
- package/src/lib/email/templates.ts +18 -0
- package/src/lib/email/types.ts +32 -0
- package/src/lib/email/webhooks.ts +87 -0
- package/src/lib/email.ts +15 -0
- package/src/lib/env.ts +1 -1
- package/src/lib/legal/index.ts +25 -0
- package/src/lib/mobile/bookings.ts +73 -0
- package/src/lib/mobile/calendar.ts +85 -0
- package/src/lib/mobile/content.ts +85 -0
- package/src/lib/mobile/http.ts +63 -0
- package/src/lib/mobile/route.ts +20 -0
- package/src/lib/ops.ts +8 -0
- package/src/lib/preview.ts +21 -1
- package/src/lib/repo/migrations.ts +217 -0
- package/src/lib/repo/sql.ts +853 -60
- package/src/lib/repo/types.ts +122 -5
- package/src/lib/scheduler.ts +25 -0
- package/src/lib/schema.ts +309 -0
- package/src/lib/search-i18n.ts +3 -0
- package/src/lib/socialIcons.ts +32 -0
- package/src/lib/tagColor.ts +11 -0
- package/src/lib/theme.ts +59 -40
- package/src/lib/themeTokens.ts +174 -0
- package/src/lib/umami.ts +108 -0
- package/src/lib/webhooks.ts +139 -0
- package/src/middleware.ts +36 -3
- package/src/pages/404.astro +23 -0
- package/src/pages/[...slug].astro +8 -5
- package/src/pages/account/[action].astro +53 -0
- package/src/pages/admin/ai.astro +12 -0
- package/src/pages/admin/c/[collection]/[id].astro +4 -1
- package/src/pages/admin/c/[collection]/index.astro +127 -92
- package/src/pages/admin/calendar/bookings.astro +400 -0
- package/src/pages/admin/calendar/calendars.astro +265 -0
- package/src/pages/admin/calendar/events/[id].astro +56 -0
- package/src/pages/admin/calendar/events/index.astro +408 -0
- package/src/pages/admin/calendar/index.ts +4 -0
- package/src/pages/admin/calendar/integrations.astro +221 -0
- package/src/pages/admin/calendar/resources/[id].astro +180 -0
- package/src/pages/admin/calendar/resources/index.astro +106 -0
- package/src/pages/admin/calendar/resources/link.astro +196 -0
- package/src/pages/admin/design.astro +10 -3
- package/src/pages/admin/email.astro +13 -0
- package/src/pages/admin/index.astro +232 -85
- package/src/pages/admin/links.astro +3 -3
- package/src/pages/admin/login.astro +14 -10
- package/src/pages/admin/navigation.astro +8 -2
- package/src/pages/admin/pages/[id].astro +4 -1
- package/src/pages/admin/pages/index.astro +41 -48
- package/src/pages/admin/settings.astro +418 -367
- package/src/pages/admin/tags.astro +4 -4
- package/src/pages/admin/users.astro +38 -28
- package/src/pages/api/agent/docs.ts +33 -0
- package/src/pages/api/agent/index.ts +21 -0
- package/src/pages/api/ai/clean.ts +13 -0
- package/src/pages/api/ai/draft.ts +14 -0
- package/src/pages/api/ai/settings.ts +53 -0
- package/src/pages/api/ai/test.ts +12 -0
- package/src/pages/api/backup.ts +8 -21
- package/src/pages/api/calendar/availability.ts +35 -0
- package/src/pages/api/calendar/bookings/[id].ts +49 -0
- package/src/pages/api/calendar/bookings/index.ts +73 -0
- package/src/pages/api/calendar/calendars.ts +35 -0
- package/src/pages/api/calendar/docs.ts +64 -0
- package/src/pages/api/calendar/events/[id].ts +63 -0
- package/src/pages/api/calendar/events/index.ts +112 -0
- package/src/pages/api/calendar/resources.ts +34 -0
- package/src/pages/api/design/capabilities.ts +10 -0
- package/src/pages/api/design/index.ts +58 -0
- package/src/pages/api/design/keys.ts +24 -0
- package/src/pages/api/design/media.ts +32 -0
- package/src/pages/api/email/run.ts +11 -0
- package/src/pages/api/email/settings.ts +61 -0
- package/src/pages/api/email/webhooks/[provider].ts +4 -0
- package/src/pages/api/mcp.ts +59 -0
- package/src/pages/api/media/upload.ts +2 -2
- package/src/pages/api/ops/status.ts +39 -0
- package/src/pages/api/restore.ts +27 -37
- package/src/pages/api/search.ts +18 -4
- package/src/pages/api/v1/availability.ts +7 -0
- package/src/pages/api/v1/bookings.ts +17 -0
- package/src/pages/api/v1/calendars.ts +7 -0
- package/src/pages/api/v1/content/[id].ts +7 -0
- package/src/pages/api/v1/content/index.ts +7 -0
- package/src/pages/api/v1/events.ts +7 -0
- package/src/pages/api/v1/resources.ts +7 -0
- package/src/pages/author/[slug].astro +43 -35
- package/src/pages/blog/[slug].astro +137 -66
- package/src/pages/blog/index.astro +95 -30
- package/src/pages/blog/tag/[slug].astro +45 -40
- package/src/pages/book/[resource]/[link].astro +70 -0
- package/src/pages/book/[resource]/index.astro +63 -0
- package/src/pages/book/[resource].ics.ts +38 -0
- package/src/pages/book/index.astro +47 -0
- package/src/pages/book/manage/[token].astro +108 -0
- package/src/pages/calendar/[slug].ics.ts +27 -0
- package/src/pages/calendar/feed.ics.ts +15 -0
- package/src/pages/calendar/index.astro +191 -0
- package/src/pages/embed/book/[resource]/[link].astro +33 -0
- package/src/pages/embed/calendar/[slug].astro +43 -0
- package/src/pages/events/[slug].astro +74 -0
- package/src/pages/events/[slug].ics.ts +25 -0
- package/src/pages/index.astro +6 -3
- package/src/pages/legal/privacy.astro +4 -0
- package/src/pages/legal/terms.astro +4 -0
- package/src/pages/links.astro +23 -3
- package/src/pages/llms.txt.ts +4 -0
- package/src/pages/preview/[type]/[id].astro +27 -6
- package/src/pages/search.astro +40 -20
- package/src/pages/sitemap.xml.ts +16 -1
- package/src/styles/admin.css +924 -105
- package/src/styles/cards.css +92 -74
- package/src/styles/editor.css +492 -114
- package/src/styles/email-settings.css +117 -0
- package/src/styles/sections.css +12 -0
- package/src/styles/site.css +938 -281
- package/src/styles/theme-editor.css +135 -0
- package/src/theme-sdk/contract.d.mts +619 -0
- package/src/theme-sdk/contract.mjs +80 -0
- package/src/theme-sdk/index.ts +15 -0
- package/src/theme-sdk/tokens.d.mts +148 -0
- package/src/theme-sdk/tokens.mjs +52 -0
- package/src/themes/editorial/templates/home.astro +4 -4
- package/src/themes/editorial/templates/post-minimal.astro +2 -2
- package/src/themes/editorial/theme.css +22 -37
- package/src/themes/editorial/theme.json +23 -3
- package/src/themes/field/theme.json +26 -9
- package/src/themes/ink/theme.json +30 -12
- package/src/themes/night/theme.css +4 -7
- package/src/themes/night/theme.json +27 -10
- package/src/virtual.d.ts +14 -0
- package/src/worker.ts +5 -1
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Admin from '../../../../layouts/Admin.astro';
|
|
3
|
+
import HoursEditor from '../../../../components/booking/HoursEditor.astro';
|
|
4
|
+
import { getRepo } from '../../../../lib/repo';
|
|
5
|
+
import { slugify } from '../../../../lib/schema';
|
|
6
|
+
import { bookingLinkUrl, bookingsEnabled, CALENDAR, resourceFeedUrl, resourceUrl } from '../../../../lib/calendar/config';
|
|
7
|
+
import { hoursFromForm, int, list, overridesFromForm } from '../../../../lib/booking/forms';
|
|
8
|
+
import { describeLink } from '../../../../lib/booking/availability';
|
|
9
|
+
import { isValidTimeZone } from '../../../../lib/calendar/events';
|
|
10
|
+
import { normalizeCalendarSrc } from '../../../../lib/calendar/src';
|
|
11
|
+
import { siteOrigin } from '../../../../lib/seo';
|
|
12
|
+
|
|
13
|
+
// One resource: details, weekly hours, date overrides, calendars that block
|
|
14
|
+
// time, who gets notified, the staff feed, and its booking links.
|
|
15
|
+
if (!bookingsEnabled()) return new Response(null, { status: 404 });
|
|
16
|
+
const repo = await getRepo(Astro.locals);
|
|
17
|
+
const id = Astro.params.id!;
|
|
18
|
+
let resource = await repo.getResource(id);
|
|
19
|
+
if (!resource) return new Response(null, { status: 404 });
|
|
20
|
+
const settings = await repo.getSettings();
|
|
21
|
+
const origin = siteOrigin(settings, Astro.url);
|
|
22
|
+
let notice = Astro.url.searchParams.has('created') ? 'Created. Set its hours, then share the booking link.' : '';
|
|
23
|
+
let error = '';
|
|
24
|
+
const token = () => btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(18)))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
25
|
+
|
|
26
|
+
if (Astro.request.method === 'POST') {
|
|
27
|
+
const form = await Astro.request.formData();
|
|
28
|
+
const op = String(form.get('op'));
|
|
29
|
+
if (op === 'save') {
|
|
30
|
+
const name = String(form.get('name') || '').trim().slice(0, 120) || resource.name;
|
|
31
|
+
const base = slugify(String(form.get('slug') || name));
|
|
32
|
+
let slug = base;
|
|
33
|
+
for (let n = 2; ; n++) { const hit = await repo.getResourceBySlug(slug); if (!hit || hit.id === id) break; slug = `${base}-${n}`; }
|
|
34
|
+
const tz = String(form.get('timezone') || '').trim();
|
|
35
|
+
const feeds = list(String(form.get('busyFeeds') || ''));
|
|
36
|
+
const busyFeeds = feeds.map((f) => normalizeCalendarSrc(f)).filter((f): f is string => Boolean(f));
|
|
37
|
+
if (busyFeeds.length < feeds.length) error = 'One of the calendar links isn’t an ICS / Google Calendar link, so it was skipped.';
|
|
38
|
+
const emails = list(String(form.get('notifyEmails') || '')).filter((e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e));
|
|
39
|
+
await repo.updateResource(id, {
|
|
40
|
+
name, slug, kind: String(form.get('kind') || resource.kind).slice(0, 40),
|
|
41
|
+
capacity: form.get('capacity') ? int(form.get('capacity'), 1, 100000, 1) : null,
|
|
42
|
+
description: String(form.get('description') || '').slice(0, 2000),
|
|
43
|
+
photo: /^(https?:\/\/|\/)/.test(String(form.get('photo') || '')) ? String(form.get('photo')) : '',
|
|
44
|
+
amenities: list(String(form.get('amenities') || '')).slice(0, 20),
|
|
45
|
+
timezone: tz && isValidTimeZone(tz) ? tz : '',
|
|
46
|
+
visibility: form.get('visibility') === 'unlisted' ? 'unlisted' : 'public',
|
|
47
|
+
showOnCalendar: Boolean(form.get('showOnCalendar')),
|
|
48
|
+
hours: hoursFromForm(form), overrides: overridesFromForm(form), busyFeeds, notifyEmails: emails,
|
|
49
|
+
});
|
|
50
|
+
notice = error ? '' : 'Saved.';
|
|
51
|
+
} else if (op === 'link') {
|
|
52
|
+
const name = String(form.get('linkName') || '').trim().slice(0, 120) || 'Booking';
|
|
53
|
+
const base = slugify(name);
|
|
54
|
+
let slug = base;
|
|
55
|
+
for (let n = 2; await repo.getBookingLinkBySlug(id, slug); n++) slug = `${base}-${n}`;
|
|
56
|
+
const l = await repo.createBookingLink({ resourceId: id, name, slug, autoConfirm: true });
|
|
57
|
+
return Astro.redirect(`/admin/calendar/resources/${id}/links/${l.id}`);
|
|
58
|
+
} else if (op === 'rotate') {
|
|
59
|
+
await repo.updateResource(id, { feedToken: token() });
|
|
60
|
+
notice = 'New staff feed link created — the old one stopped working.';
|
|
61
|
+
} else if (op === 'delete') {
|
|
62
|
+
await repo.deleteResource(id);
|
|
63
|
+
return Astro.redirect('/admin/calendar/resources');
|
|
64
|
+
}
|
|
65
|
+
resource = (await repo.getResource(id))!;
|
|
66
|
+
}
|
|
67
|
+
const links = await repo.listBookingLinks(id);
|
|
68
|
+
const staffFeed = origin + resourceFeedUrl(resource.slug, resource.feedToken);
|
|
69
|
+
const ovRows = [...resource.overrides, { date: '', endDate: '', ranges: [], label: '' }, { date: '', endDate: '', ranges: [], label: '' }];
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
<Admin title={resource.name} section="calendar:resources">
|
|
73
|
+
<div class="page-head">
|
|
74
|
+
<div>
|
|
75
|
+
<p class="sub"><a href="/admin/calendar/resources">Resources</a> ›</p>
|
|
76
|
+
<h1>{resource.name}</h1>
|
|
77
|
+
<p class="sub"><a href={resourceUrl(resource.slug)} target="_blank" rel="noopener">{origin.replace(/^https?:\/\//, '')}{resourceUrl(resource.slug)} ↗</a></p>
|
|
78
|
+
</div>
|
|
79
|
+
<div class="page-actions"><button class="btn primary" type="submit" form="res-form">Save</button></div>
|
|
80
|
+
</div>
|
|
81
|
+
{notice && <p class="notice" role="status">{notice}</p>}
|
|
82
|
+
{error && <p class="notice warn">{error}</p>}
|
|
83
|
+
|
|
84
|
+
<div class="res-grid">
|
|
85
|
+
<div>
|
|
86
|
+
<section class="card card-pad">
|
|
87
|
+
<div class="card-head"><b>Booking links</b><span class="hint">Each link is its own shareable page with its own rules</span></div>
|
|
88
|
+
<div class="res-linklist">
|
|
89
|
+
{links.map((l) => (
|
|
90
|
+
<a class="res-linkrow" href={`/admin/calendar/resources/${id}/links/${l.id}`}>
|
|
91
|
+
<span><b>{l.name}</b>{!l.active && <span class="pill lang">Off</span>}{l.visibility === 'unlisted' && <span class="pill lang">Unlisted</span>}</span>
|
|
92
|
+
<small>{describeLink(l)} · {l.mode === 'range' ? 'choose a length' : l.mode === 'day' ? 'whole days' : 'fixed length'} · <em class={l.autoConfirm ? 'auto' : 'manual'}>{l.autoConfirm ? 'Confirms automatically' : 'Needs approval'}</em></small>
|
|
93
|
+
<code>{bookingLinkUrl(resource.slug, l.slug)}</code>
|
|
94
|
+
</a>
|
|
95
|
+
))}
|
|
96
|
+
</div>
|
|
97
|
+
<form method="post" class="res-newlink">
|
|
98
|
+
<input type="hidden" name="op" value="link" />
|
|
99
|
+
<input type="text" name="linkName" placeholder="New link, e.g. “Event or reception”" required />
|
|
100
|
+
<button class="btn" type="submit">Add link</button>
|
|
101
|
+
</form>
|
|
102
|
+
</section>
|
|
103
|
+
|
|
104
|
+
<section class="card card-pad">
|
|
105
|
+
<div class="card-head"><b>Open hours</b><span class="hint">The outer limit for every link</span></div>
|
|
106
|
+
<HoursEditor hours={resource.hours} prefix="hours" form="res-form" note={`Times in ${resource.timezone || settings.timezone || 'UTC'}. Links can narrow these hours but never extend them.`} />
|
|
107
|
+
</section>
|
|
108
|
+
|
|
109
|
+
<section class="card card-pad">
|
|
110
|
+
<div class="card-head"><b>Date overrides</b><span class="hint">Holidays, closures, special hours</span></div>
|
|
111
|
+
<div class="ov-list">
|
|
112
|
+
{ovRows.map((o, i) => (
|
|
113
|
+
<div class="ov-row">
|
|
114
|
+
<input type="date" name={`ov_${i}_date`} value={o.date} form="res-form" aria-label="From" />
|
|
115
|
+
<input type="date" name={`ov_${i}_end`} value={o.endDate} form="res-form" aria-label="Until (optional)" />
|
|
116
|
+
<label class="ov-closed"><input type="checkbox" name={`ov_${i}_closed`} checked={Boolean(o.date) && !o.ranges.length} form="res-form" /> Closed</label>
|
|
117
|
+
<input type="time" name={`ov_${i}_s`} value={o.ranges[0]?.start ?? ''} form="res-form" aria-label="Opens" />
|
|
118
|
+
<input type="time" name={`ov_${i}_e`} value={o.ranges[0]?.end ?? ''} form="res-form" aria-label="Closes" />
|
|
119
|
+
<input type="text" name={`ov_${i}_label`} value={o.label} placeholder="Label" form="res-form" />
|
|
120
|
+
</div>
|
|
121
|
+
))}
|
|
122
|
+
</div>
|
|
123
|
+
<p class="hint">Leave a row empty to remove it. Two blank rows are always here for new dates.</p>
|
|
124
|
+
</section>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<form method="post" id="res-form" class="res-side">
|
|
128
|
+
<input type="hidden" name="op" value="save" />
|
|
129
|
+
<section class="card card-pad">
|
|
130
|
+
<div class="card-head"><b>Details</b></div>
|
|
131
|
+
<label class="field"><span>Name</span><input type="text" name="name" value={resource.name} required /></label>
|
|
132
|
+
<div class="field-row">
|
|
133
|
+
<label class="field"><span>Kind</span><input type="text" name="kind" list="res-kinds" value={resource.kind} /></label>
|
|
134
|
+
<label class="field"><span>Capacity</span><input type="number" name="capacity" min="1" value={resource.capacity ?? ''} /></label>
|
|
135
|
+
</div>
|
|
136
|
+
<datalist id="res-kinds">{CALENDAR.resourceKinds.map((k) => <option value={k} />)}</datalist>
|
|
137
|
+
<label class="field"><span>URL name</span><input type="text" class="mono" name="slug" value={resource.slug} /></label>
|
|
138
|
+
<label class="field"><span>Description</span><textarea name="description" rows="3">{resource.description}</textarea></label>
|
|
139
|
+
<label class="field"><span>Photo URL</span><input type="text" class="mono" name="photo" value={resource.photo} placeholder="/media/… or https://…" /></label>
|
|
140
|
+
<label class="field"><span>Amenities <em class="hint">comma-separated</em></span><input type="text" name="amenities" value={resource.amenities.join(', ')} placeholder="Kitchen, Projector, Accessible" /></label>
|
|
141
|
+
<label class="field"><span>Timezone</span><input type="text" name="timezone" value={resource.timezone} placeholder={`${settings.timezone || 'UTC'} (site default)`} /></label>
|
|
142
|
+
<label class="field">
|
|
143
|
+
<span>Listed</span>
|
|
144
|
+
<select name="visibility">
|
|
145
|
+
<option value="public" selected={resource.visibility === 'public'}>Public — listed on {CALENDAR.bookBase}</option>
|
|
146
|
+
<option value="unlisted" selected={resource.visibility === 'unlisted'}>Unlisted — only people with the link</option>
|
|
147
|
+
</select>
|
|
148
|
+
</label>
|
|
149
|
+
<label class="check-row"><input type="checkbox" name="showOnCalendar" checked={resource.showOnCalendar} /> Show confirmed bookings on the public calendar as “Reserved”</label>
|
|
150
|
+
</section>
|
|
151
|
+
<section class="card card-pad">
|
|
152
|
+
<div class="card-head"><b>Busy calendars</b></div>
|
|
153
|
+
<label class="field"><span>Block time from these calendars <em class="hint">one per line</em></span>
|
|
154
|
+
<textarea name="busyFeeds" rows="3" class="mono" placeholder="A Google Calendar secret iCal address, or any .ics link">{resource.busyFeeds.join('\n')}</textarea></label>
|
|
155
|
+
<p class="hint">Anything on these calendars can’t be booked — handy for a person’s own Google Calendar.</p>
|
|
156
|
+
</section>
|
|
157
|
+
<section class="card card-pad">
|
|
158
|
+
<div class="card-head"><b>Notifications</b></div>
|
|
159
|
+
<label class="field"><span>Email new bookings to <em class="hint">comma-separated</em></span><input type="text" name="notifyEmails" value={resource.notifyEmails.join(', ')} placeholder="office@example.org" /></label>
|
|
160
|
+
<span class="hint">Staff feed — subscribe in Google, Apple or Outlook (includes names):</span>
|
|
161
|
+
<div class="cal-feed"><input type="text" class="mono" readonly value={staffFeed} /><button class="btn sm" type="button" data-copy={staffFeed}>Copy</button><button class="btn sm ghost" type="submit" form="res-rotate">New link</button></div>
|
|
162
|
+
</section>
|
|
163
|
+
<button class="btn danger ghost" type="submit" form="res-delete">Delete resource</button>
|
|
164
|
+
</form>
|
|
165
|
+
</div>
|
|
166
|
+
<form method="post" id="res-rotate"><input type="hidden" name="op" value="rotate" /></form>
|
|
167
|
+
<form method="post" id="res-delete" data-confirm-submit={`Delete ${resource.name} and its booking links? Past bookings stay in the list.`}><input type="hidden" name="op" value="delete" /></form>
|
|
168
|
+
</Admin>
|
|
169
|
+
|
|
170
|
+
<script>
|
|
171
|
+
document.querySelectorAll<HTMLFormElement>('form[data-confirm-submit]').forEach((f) =>
|
|
172
|
+
f.addEventListener('submit', (e) => { if (!confirm(f.dataset.confirmSubmit)) e.preventDefault(); }));
|
|
173
|
+
document.addEventListener('click', async (e) => {
|
|
174
|
+
const b = e.target instanceof Element ? e.target.closest<HTMLButtonElement>('[data-copy]') : null;
|
|
175
|
+
if (!b) return;
|
|
176
|
+
await navigator.clipboard.writeText(b.dataset.copy ?? '');
|
|
177
|
+
b.textContent = 'Copied';
|
|
178
|
+
setTimeout(() => (b.textContent = 'Copy'), 1200);
|
|
179
|
+
});
|
|
180
|
+
</script>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Admin from '../../../../layouts/Admin.astro';
|
|
3
|
+
import Modal from '../../../../components/Modal.astro';
|
|
4
|
+
import { getRepo } from '../../../../lib/repo';
|
|
5
|
+
import { slugify } from '../../../../lib/schema';
|
|
6
|
+
import { bookingsEnabled, CALENDAR, resourceUrl } from '../../../../lib/calendar/config';
|
|
7
|
+
import { DEFAULT_HOURS, int } from '../../../../lib/booking/forms';
|
|
8
|
+
import { availabilityContext } from '../../../../lib/booking/service';
|
|
9
|
+
import { describeLink, nextOpenings } from '../../../../lib/booking/availability';
|
|
10
|
+
import { fmt } from '../../../../lib/booking/view';
|
|
11
|
+
|
|
12
|
+
// Everything people can book: rooms, people, vehicles, equipment. Each row
|
|
13
|
+
// shows its booking links and when it's next free.
|
|
14
|
+
if (!bookingsEnabled()) return new Response(null, { status: 404 });
|
|
15
|
+
const repo = await getRepo(Astro.locals);
|
|
16
|
+
const settings = await repo.getSettings();
|
|
17
|
+
|
|
18
|
+
if (Astro.request.method === 'POST') {
|
|
19
|
+
const form = await Astro.request.formData();
|
|
20
|
+
const op = String(form.get('op'));
|
|
21
|
+
if (op === 'create') {
|
|
22
|
+
const name = String(form.get('name') || '').trim().slice(0, 120) || 'Untitled';
|
|
23
|
+
const base = slugify(name);
|
|
24
|
+
let slug = base;
|
|
25
|
+
for (let n = 2; await repo.getResourceBySlug(slug); n++) slug = `${base}-${n}`;
|
|
26
|
+
const kind = String(form.get('kind') || CALENDAR.resourceKinds[0]).slice(0, 40);
|
|
27
|
+
const capacity = form.get('capacity') ? int(form.get('capacity'), 1, 100000, 1) : null;
|
|
28
|
+
const r = await repo.createResource({ name, slug, kind, capacity, hours: DEFAULT_HOURS, timezone: '' });
|
|
29
|
+
// every resource starts with one ready-to-share link that confirms itself
|
|
30
|
+
await repo.createBookingLink({ resourceId: r.id, name: kind === 'Person' ? 'Meeting' : 'Book', slug: 'book', duration: 60, step: 30, autoConfirm: true });
|
|
31
|
+
return Astro.redirect(`/admin/calendar/resources/${r.id}?created=1`);
|
|
32
|
+
}
|
|
33
|
+
if (op === 'move') {
|
|
34
|
+
const ids = (await repo.listResources()).map((r) => r.id);
|
|
35
|
+
const i = ids.indexOf(String(form.get('id')));
|
|
36
|
+
const j = form.get('dir') === 'up' ? i - 1 : i + 1;
|
|
37
|
+
if (i >= 0 && j >= 0 && j < ids.length) [ids[i], ids[j]] = [ids[j], ids[i]];
|
|
38
|
+
await repo.reorderResources(ids);
|
|
39
|
+
return Astro.redirect('/admin/calendar/resources');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const [resources, links] = await Promise.all([repo.listResources(), repo.listBookingLinks()]);
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
const rows = await Promise.all(resources.map(async (r) => {
|
|
46
|
+
const own = links.filter((l) => l.resourceId === r.id);
|
|
47
|
+
const first = own.find((l) => l.active);
|
|
48
|
+
const next = first ? nextOpenings(await availabilityContext(repo, r, first, settings, now), 1)[0] : undefined;
|
|
49
|
+
const f = fmt(settings, r.timezone || settings.timezone || 'UTC');
|
|
50
|
+
return { r, own, next: next ? `${f.day.format(next.start)} ${f.time.format(next.start)}` : '' };
|
|
51
|
+
}));
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
<Admin title="Resources" section="calendar:resources">
|
|
55
|
+
<div class="page-head">
|
|
56
|
+
<div>
|
|
57
|
+
<h1>Resources</h1>
|
|
58
|
+
<p class="sub">Rooms, people and things people can book · public list at <a href={CALENDAR.bookBase} target="_blank" rel="noopener">{CALENDAR.bookBase}</a></p>
|
|
59
|
+
</div>
|
|
60
|
+
<div class="page-actions"><button class="btn primary" type="button" data-modal="res-new">New resource</button></div>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<Modal id="res-new" title="New resource" open={Astro.url.searchParams.has('new')}>
|
|
64
|
+
<form method="post">
|
|
65
|
+
<input type="hidden" name="op" value="create" />
|
|
66
|
+
<label class="field"><span>Name</span><input type="text" name="name" placeholder="Fellowship Hall" required autofocus /></label>
|
|
67
|
+
<div class="field-row">
|
|
68
|
+
<label class="field"><span>Kind</span><select name="kind">{CALENDAR.resourceKinds.map((k) => <option>{k}</option>)}</select></label>
|
|
69
|
+
<label class="field"><span>Capacity <em class="hint">optional</em></span><input type="number" name="capacity" min="1" placeholder="120" /></label>
|
|
70
|
+
</div>
|
|
71
|
+
<div class="modal-foot"><button class="btn ghost" type="button" data-close>Cancel</button><button class="btn primary" type="submit">Create</button></div>
|
|
72
|
+
<p class="hint modal-note">Starts open weekdays 9–5 with one booking link that confirms automatically. Change both on the next screen.</p>
|
|
73
|
+
</form>
|
|
74
|
+
</Modal>
|
|
75
|
+
|
|
76
|
+
{rows.length === 0 ? (
|
|
77
|
+
<div class="empty-state">
|
|
78
|
+
<b>Nothing to book yet</b>
|
|
79
|
+
<p>Add a room, a person’s time, or a piece of equipment. Each gets its own booking page and links.</p>
|
|
80
|
+
<button class="btn primary" type="button" data-modal="res-new">New resource</button>
|
|
81
|
+
</div>
|
|
82
|
+
) : (
|
|
83
|
+
<div class="res-list">
|
|
84
|
+
{rows.map(({ r, own, next }, i) => (
|
|
85
|
+
<article class="res-row">
|
|
86
|
+
<a class="res-thumb" href={`/admin/calendar/resources/${r.id}`}>
|
|
87
|
+
{r.photo ? <img src={r.photo} alt="" /> : <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21V9l9-5 9 5v12M9 21v-6h6v6" /></svg>}
|
|
88
|
+
</a>
|
|
89
|
+
<a class="res-main" href={`/admin/calendar/resources/${r.id}`}>
|
|
90
|
+
<b>{r.name} <span class="pill lang">{r.kind}</span>{r.visibility === 'unlisted' && <span class="pill lang">Unlisted</span>}</b>
|
|
91
|
+
<small>{[r.capacity ? `Up to ${r.capacity}` : '', `${own.length} link${own.length === 1 ? '' : 's'}`, next ? `next free ${next}` : 'no openings soon'].filter(Boolean).join(' · ')}</small>
|
|
92
|
+
<span class="res-links">{own.map((l) => <span class={`res-link${l.active ? '' : ' is-off'}`}>{l.name} · {describeLink(l)} · <em class={l.autoConfirm ? 'auto' : 'manual'}>{l.autoConfirm ? 'auto-confirm' : 'approval'}</em></span>)}</span>
|
|
93
|
+
</a>
|
|
94
|
+
<div class="cal-actions">
|
|
95
|
+
<a class="icon-btn" href={resourceUrl(r.slug)} target="_blank" rel="noopener" title="View booking page" aria-label="View booking page"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5" /></svg></a>
|
|
96
|
+
<form method="post" class="cal-move">
|
|
97
|
+
<input type="hidden" name="op" value="move" /><input type="hidden" name="id" value={r.id} />
|
|
98
|
+
<button class="icon-btn" name="dir" value="up" disabled={i === 0} aria-label="Move up"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 15l6-6 6 6" /></svg></button>
|
|
99
|
+
<button class="icon-btn" name="dir" value="down" disabled={i === rows.length - 1} aria-label="Move down"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 9l6 6 6-6" /></svg></button>
|
|
100
|
+
</form>
|
|
101
|
+
</div>
|
|
102
|
+
</article>
|
|
103
|
+
))}
|
|
104
|
+
</div>
|
|
105
|
+
)}
|
|
106
|
+
</Admin>
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Admin from '../../../../layouts/Admin.astro';
|
|
3
|
+
import HoursEditor from '../../../../components/booking/HoursEditor.astro';
|
|
4
|
+
import { getRepo } from '../../../../lib/repo';
|
|
5
|
+
import { slugify, QUESTION_TYPES, type BookingMode } from '../../../../lib/schema';
|
|
6
|
+
import { bookingLinkUrl, bookingsEnabled } from '../../../../lib/calendar/config';
|
|
7
|
+
import { hoursFromForm, int, list, questionsFromForm } from '../../../../lib/booking/forms';
|
|
8
|
+
import { describeLink } from '../../../../lib/booking/availability';
|
|
9
|
+
import { siteOrigin } from '../../../../lib/seo';
|
|
10
|
+
|
|
11
|
+
// One booking link (mockup H): how people book, whether it confirms on its
|
|
12
|
+
// own, when it's open (within the resource's hours), and what we ask.
|
|
13
|
+
if (!bookingsEnabled()) return new Response(null, { status: 404 });
|
|
14
|
+
const repo = await getRepo(Astro.locals);
|
|
15
|
+
const resource = await repo.getResource(Astro.params.id!);
|
|
16
|
+
let link = resource ? await repo.getBookingLink(Astro.params.link!) : null;
|
|
17
|
+
if (!resource || !link || link.resourceId !== resource.id) return new Response(null, { status: 404 });
|
|
18
|
+
const settings = await repo.getSettings();
|
|
19
|
+
const origin = siteOrigin(settings, Astro.url);
|
|
20
|
+
let notice = '';
|
|
21
|
+
|
|
22
|
+
if (Astro.request.method === 'POST') {
|
|
23
|
+
const form = await Astro.request.formData();
|
|
24
|
+
const ops = form.getAll('op');
|
|
25
|
+
const op = String(ops[ops.length - 1]);
|
|
26
|
+
if (op === 'delete') {
|
|
27
|
+
await repo.deleteBookingLink(link.id);
|
|
28
|
+
return Astro.redirect(`/admin/calendar/resources/${resource.id}`);
|
|
29
|
+
}
|
|
30
|
+
const name = String(form.get('name') || '').trim().slice(0, 120) || link.name;
|
|
31
|
+
const base = slugify(String(form.get('slug') || name));
|
|
32
|
+
let slug = base;
|
|
33
|
+
for (let n = 2; ; n++) { const hit = await repo.getBookingLinkBySlug(resource.id, slug); if (!hit || hit.id === link.id) break; slug = `${base}-${n}`; }
|
|
34
|
+
const mode = (['slots', 'range', 'day'] as const).find((m) => m === form.get('mode')) ?? 'slots';
|
|
35
|
+
const lengths = list(String(form.get('lengths') || '')).map((x) => Math.round(Number(x) * 60)).filter((n) => n >= 15 && n <= 1440);
|
|
36
|
+
await repo.updateBookingLink(link.id, {
|
|
37
|
+
name, slug, mode: mode as BookingMode,
|
|
38
|
+
description: String(form.get('description') || '').slice(0, 1000),
|
|
39
|
+
duration: int(form.get('duration'), 5, 1440, 60),
|
|
40
|
+
lengths,
|
|
41
|
+
minDays: int(form.get('minDays'), 1, 60, 1),
|
|
42
|
+
maxDays: Math.max(int(form.get('minDays'), 1, 60, 1), int(form.get('maxDays'), 1, 60, 1)),
|
|
43
|
+
step: int(form.get('step'), 5, 240, 30),
|
|
44
|
+
bufferBefore: int(form.get('bufferBefore'), 0, 480, 0),
|
|
45
|
+
bufferAfter: int(form.get('bufferAfter'), 0, 480, 0),
|
|
46
|
+
minNotice: int(form.get('minNoticeH'), 0, 24 * 60, 2) * 60,
|
|
47
|
+
maxAdvance: int(form.get('maxAdvance'), 1, 730, 90),
|
|
48
|
+
perDayCap: int(form.get('perDayCap'), 0, 100, 0),
|
|
49
|
+
hours: form.get('customHours') ? hoursFromForm(form, 'lh') : null,
|
|
50
|
+
autoConfirm: Boolean(form.get('autoConfirm')),
|
|
51
|
+
visibility: form.get('visibility') === 'unlisted' ? 'unlisted' : 'public',
|
|
52
|
+
askPhone: Boolean(form.get('askPhone')),
|
|
53
|
+
questions: questionsFromForm(form),
|
|
54
|
+
confirmMessage: String(form.get('confirmMessage') || '').slice(0, 1000),
|
|
55
|
+
redirectUrl: /^https?:\/\//.test(String(form.get('redirectUrl') || '')) ? String(form.get('redirectUrl')) : '',
|
|
56
|
+
active: Boolean(form.get('active')),
|
|
57
|
+
});
|
|
58
|
+
link = (await repo.getBookingLink(link.id))!;
|
|
59
|
+
notice = 'Saved.';
|
|
60
|
+
}
|
|
61
|
+
const publicUrl = origin + bookingLinkUrl(resource.slug, link.slug);
|
|
62
|
+
const embed = `<iframe src="${origin}/embed/book/${resource.slug}/${link.slug}" style="width:100%;height:620px;border:0" title="${link.name}"></iframe>`;
|
|
63
|
+
const qRows = [...link.questions, ...Array.from({ length: 2 }, () => ({ id: '', label: '', type: 'text' as const, required: false, options: [] as string[], help: '' }))];
|
|
64
|
+
const hours = (link.hours ?? resource.hours);
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
<Admin title={link.name} section="calendar:resources">
|
|
68
|
+
<div class="page-head">
|
|
69
|
+
<div>
|
|
70
|
+
<p class="sub"><a href="/admin/calendar/resources">Resources</a> › <a href={`/admin/calendar/resources/${resource.id}`}>{resource.name}</a> ›</p>
|
|
71
|
+
<h1>{link.name}</h1>
|
|
72
|
+
<p class="sub"><a href={publicUrl} target="_blank" rel="noopener">{publicUrl.replace(/^https?:\/\//, '')} ↗</a> · {describeLink(link)}</p>
|
|
73
|
+
</div>
|
|
74
|
+
<div class="page-actions">
|
|
75
|
+
<a class="btn" href={publicUrl} target="_blank" rel="noopener">Preview</a>
|
|
76
|
+
<button class="btn primary" type="submit" form="link-form">Save</button>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
{notice && <p class="notice" role="status">{notice}</p>}
|
|
80
|
+
|
|
81
|
+
<form method="post" id="link-form" class="res-grid">
|
|
82
|
+
<input type="hidden" name="op" value="save" />
|
|
83
|
+
<div>
|
|
84
|
+
<section class={`card card-pad confirm-card${link.autoConfirm ? ' is-auto' : ''}`}>
|
|
85
|
+
<label class="confirm-switch">
|
|
86
|
+
<input type="checkbox" role="switch" class="sw" name="autoConfirm" checked={link.autoConfirm} />
|
|
87
|
+
<span><b>Confirm automatically</b><small>On: bookings are confirmed right away and show on the calendar. Off: they wait in Bookings → Unconfirmed until you approve them.</small></span>
|
|
88
|
+
</label>
|
|
89
|
+
</section>
|
|
90
|
+
|
|
91
|
+
<section class="card card-pad">
|
|
92
|
+
<div class="card-head"><b>How people book</b></div>
|
|
93
|
+
<div class="seg block mode-seg" role="radiogroup">
|
|
94
|
+
{[['slots', 'Fixed length'], ['range', 'Choose a length'], ['day', 'Whole days']].map(([v, l]) => (
|
|
95
|
+
<label class={link.mode === v ? 'on' : ''}><input type="radio" name="mode" value={v} checked={link.mode === v} /> {l}</label>
|
|
96
|
+
))}
|
|
97
|
+
</div>
|
|
98
|
+
<div class="field-row three mode-fields">
|
|
99
|
+
<label class="field" data-mode="slots"><span>Length (minutes)</span><input type="number" name="duration" min="5" max="1440" value={link.duration} /></label>
|
|
100
|
+
<label class="field" data-mode="range"><span>Lengths offered (hours)</span><input type="text" name="lengths" value={link.lengths.map((n) => n / 60).join(', ')} placeholder="4, 5, 6, 8" /></label>
|
|
101
|
+
<label class="field" data-mode="day"><span>Min days</span><input type="number" name="minDays" min="1" max="60" value={link.minDays} /></label>
|
|
102
|
+
<label class="field" data-mode="day"><span>Max days</span><input type="number" name="maxDays" min="1" max="60" value={link.maxDays} /></label>
|
|
103
|
+
<label class="field" data-mode="slots range"><span>Start every (minutes)</span><input type="number" name="step" min="5" max="240" value={link.step} /></label>
|
|
104
|
+
</div>
|
|
105
|
+
<div class="field-row three">
|
|
106
|
+
<label class="field"><span>Setup before (min)</span><input type="number" name="bufferBefore" min="0" max="480" value={link.bufferBefore} /></label>
|
|
107
|
+
<label class="field"><span>Cleanup after (min)</span><input type="number" name="bufferAfter" min="0" max="480" value={link.bufferAfter} /></label>
|
|
108
|
+
<label class="field"><span>Max per day <em class="hint">0 = no limit</em></span><input type="number" name="perDayCap" min="0" max="100" value={link.perDayCap} /></label>
|
|
109
|
+
<label class="field"><span>Book at least (hours ahead)</span><input type="number" name="minNoticeH" min="0" max="1440" value={Math.round(link.minNotice / 60)} /></label>
|
|
110
|
+
<label class="field"><span>Book up to (days ahead)</span><input type="number" name="maxAdvance" min="1" max="730" value={link.maxAdvance} /></label>
|
|
111
|
+
</div>
|
|
112
|
+
</section>
|
|
113
|
+
|
|
114
|
+
<section class="card card-pad">
|
|
115
|
+
<div class="card-head"><b>Hours for this link</b></div>
|
|
116
|
+
<label class="check-row"><input type="checkbox" name="customHours" checked={Boolean(link.hours)} data-toggle="lh-box" /> Narrow the {resource.name.toLowerCase()} hours for this link</label>
|
|
117
|
+
<div id="lh-box" hidden={!link.hours}>
|
|
118
|
+
<HoursEditor hours={hours} prefix="lh" bounds={resource.hours} note={`Green = this link, grey = when ${resource.name} is open. The link can only narrow those hours.`} />
|
|
119
|
+
</div>
|
|
120
|
+
{!link.hours && <p class="hint">Uses {resource.name}’s open hours.</p>}
|
|
121
|
+
</section>
|
|
122
|
+
|
|
123
|
+
<section class="card card-pad">
|
|
124
|
+
<div class="card-head"><b>Questions</b><span class="hint">Name and email are always asked</span></div>
|
|
125
|
+
<label class="check-row"><input type="checkbox" name="askPhone" checked={link.askPhone} /> Ask for a phone number</label>
|
|
126
|
+
<div class="q-list">
|
|
127
|
+
{qRows.map((q, i) => (
|
|
128
|
+
<div class={`q-row${q.label ? '' : ' is-blank'}`}>
|
|
129
|
+
<input type="hidden" name={`qq_${i}_id`} value={q.id} />
|
|
130
|
+
<input type="text" name={`qq_${i}_label`} value={q.label} placeholder={q.label ? '' : 'Add a question…'} aria-label="Question" />
|
|
131
|
+
<select name={`qq_${i}_type`} aria-label="Answer type">{QUESTION_TYPES.map((t) => <option value={t} selected={q.type === t}>{t}</option>)}</select>
|
|
132
|
+
<label class="q-req"><input type="checkbox" name={`qq_${i}_required`} checked={q.required} /> Required</label>
|
|
133
|
+
<input type="text" class="q-opts" name={`qq_${i}_options`} value={q.options.join(', ')} placeholder="Options, comma-separated (for select)" />
|
|
134
|
+
</div>
|
|
135
|
+
))}
|
|
136
|
+
</div>
|
|
137
|
+
<p class="hint">Clear a question’s text to remove it. Save to get two fresh blank rows.</p>
|
|
138
|
+
</section>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<div class="res-side">
|
|
142
|
+
<section class="card card-pad">
|
|
143
|
+
<div class="card-head"><b>Link</b></div>
|
|
144
|
+
<label class="field"><span>Name</span><input type="text" name="name" value={link.name} required /></label>
|
|
145
|
+
<label class="field"><span>URL name</span><input type="text" class="mono" name="slug" value={link.slug} /></label>
|
|
146
|
+
<label class="field"><span>Description</span><textarea name="description" rows="3" placeholder="What this booking is for">{link.description}</textarea></label>
|
|
147
|
+
<label class="field">
|
|
148
|
+
<span>Who can find it</span>
|
|
149
|
+
<select name="visibility">
|
|
150
|
+
<option value="public" selected={link.visibility === 'public'}>Listed on the {resource.name} page</option>
|
|
151
|
+
<option value="unlisted" selected={link.visibility === 'unlisted'}>Unlisted — share the link directly</option>
|
|
152
|
+
</select>
|
|
153
|
+
</label>
|
|
154
|
+
<label class="check-row"><input type="checkbox" name="active" checked={link.active} /> Taking bookings</label>
|
|
155
|
+
</section>
|
|
156
|
+
<section class="card card-pad">
|
|
157
|
+
<div class="card-head"><b>After booking</b></div>
|
|
158
|
+
<label class="field"><span>Confirmation message</span><textarea name="confirmMessage" rows="3" placeholder="Shown on the confirmation page and in the email">{link.confirmMessage}</textarea></label>
|
|
159
|
+
<label class="field"><span>Redirect to <em class="hint">optional</em></span><input type="text" class="mono" name="redirectUrl" value={link.redirectUrl} placeholder="https://…" /></label>
|
|
160
|
+
</section>
|
|
161
|
+
<section class="card card-pad">
|
|
162
|
+
<div class="card-head"><b>Share</b></div>
|
|
163
|
+
<div class="cal-feed"><input type="text" class="mono" readonly value={publicUrl} /><button class="btn sm" type="button" data-copy={publicUrl}>Copy</button></div>
|
|
164
|
+
<span class="hint" style="display:block;margin-top:10px">Embed on another site:</span>
|
|
165
|
+
<div class="cal-feed"><input type="text" class="mono" readonly value={embed} /><button class="btn sm" type="button" data-copy={embed}>Copy</button></div>
|
|
166
|
+
</section>
|
|
167
|
+
<button class="btn danger ghost" type="submit" name="op" value="delete" formnovalidate data-confirm="Delete this booking link? Existing bookings stay.">Delete link</button>
|
|
168
|
+
</div>
|
|
169
|
+
</form>
|
|
170
|
+
</Admin>
|
|
171
|
+
|
|
172
|
+
<script>
|
|
173
|
+
const form = document.getElementById('link-form') as HTMLFormElement;
|
|
174
|
+
const sync = () => {
|
|
175
|
+
const mode = (form.querySelector<HTMLInputElement>('input[name=mode]:checked')?.value) ?? 'slots';
|
|
176
|
+
form.querySelectorAll<HTMLElement>('.mode-seg label').forEach((l) => l.classList.toggle('on', l.querySelector('input')?.checked ?? false));
|
|
177
|
+
form.querySelectorAll<HTMLElement>('.mode-fields [data-mode]').forEach((el) => { el.hidden = !(el.dataset.mode ?? '').split(' ').includes(mode); });
|
|
178
|
+
const auto = form.querySelector<HTMLInputElement>('input[name=autoConfirm]');
|
|
179
|
+
form.querySelector('.confirm-card')?.classList.toggle('is-auto', Boolean(auto?.checked));
|
|
180
|
+
};
|
|
181
|
+
form.addEventListener('change', (e) => {
|
|
182
|
+
sync();
|
|
183
|
+
const t = e.target as HTMLInputElement;
|
|
184
|
+
if (t.dataset.toggle) { const box = document.getElementById(t.dataset.toggle); if (box) box.hidden = !t.checked; }
|
|
185
|
+
});
|
|
186
|
+
sync();
|
|
187
|
+
document.addEventListener('click', async (e) => {
|
|
188
|
+
const c = e.target instanceof Element ? e.target.closest<HTMLElement>('[data-confirm]') : null;
|
|
189
|
+
if (c && !confirm(c.dataset.confirm)) { e.preventDefault(); return; }
|
|
190
|
+
const b = e.target instanceof Element ? e.target.closest<HTMLButtonElement>('[data-copy]') : null;
|
|
191
|
+
if (!b) return;
|
|
192
|
+
await navigator.clipboard.writeText(b.dataset.copy ?? '');
|
|
193
|
+
b.textContent = 'Copied';
|
|
194
|
+
setTimeout(() => (b.textContent = 'Copy'), 1200);
|
|
195
|
+
});
|
|
196
|
+
</script>
|
|
@@ -3,7 +3,8 @@ import Admin from '../../layouts/Admin.astro';
|
|
|
3
3
|
import { getRepo } from '../../lib/repo';
|
|
4
4
|
import { env } from '../../lib/env';
|
|
5
5
|
import DesignManager from '../../components/DesignManager';
|
|
6
|
-
import { listThemes, FONT_PAIRINGS } from '../../lib/theme';
|
|
6
|
+
import { listThemes, FONT_PAIRINGS, getTheme } from '../../lib/theme';
|
|
7
|
+
import { diffDesign } from '../../lib/designDiff';
|
|
7
8
|
import { mergeDesignDraft } from '../../lib/drafts';
|
|
8
9
|
import { postUrl } from '../../lib/content';
|
|
9
10
|
// ImagePicker (logo upload) styles live in editor.css
|
|
@@ -15,7 +16,9 @@ const liveSettings = await repo.getSettings();
|
|
|
15
16
|
const settings = mergeDesignDraft(liveSettings);
|
|
16
17
|
const pendingDesign = Boolean(liveSettings.draftDesign);
|
|
17
18
|
// most recent published post powers the preview's "Post" tab
|
|
18
|
-
const [latestPost] = await repo.listPublishedPosts();
|
|
19
|
+
const [[latestPost], liveNav] = await Promise.all([repo.listPublishedPosts(), repo.listNavItems()]);
|
|
20
|
+
// "What changes": the staged draft vs the live design
|
|
21
|
+
const changes = diffDesign(liveSettings, liveNav, liveSettings.draftDesign, (slug) => getTheme(slug).name);
|
|
19
22
|
const postPreviewPath = latestPost ? postUrl(latestPost.slug) : null;
|
|
20
23
|
// serialize to plain data for the island — no component references
|
|
21
24
|
const themes = listThemes().map((t) => ({
|
|
@@ -25,6 +28,9 @@ const themes = listThemes().map((t) => ({
|
|
|
25
28
|
tokens: t.tokens,
|
|
26
29
|
post: t.post,
|
|
27
30
|
hasHome: Boolean(t.HomeTemplate),
|
|
31
|
+
version: t.version,
|
|
32
|
+
templateCount: Number(Boolean(t.HomeTemplate)) + Number(Boolean(t.EventTemplate)) + Object.keys(t.postTemplates).length + Object.keys(t.pageTemplates).length,
|
|
33
|
+
sectionCount: Object.keys(t.sections).length,
|
|
28
34
|
}));
|
|
29
35
|
const unsplashEnabled = Boolean(env('UNSPLASH_ACCESS_KEY'));
|
|
30
36
|
---
|
|
@@ -37,11 +43,12 @@ const unsplashEnabled = Boolean(env('UNSPLASH_ACCESS_KEY'));
|
|
|
37
43
|
|
|
38
44
|
<DesignManager
|
|
39
45
|
client:only="react"
|
|
40
|
-
settings={{ ...settings, draftsApiKey: '' }}
|
|
46
|
+
settings={{ ...settings, draftsApiKey: '', ai: { ...settings.ai, encryptedKey: '' }, agent: { ...settings.agent, keyHash: '' } }}
|
|
41
47
|
pendingDesign={pendingDesign}
|
|
42
48
|
themes={themes}
|
|
43
49
|
pairings={[...FONT_PAIRINGS]}
|
|
44
50
|
unsplashEnabled={unsplashEnabled}
|
|
45
51
|
postPreviewPath={postPreviewPath}
|
|
52
|
+
changes={changes}
|
|
46
53
|
/>
|
|
47
54
|
</Admin>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Admin from '../../layouts/Admin.astro';
|
|
3
|
+
import EmailSettings from '../../components/EmailSettings';
|
|
4
|
+
import { getRepo } from '../../lib/repo';
|
|
5
|
+
import { requireSession } from '../../lib/api';
|
|
6
|
+
const repo=await getRepo(Astro.locals);
|
|
7
|
+
try { await requireSession(Astro.request,Astro.cookies,repo,true); }
|
|
8
|
+
catch { return new Response('Only owners can manage email.',{status:403}); }
|
|
9
|
+
---
|
|
10
|
+
<Admin title="Email" section="settings">
|
|
11
|
+
<div class="page-head"><h1>Email</h1><a class="btn ghost" href="/admin/settings">Site settings</a></div>
|
|
12
|
+
<EmailSettings client:load />
|
|
13
|
+
</Admin>
|