@acms-subir/core 0.3.3 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/cli/preview.mjs +8 -4
- package/cli/sections.mjs +4 -2
- package/docs/mobile.md +1 -1
- package/docs/theme-authoring.md +19 -3
- package/integration.mjs +21 -4
- package/package.json +14 -12
- package/src/actions/index.ts +186 -3
- package/src/components/booking/BookingFlow.astro +6 -3
- package/src/components/calendar/BookingsHead.astro +51 -0
- package/src/components/calendar/CalendarBoard.tsx +618 -0
- package/src/components/calendar/MiniMonth.tsx +54 -0
- package/src/components/calendar/NewEventDialog.tsx +44 -0
- package/src/components/calendar/QuickAdd.tsx +263 -0
- package/src/components/calendar/RoomHolds.tsx +203 -0
- package/src/components/calendar/WhereItLands.tsx +51 -0
- package/src/components/editor/EventPanel.tsx +23 -2
- package/src/components/editor/PostEditor.tsx +32 -4
- package/src/layouts/Admin.astro +5 -6
- package/src/layouts/Site.astro +29 -2
- package/src/lib/backup/content.ts +6 -1
- package/src/lib/booking/agent.ts +3 -2
- package/src/lib/booking/approval.ts +70 -0
- package/src/lib/booking/holds.ts +120 -0
- package/src/lib/booking/service.ts +37 -3
- package/src/lib/calendar/agent-schema.ts +9 -1
- package/src/lib/calendar/agent.ts +16 -1
- package/src/lib/calendar/board.ts +171 -0
- package/src/lib/calendar/config.ts +3 -0
- package/src/lib/calendar/grid.ts +169 -0
- package/src/lib/calendar/migration.ts +16 -0
- package/src/lib/calendar/render.ts +9 -7
- package/src/lib/calendar/schedule.ts +166 -0
- package/src/lib/content-service.ts +5 -0
- package/src/lib/drafts.ts +1 -1
- package/src/lib/mobile/calendar.ts +10 -6
- package/src/lib/mobile/http.ts +6 -0
- package/src/lib/repo/migrations.ts +3 -0
- package/src/lib/repo/sql.ts +28 -16
- package/src/lib/repo/types.ts +2 -2
- package/src/lib/scheduler.ts +3 -1
- package/src/lib/schema.ts +49 -0
- package/src/lib/sections.ts +33 -8
- package/src/lib/site-chrome.ts +34 -0
- package/src/lib/theme-assets.ts +16 -0
- package/src/lib/theme.ts +17 -3
- package/src/middleware.ts +6 -2
- package/src/pages/admin/calendar/bookings.astro +32 -26
- package/src/pages/admin/calendar/calendars.astro +4 -263
- package/src/pages/admin/calendar/events/index.astro +41 -83
- package/src/pages/admin/calendar/index.astro +43 -0
- package/src/pages/admin/calendar/resources/[id].astro +27 -5
- package/src/pages/admin/calendar/resources/index.astro +16 -17
- package/src/pages/admin/calendar/resources/link.astro +52 -12
- package/src/pages/admin/calendar/settings.astro +458 -0
- package/src/pages/admin/integrations.astro +5 -11
- package/src/pages/book/[resource].ics.ts +7 -1
- package/src/styles/admin.css +48 -0
- package/src/styles/cards.css +5 -1
- package/src/styles/editor.css +36 -0
- package/src/styles/schedule.css +325 -0
- package/src/styles/sections.css +19 -16
- package/src/theme-sdk/fields.mjs +5 -1
- package/src/theme-sdk/index.ts +5 -1
- package/src/virtual.d.ts +3 -0
- package/src/pages/admin/calendar/index.ts +0 -4
|
@@ -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>
|
|
@@ -1,265 +1,6 @@
|
|
|
1
1
|
---
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
import { CALENDAR, calendarFeedUrl, calendarPageUrl } from '../../../lib/calendar/config';
|
|
7
|
-
import { safeColor } from '../../../lib/calendar/events';
|
|
8
|
-
import { normalizeCalendarSrc } from '../../../lib/calendar/src';
|
|
9
|
-
import { loadFeed } from '../../../lib/calendar/ics';
|
|
10
|
-
import { siteOrigin } from '../../../lib/seo';
|
|
11
|
-
|
|
12
|
-
// Calendars: create/edit native calendars and live feed subscriptions, set
|
|
13
|
-
// who can see each one, and copy the subscribe links people paste into
|
|
14
|
-
// Google Calendar, Apple Calendar or Outlook.
|
|
15
|
-
if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
16
|
-
|
|
17
|
-
const repo = await getRepo(Astro.locals);
|
|
18
|
-
const settings = await repo.getSettings();
|
|
19
|
-
const origin = siteOrigin(settings, Astro.url);
|
|
20
|
-
const back = (notice?: string) => Astro.redirect(`/admin/calendar/calendars${notice ? `?notice=${encodeURIComponent(notice)}` : ''}`);
|
|
21
|
-
const VIS: CalendarVisibility[] = ['public', 'unlisted', 'private'];
|
|
22
|
-
const token = () => {
|
|
23
|
-
const bytes = crypto.getRandomValues(new Uint8Array(18));
|
|
24
|
-
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
25
|
-
};
|
|
26
|
-
async function freeSlug(raw: string, selfId?: string) {
|
|
27
|
-
const base = slugify(raw);
|
|
28
|
-
let slug = base;
|
|
29
|
-
for (let n = 2; ; n++) {
|
|
30
|
-
const hit = await repo.getCalendarBySlug(slug);
|
|
31
|
-
if (!hit || hit.id === selfId) return slug;
|
|
32
|
-
slug = `${base}-${n}`;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
if (Astro.request.method === 'POST') {
|
|
37
|
-
const form = await Astro.request.formData();
|
|
38
|
-
// the edit modal's Delete button overrides the hidden op (it comes later
|
|
39
|
-
// in the form, so it's the last value)
|
|
40
|
-
const ops = form.getAll('op');
|
|
41
|
-
const op = String(ops[ops.length - 1]);
|
|
42
|
-
const id = String(form.get('id') || '');
|
|
43
|
-
const name = String(form.get('name') || '').trim().slice(0, 120);
|
|
44
|
-
const visibility = VIS.find((v) => v === form.get('visibility')) ?? 'public';
|
|
45
|
-
const color = safeColor(String(form.get('color') || '#4f46e5'));
|
|
46
|
-
const description = String(form.get('description') || '').slice(0, 1000);
|
|
47
|
-
|
|
48
|
-
if (op === 'create' || op === 'subscribe') {
|
|
49
|
-
if (!name) return back('Give the calendar a name.');
|
|
50
|
-
let feedUrl = '';
|
|
51
|
-
if (op === 'subscribe') {
|
|
52
|
-
feedUrl = normalizeCalendarSrc(String(form.get('feedUrl') || '')) ?? '';
|
|
53
|
-
if (!feedUrl) return back('Paste a Google Calendar link, a calendar ID, or an .ics feed URL.');
|
|
54
|
-
try { await loadFeed(feedUrl, Date.now()); } catch (err) { return back(err instanceof Error ? err.message : 'Couldn’t load that feed.'); }
|
|
55
|
-
}
|
|
56
|
-
await repo.createCalendar({ name, slug: await freeSlug(String(form.get('slug') || name)), color, description, visibility, kind: op === 'subscribe' ? 'feed' : 'native', feedUrl });
|
|
57
|
-
return back(`Created “${name}”.`);
|
|
58
|
-
}
|
|
59
|
-
const cal = id ? await repo.getCalendar(id) : null;
|
|
60
|
-
if (!cal) return back('That calendar no longer exists.');
|
|
61
|
-
if (op === 'update') {
|
|
62
|
-
let feedUrl = cal.feedUrl;
|
|
63
|
-
if (cal.kind === 'feed' && form.has('feedUrl') && String(form.get('feedUrl')) !== cal.feedUrl) {
|
|
64
|
-
feedUrl = normalizeCalendarSrc(String(form.get('feedUrl'))) ?? '';
|
|
65
|
-
if (!feedUrl) return back('Paste a Google Calendar link, a calendar ID, or an .ics feed URL.');
|
|
66
|
-
}
|
|
67
|
-
await repo.updateCalendar(cal.id, {
|
|
68
|
-
name: name || cal.name, slug: await freeSlug(String(form.get('slug') || name || cal.slug), cal.id),
|
|
69
|
-
color, description, visibility, feedUrl,
|
|
70
|
-
});
|
|
71
|
-
return back(`Saved “${name || cal.name}”.`);
|
|
72
|
-
}
|
|
73
|
-
if (op === 'rotate') {
|
|
74
|
-
await repo.updateCalendar(cal.id, { feedToken: token() });
|
|
75
|
-
return back('New private link created — the old one stopped working.');
|
|
76
|
-
}
|
|
77
|
-
if (op === 'move') {
|
|
78
|
-
const ids = (await repo.listCalendars()).map((c) => c.id);
|
|
79
|
-
const i = ids.indexOf(cal.id);
|
|
80
|
-
const j = form.get('dir') === 'up' ? i - 1 : i + 1;
|
|
81
|
-
if (j >= 0 && j < ids.length) [ids[i], ids[j]] = [ids[j], ids[i]];
|
|
82
|
-
await repo.reorderCalendars(ids);
|
|
83
|
-
return back();
|
|
84
|
-
}
|
|
85
|
-
if (op === 'delete') {
|
|
86
|
-
await repo.deleteCalendar(cal.id);
|
|
87
|
-
return back(`Deleted “${cal.name}”. Its events are still there, just unfiled.`);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const [calendars, events] = await Promise.all([repo.listCalendars(), repo.listEvents()]);
|
|
92
|
-
const counts = new Map<string, number>();
|
|
93
|
-
for (const e of events) for (const id of e.calendarIds) counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
94
|
-
const notice = Astro.url.searchParams.get('notice') ?? '';
|
|
95
|
-
const feedFor = (c: (typeof calendars)[number]) =>
|
|
96
|
-
origin + calendarFeedUrl(c.slug) + (c.visibility === 'private' ? `?t=${encodeURIComponent(c.feedToken)}` : '');
|
|
97
|
-
const hostOf = (u: string) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return u; } };
|
|
98
|
-
const VIS_LABEL: Record<CalendarVisibility, string> = {
|
|
99
|
-
public: 'Public — listed on the calendar page',
|
|
100
|
-
unlisted: 'Unlisted — event pages and feed work, not listed',
|
|
101
|
-
private: 'Private — admins and the secret feed link only',
|
|
102
|
-
};
|
|
103
|
-
const I = {
|
|
104
|
-
up: 'M6 15l6-6 6 6', down: 'M6 9l6 6 6-6', copy: 'M9 9h10v10H9zM5 15V5h10',
|
|
105
|
-
ext: 'M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5',
|
|
106
|
-
};
|
|
2
|
+
// The Calendars page moved into Calendar settings (⚙ on Schedule).
|
|
3
|
+
// Kept as a redirect so bookmarks, emails and old links keep working.
|
|
4
|
+
const hash = Astro.url.searchParams.has('subscribe') ? '?subscribe=1#subscribed' : '#calendars';
|
|
5
|
+
return Astro.redirect(`/admin/calendar/settings${hash}`, 301);
|
|
107
6
|
---
|
|
108
|
-
|
|
109
|
-
<Admin title="Calendars" section="calendar:calendars">
|
|
110
|
-
<div class="page-head">
|
|
111
|
-
<div>
|
|
112
|
-
<h1>Calendars</h1>
|
|
113
|
-
<p class="sub">Group events, choose who sees them, and share subscribe links for Google, Apple and Outlook.</p>
|
|
114
|
-
</div>
|
|
115
|
-
<div class="page-actions">
|
|
116
|
-
<button class="btn" type="button" data-modal="cal-subscribe">Subscribe to a feed</button>
|
|
117
|
-
<button class="btn primary" type="button" data-modal="cal-new">New calendar</button>
|
|
118
|
-
</div>
|
|
119
|
-
</div>
|
|
120
|
-
|
|
121
|
-
{notice && <p class="notice" role="status">{notice}</p>}
|
|
122
|
-
|
|
123
|
-
<section class="card cal-allfeed">
|
|
124
|
-
<div>
|
|
125
|
-
<b>Everything public</b>
|
|
126
|
-
<small>One link with every public calendar — paste it into any calendar app.</small>
|
|
127
|
-
</div>
|
|
128
|
-
<div class="cal-feed">
|
|
129
|
-
<input type="text" class="mono" readonly value={origin + calendarFeedUrl()} aria-label="Subscribe link for all public calendars" />
|
|
130
|
-
<button class="btn sm" type="button" data-copy={origin + calendarFeedUrl()}>Copy</button>
|
|
131
|
-
</div>
|
|
132
|
-
</section>
|
|
133
|
-
|
|
134
|
-
{calendars.length === 0 ? (
|
|
135
|
-
<div class="empty-state">
|
|
136
|
-
<b>No calendars yet</b>
|
|
137
|
-
<p>Events work without calendars. Add some to color-code events, filter the calendar page, and give each group its own subscribe link.</p>
|
|
138
|
-
<div class="empty-actions">
|
|
139
|
-
<button class="btn" type="button" data-modal="cal-subscribe">Subscribe to a feed</button>
|
|
140
|
-
<button class="btn primary" type="button" data-modal="cal-new">New calendar</button>
|
|
141
|
-
</div>
|
|
142
|
-
</div>
|
|
143
|
-
) : (
|
|
144
|
-
<div class="cal-list">
|
|
145
|
-
{calendars.map((c, i) => (
|
|
146
|
-
<article class="cal-row" id={`cal-${c.id}`}>
|
|
147
|
-
<i class="cal-dot lg" style={`background:${safeColor(c.color)}`} aria-hidden="true" />
|
|
148
|
-
<div class="cal-main">
|
|
149
|
-
<div class="cal-title">
|
|
150
|
-
<b>{c.name}</b>
|
|
151
|
-
{c.kind === 'feed' && <span class="pill lang">Feed · {hostOf(c.feedUrl)}</span>}
|
|
152
|
-
<span class={`cal-vis is-${c.visibility}`}>{c.visibility[0].toUpperCase() + c.visibility.slice(1)}</span>
|
|
153
|
-
</div>
|
|
154
|
-
<small>
|
|
155
|
-
{c.kind === 'feed' ? 'Live mirror, refreshed every few minutes · read-only' : `${counts.get(c.id) ?? 0} event${counts.get(c.id) === 1 ? '' : 's'}`}
|
|
156
|
-
{c.description && ` · ${c.description}`}
|
|
157
|
-
</small>
|
|
158
|
-
{c.visibility !== 'private' || c.feedToken ? (
|
|
159
|
-
<div class="cal-feed">
|
|
160
|
-
<input type="text" class="mono" readonly value={feedFor(c)} aria-label={`Subscribe link for ${c.name}`} />
|
|
161
|
-
<button class="btn sm" type="button" data-copy={feedFor(c)}>Copy</button>
|
|
162
|
-
{c.visibility === 'private' && (
|
|
163
|
-
<form method="post">
|
|
164
|
-
<input type="hidden" name="op" value="rotate" /><input type="hidden" name="id" value={c.id} />
|
|
165
|
-
<button class="btn sm ghost" type="submit" data-confirm="Make a new private link? Anyone subscribed with the old one stops getting updates.">New link</button>
|
|
166
|
-
</form>
|
|
167
|
-
)}
|
|
168
|
-
</div>
|
|
169
|
-
) : null}
|
|
170
|
-
</div>
|
|
171
|
-
<div class="cal-actions">
|
|
172
|
-
{c.visibility !== 'private' && (
|
|
173
|
-
<a class="icon-btn" href={calendarPageUrl(c.slug)} target="_blank" rel="noopener" title="View on site" aria-label="View on site">
|
|
174
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={I.ext} /></svg>
|
|
175
|
-
</a>
|
|
176
|
-
)}
|
|
177
|
-
<form method="post" class="cal-move">
|
|
178
|
-
<input type="hidden" name="op" value="move" /><input type="hidden" name="id" value={c.id} />
|
|
179
|
-
<button class="icon-btn" name="dir" value="up" disabled={i === 0} title="Move up" aria-label="Move up"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d={I.up} /></svg></button>
|
|
180
|
-
<button class="icon-btn" name="dir" value="down" disabled={i === calendars.length - 1} title="Move down" aria-label="Move down"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d={I.down} /></svg></button>
|
|
181
|
-
</form>
|
|
182
|
-
<button class="btn sm" type="button" data-modal={`cal-edit-${c.id}`}>Edit</button>
|
|
183
|
-
</div>
|
|
184
|
-
<Modal id={`cal-edit-${c.id}`} title={`Edit ${c.name}`}>
|
|
185
|
-
<form method="post">
|
|
186
|
-
<input type="hidden" name="op" value="update" /><input type="hidden" name="id" value={c.id} />
|
|
187
|
-
<div class="field-row">
|
|
188
|
-
<label class="field"><span>Name</span><input type="text" name="name" value={c.name} required /></label>
|
|
189
|
-
<label class="field"><span>Color</span><input type="color" name="color" value={safeColor(c.color)} /></label>
|
|
190
|
-
</div>
|
|
191
|
-
<label class="field"><span>URL name</span><input class="mono" type="text" name="slug" value={c.slug} /></label>
|
|
192
|
-
{c.kind === 'feed' && <label class="field"><span>Feed link</span><input class="mono" type="text" name="feedUrl" value={c.feedUrl} /></label>}
|
|
193
|
-
<label class="field"><span>Description</span><input type="text" name="description" value={c.description} placeholder="Shown on the calendar page filter" /></label>
|
|
194
|
-
<label class="field">
|
|
195
|
-
<span>Who can see it</span>
|
|
196
|
-
<select name="visibility">
|
|
197
|
-
{VIS.map((v) => <option value={v} selected={c.visibility === v}>{VIS_LABEL[v]}</option>)}
|
|
198
|
-
</select>
|
|
199
|
-
</label>
|
|
200
|
-
<div class="modal-foot">
|
|
201
|
-
<button class="btn danger ghost" type="submit" name="op" value="delete" formnovalidate data-confirm={`Delete “${c.name}”? Events stay, they just lose this calendar.`}>Delete</button>
|
|
202
|
-
<span class="spacer" />
|
|
203
|
-
<button class="btn ghost" type="button" data-close>Cancel</button>
|
|
204
|
-
<button class="btn primary" type="submit">Save</button>
|
|
205
|
-
</div>
|
|
206
|
-
</form>
|
|
207
|
-
</Modal>
|
|
208
|
-
</article>
|
|
209
|
-
))}
|
|
210
|
-
</div>
|
|
211
|
-
)}
|
|
212
|
-
|
|
213
|
-
<Modal id="cal-new" title="New calendar">
|
|
214
|
-
<form method="post">
|
|
215
|
-
<input type="hidden" name="op" value="create" />
|
|
216
|
-
<div class="field-row">
|
|
217
|
-
<label class="field"><span>Name</span><input type="text" name="name" placeholder="Youth" required autofocus /></label>
|
|
218
|
-
<label class="field"><span>Color</span><input type="color" name="color" value="#4f46e5" /></label>
|
|
219
|
-
</div>
|
|
220
|
-
<label class="field"><span>Description</span><input type="text" name="description" placeholder="Optional" /></label>
|
|
221
|
-
<label class="field">
|
|
222
|
-
<span>Who can see it</span>
|
|
223
|
-
<select name="visibility">{VIS.map((v) => <option value={v}>{VIS_LABEL[v]}</option>)}</select>
|
|
224
|
-
</label>
|
|
225
|
-
<div class="modal-foot">
|
|
226
|
-
<button class="btn ghost" type="button" data-close>Cancel</button>
|
|
227
|
-
<button class="btn primary" type="submit">Create calendar</button>
|
|
228
|
-
</div>
|
|
229
|
-
</form>
|
|
230
|
-
</Modal>
|
|
231
|
-
|
|
232
|
-
<Modal id="cal-subscribe" title="Subscribe to a feed">
|
|
233
|
-
<form method="post">
|
|
234
|
-
<input type="hidden" name="op" value="subscribe" />
|
|
235
|
-
<p class="hint">Mirror a Google, Apple or Outlook calendar. Its events show up read-only and stay in sync.</p>
|
|
236
|
-
<div class="field-row">
|
|
237
|
-
<label class="field"><span>Name</span><input type="text" name="name" placeholder="Staff calendar" required /></label>
|
|
238
|
-
<label class="field"><span>Color</span><input type="color" name="color" value="#2f9e6e" /></label>
|
|
239
|
-
</div>
|
|
240
|
-
<label class="field"><span>Calendar link</span><input class="mono" type="text" name="feedUrl" placeholder="Google Calendar link, calendar ID, or .ics URL" required /></label>
|
|
241
|
-
<label class="field">
|
|
242
|
-
<span>Who can see it</span>
|
|
243
|
-
<select name="visibility">{VIS.map((v) => <option value={v}>{VIS_LABEL[v]}</option>)}</select>
|
|
244
|
-
</label>
|
|
245
|
-
<div class="modal-foot">
|
|
246
|
-
<button class="btn ghost" type="button" data-close>Cancel</button>
|
|
247
|
-
<button class="btn primary" type="submit">Subscribe</button>
|
|
248
|
-
</div>
|
|
249
|
-
</form>
|
|
250
|
-
</Modal>
|
|
251
|
-
</Admin>
|
|
252
|
-
|
|
253
|
-
<script>
|
|
254
|
-
document.addEventListener('click', async (e) => {
|
|
255
|
-
const b = e.target instanceof Element ? e.target.closest<HTMLButtonElement>('[data-copy]') : null;
|
|
256
|
-
if (b) {
|
|
257
|
-
await navigator.clipboard.writeText(b.dataset.copy ?? '');
|
|
258
|
-
const label = b.textContent;
|
|
259
|
-
b.textContent = 'Copied';
|
|
260
|
-
setTimeout(() => (b.textContent = label), 1200);
|
|
261
|
-
}
|
|
262
|
-
const c = e.target instanceof Element ? e.target.closest<HTMLElement>('[data-confirm]') : null;
|
|
263
|
-
if (c && !confirm(c.dataset.confirm)) e.preventDefault();
|
|
264
|
-
});
|
|
265
|
-
</script>
|