@acms-subir/core 0.3.46 → 0.3.48
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 +47 -0
- package/cli/migrations.generated.mjs +42 -23
- package/docs/calendar.md +80 -0
- package/docs/server.md +28 -3
- package/docs/theme-authoring.md +1 -1
- package/package.json +1 -1
- package/src/actions/index.ts +76 -11
- package/src/components/EventPage.astro +18 -12
- package/src/components/calendar/CalendarBoard.tsx +43 -19
- package/src/components/editor/event/EventSheet.tsx +2 -0
- package/src/components/editor/event/SingleDates.tsx +123 -0
- package/src/lib/booking/holds.ts +7 -7
- package/src/lib/calendar/api.ts +11 -1
- package/src/lib/calendar/board.ts +7 -3
- package/src/lib/calendar/cards.ts +14 -11
- package/src/lib/calendar/change-migration.ts +40 -15
- package/src/lib/calendar/data.ts +34 -12
- package/src/lib/calendar/event-words.ts +133 -0
- package/src/lib/calendar/events.ts +94 -9
- package/src/lib/calendar/feeds.ts +11 -3
- package/src/lib/calendar/filter.ts +140 -0
- package/src/lib/calendar/ics-write.ts +25 -2
- package/src/lib/calendar/moves.ts +291 -0
- package/src/lib/calendar/render.ts +26 -18
- package/src/lib/calendar/schedule.ts +73 -61
- package/src/lib/email/booking-words.ts +7 -1
- package/src/lib/email/service.ts +6 -1
- package/src/lib/email/templates.ts +6 -2
- package/src/lib/mobile/calendar.ts +32 -14
- package/src/lib/mobile/http.ts +5 -3
- package/src/lib/mobile/schemas.ts +7 -3
- package/src/lib/push/service.ts +44 -10
- package/src/lib/push/types.ts +2 -0
- package/src/lib/repo/migrations.ts +5 -2
- package/src/lib/repo/sql.ts +28 -10
- package/src/lib/repo/types.ts +6 -3
- package/src/lib/schema.ts +21 -0
- package/src/lib/sections.ts +5 -2
- package/src/lib/sync/catalog.ts +1 -0
- package/src/pages/api/calendar/events/index.ts +24 -12
- package/src/pages/calendar/[slug].ics.ts +8 -2
- package/src/pages/calendar/feed.ics.ts +9 -2
- package/src/pages/calendar/index.astro +104 -48
- package/src/pages/embed/calendar/[slug].astro +29 -9
- package/src/pages/members/_Layout.astro +1 -0
- package/src/pages/members/events/index.astro +1 -1
- package/src/server/index.ts +33 -0
- package/src/styles/editor.css +19 -0
- package/src/styles/schedule.css +2 -0
- package/src/styles/site.css +16 -0
- package/src/theme-sdk/calendar.ts +3 -2
|
@@ -1,36 +1,56 @@
|
|
|
1
1
|
---
|
|
2
2
|
import Site from '../../../layouts/Site.astro';
|
|
3
3
|
import { getRepo } from '../../../lib/repo';
|
|
4
|
-
import {
|
|
4
|
+
import { siteLang } from '../../../lib/dates';
|
|
5
|
+
import { I18N, localePath } from '../../../lib/i18n';
|
|
6
|
+
import { isExtraLocale } from '../../../lib/translations';
|
|
7
|
+
import { eventStrings } from '../../../lib/calendar/event-words';
|
|
8
|
+
import { CALENDAR, eventUrl } from '../../../lib/calendar/config';
|
|
5
9
|
import { calendarWindow, renderCalendarView } from '../../../lib/calendar/render';
|
|
6
10
|
import { collectViewEvents } from '../../../lib/calendar/data';
|
|
7
11
|
import { upcomingEvents, eventCardHtml } from '../../../lib/calendar/cards';
|
|
12
|
+
import { readEventFilter, resolveEventFilter } from '../../../lib/calendar/filter';
|
|
8
13
|
|
|
9
14
|
// Embeddable calendar for other websites:
|
|
10
15
|
// <iframe src="https://site/embed/calendar/all?view=month" …>
|
|
11
16
|
// slug 'all' = every public calendar. view: month | week | list | cards.
|
|
12
|
-
//
|
|
13
|
-
//
|
|
17
|
+
// The /calendar page's filters work here too (?tag=Class, ?notag=,
|
|
18
|
+
// ?cal=a,b to add calendars, ?room=), and ?lang=es picks one of the site's
|
|
19
|
+
// languages (embeds are never under /es). Links open in the parent window;
|
|
20
|
+
// height is posted to the parent so a small script can auto-size the iframe.
|
|
14
21
|
if (!CALENDAR.enabled) return new Response(null, { status: 404 });
|
|
15
22
|
const repo = await getRepo(Astro.locals);
|
|
16
|
-
const
|
|
23
|
+
const base = await repo.getSettings();
|
|
17
24
|
const slug = Astro.params.slug ?? 'all';
|
|
18
25
|
const cal = slug === 'all' ? null : await repo.getCalendarBySlug(slug);
|
|
19
26
|
if (slug !== 'all' && (!cal || cal.visibility === 'private')) return new Response(null, { status: 404 });
|
|
27
|
+
const langParam = Astro.url.searchParams.get('lang') ?? '';
|
|
28
|
+
const reqLocale = I18N.enabled ? (I18N.locales.includes(langParam) ? langParam : I18N.defaultLocale) : '';
|
|
29
|
+
// the layout's <html lang> reads the request's language
|
|
30
|
+
if (reqLocale) (Astro.locals as { acmsLocale?: string }).acmsLocale = reqLocale;
|
|
31
|
+
const settings = isExtraLocale(reqLocale) ? { ...base, locale: reqLocale } : base;
|
|
32
|
+
const lang = reqLocale || siteLang(settings);
|
|
33
|
+
const w = eventStrings(lang).calendar;
|
|
34
|
+
|
|
35
|
+
const query = readEventFilter(Astro.url.searchParams);
|
|
36
|
+
const filter = cal ? { ...query, cal: [cal.slug, ...query.cal] } : query;
|
|
37
|
+
const [calendars, resources] = await Promise.all([repo.listCalendars(), filter.room.length ? repo.listResources() : []]);
|
|
38
|
+
const resolved = resolveEventFilter(filter, { calendars, resources });
|
|
20
39
|
const view = (['month', 'week', 'list', 'cards'] as const).find((v) => v === Astro.url.searchParams.get('view')) ?? 'month';
|
|
21
40
|
const now = Date.now();
|
|
22
41
|
let html: string;
|
|
23
42
|
if (view === 'cards') {
|
|
24
|
-
const items = await upcomingEvents(repo, settings, now, {
|
|
25
|
-
|
|
43
|
+
const items = await upcomingEvents(repo, settings, now, { filter: resolved, locale: lang, limit: Math.min(12, Number(Astro.url.searchParams.get('limit')) || 6) });
|
|
44
|
+
const cards = items.map((o) => eventCardHtml(o, settings, { showImage: true, href: localePath(eventUrl(o.event.slug), reqLocale) })).join('');
|
|
45
|
+
html = '<section class="pc-ev-cards" data-layout="grid">' + (cards || '<p class="pc-ev-empty">' + w.noUpcoming + '</p>') + '</section>';
|
|
26
46
|
} else {
|
|
27
|
-
const { events, calendars } = await collectViewEvents(repo, settings, calendarWindow(settings, now), { audience: 'public',
|
|
28
|
-
html = renderCalendarView(events, settings, now, { view: view === 'list' ? 'upcoming' : view, limit: 30, multi:
|
|
47
|
+
const { events, calendars: shown } = await collectViewEvents(repo, settings, calendarWindow(settings, now), { audience: 'public', filter: resolved, locale: lang });
|
|
48
|
+
html = renderCalendarView(events, settings, now, { view: view === 'list' ? 'upcoming' : view, limit: 30, multi: shown.length > 1 });
|
|
29
49
|
}
|
|
30
50
|
Astro.response.headers.set('Content-Security-Policy', "frame-ancestors *");
|
|
31
51
|
---
|
|
32
52
|
|
|
33
|
-
<Site title={cal?.name ??
|
|
53
|
+
<Site title={cal?.name ?? w.title} bare noindex>
|
|
34
54
|
<base target="_top" />
|
|
35
55
|
<div class="pc-embed" set:html={html} />
|
|
36
56
|
<script is:inline>
|
|
@@ -62,6 +62,7 @@ const { title, user } = Astro.props;
|
|
|
62
62
|
.pc-mem-dates li { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 42px; padding: 5px 0; border-top: 1px solid var(--line); font-variant-numeric: tabular-nums; }
|
|
63
63
|
.pc-mem-dates li.is-cancelled > span { color: var(--muted); }
|
|
64
64
|
.pc-mem-dates em { font-style: normal; font-size: 12.5px; font-weight: 600; color: var(--danger); margin-left: 8px; }
|
|
65
|
+
.pc-mem-dates em.is-moved { color: var(--accent); font-weight: 500; }
|
|
65
66
|
.pc-mem-past > summary { cursor: pointer; font-weight: 600; font-size: 15px; }
|
|
66
67
|
.pc-mem-past .pc-mem-list { margin-top: 10px; }
|
|
67
68
|
/* the request form */
|
|
@@ -175,7 +175,7 @@ const pastShown = past.slice(0, 10);
|
|
|
175
175
|
const off = e.cancelledDates.includes(o.key);
|
|
176
176
|
return (
|
|
177
177
|
<li class={o.cancelled ? 'is-cancelled' : undefined}>
|
|
178
|
-
<span>{o.cancelled ? <s>{when(o)}</s> : when(o)}{o.cancelled && <em>Cancelled</em>}</span>
|
|
178
|
+
<span>{o.cancelled ? <s>{when(o)}</s> : when(o)}{o.cancelled && <em>Cancelled</em>}{o.moved && !o.cancelled && <em class="is-moved" title={o.moved.note || undefined}>Moved from {when({ ...o, start: o.moved.from.start, end: o.moved.from.end })}</em>}</span>
|
|
179
179
|
{e.rrule && !e.cancelled && o.start > now && (
|
|
180
180
|
<form method="post" onsubmit={off ? undefined : "return confirm('Cancel this date? It shows as cancelled straight away.')"}>
|
|
181
181
|
<input type="hidden" name="op" value={off ? 'restore-date' : 'cancel-date'} />
|
package/src/server/index.ts
CHANGED
|
@@ -37,6 +37,7 @@ export interface ServerUser {
|
|
|
37
37
|
export type { CalEvent, Occurrence };
|
|
38
38
|
export type { EventRequestInput } from '../lib/calendar/requests';
|
|
39
39
|
export type { EventClash, BusySlot, Hold } from '../lib/booking/holds';
|
|
40
|
+
export type { MoveInput, MoveResult } from '../lib/calendar/moves';
|
|
40
41
|
|
|
41
42
|
export interface ListEventsOptions {
|
|
42
43
|
/** only published events (default: every event except archived ones) */
|
|
@@ -172,6 +173,38 @@ export async function restoreOccurrence(repo: Repo, eventId: string, occurrenceK
|
|
|
172
173
|
return restore(internal(repo), eventId, occurrenceKey, { url: context.url, locals: context.locals });
|
|
173
174
|
}
|
|
174
175
|
|
|
176
|
+
// ---- moved dates -------------------------------------------------------------
|
|
177
|
+
// One date of a repeating event at another time (and maybe other rooms or
|
|
178
|
+
// another place), keeping its key, its RSVPs (moved with it; each person gets
|
|
179
|
+
// the "new time" email with their cancel link) and its hosts. These check who
|
|
180
|
+
// is acting: staff ("Create and edit content") and GOOGLE_SYNC may move any
|
|
181
|
+
// date, and a room taken at the new time comes back in `clashes` as a
|
|
182
|
+
// warning; a host of the event may move its dates only while the rooms are
|
|
183
|
+
// free (409 otherwise), and staff are emailed about it.
|
|
184
|
+
|
|
185
|
+
/** The actor for a calendar sync acting with staff rights (a change already made elsewhere): clashes only warn. */
|
|
186
|
+
export const GOOGLE_SYNC = 'google-sync';
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Move one date (by its occurrence key) to `input.start` (and `end`, else it
|
|
190
|
+
* keeps its length; wall-clock in the event's timezone, like `start`).
|
|
191
|
+
* `roomIds`/`location`/`note` replace that date's own (omit to keep them).
|
|
192
|
+
* Applies at once, even on a published event. `quiet` skips the app notice
|
|
193
|
+
* and the staff email (a sync catching up); RSVP emails still go. Errors have
|
|
194
|
+
* a `status`: 400 (not a repeating event, not one of its dates, started or a
|
|
195
|
+
* past time), 401, 404, 409 (a cancelled date, or a taken room for a host).
|
|
196
|
+
*/
|
|
197
|
+
export async function moveOccurrence(repo: Repo, who: Who | typeof GOOGLE_SYNC, eventId: string, occurrenceKey: string, input: import('../lib/calendar/moves').MoveInput, context: WriteContext, opts: { quiet?: boolean } = {}): Promise<import('../lib/calendar/moves').MoveResult> {
|
|
198
|
+
const { moveOccurrence: move } = await import('../lib/calendar/moves');
|
|
199
|
+
return move(internal(repo), who, eventId, occurrenceKey, input, { url: context.url, locals: context.locals }, opts);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Put a moved date back where the series has it. Same rules; idempotent. */
|
|
203
|
+
export async function moveOccurrenceBack(repo: Repo, who: Who | typeof GOOGLE_SYNC, eventId: string, occurrenceKey: string, context: WriteContext, opts: { quiet?: boolean } = {}): Promise<import('../lib/calendar/moves').MoveResult> {
|
|
204
|
+
const { moveOccurrenceBack: back } = await import('../lib/calendar/moves');
|
|
205
|
+
return back(internal(repo), who, eventId, occurrenceKey, { url: context.url, locals: context.locals }, opts);
|
|
206
|
+
}
|
|
207
|
+
|
|
175
208
|
// ---- rooms -----------------------------------------------------------------
|
|
176
209
|
// Events hold rooms (CalEvent.roomHold) while they happen. These find where
|
|
177
210
|
// two things want the same room. They don't check permissions, and their
|
package/src/styles/editor.css
CHANGED
|
@@ -1328,3 +1328,22 @@ html.pe-evr-lock { overflow: hidden; }
|
|
|
1328
1328
|
.pe-evs-wrap { --evs-pad: 14px; padding-top: 18px; }
|
|
1329
1329
|
.pe-evsec input:where(:not([type='checkbox'], [type='radio'])), .pe-evsec select, .pe-evsec textarea, .pe-loc-search input, .pe-evsec input.mono { font-size: 16px; }
|
|
1330
1330
|
}
|
|
1331
|
+
|
|
1332
|
+
/* Event settings → Single dates (SingleDates.tsx): move or cancel one date */
|
|
1333
|
+
.pe-evd { list-style: none; margin: 0; padding: 0; }
|
|
1334
|
+
.pe-evd-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 6px 12px; padding: 8px 0; border-top: 1px solid var(--line); }
|
|
1335
|
+
.pe-evd-row:first-child { border-top: 0; }
|
|
1336
|
+
.pe-evd-main { display: flex; flex-direction: column; min-width: 0; font-size: 13.5px; font-variant-numeric: tabular-nums; }
|
|
1337
|
+
.pe-evd-state { font-size: 12px; color: var(--text-2); }
|
|
1338
|
+
.pe-evd-row.is-cancelled .pe-evd-when { color: var(--muted); }
|
|
1339
|
+
.pe-evd-row.is-cancelled .pe-evd-state { color: var(--red); }
|
|
1340
|
+
.pe-evd-row.is-moved .pe-evd-state { color: var(--accent); }
|
|
1341
|
+
.pe-evd-acts { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
1342
|
+
.pe-evd-form { flex-basis: 100%; display: flex; flex-direction: column; gap: 10px; margin: 4px 0 6px; padding: 12px; border-radius: 10px; background: var(--panel-2, var(--soft)); }
|
|
1343
|
+
.pe-evd-fields { display: flex; flex-wrap: wrap; gap: 10px; }
|
|
1344
|
+
.pe-evd-fields .pe-field { flex: 1 1 130px; margin: 0; }
|
|
1345
|
+
.pe-evd-rooms { border: 0; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 13px; }
|
|
1346
|
+
.pe-evd-rooms legend { font-size: 12px; color: var(--text-2); margin-bottom: 4px; padding: 0; }
|
|
1347
|
+
.pe-evd-room { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
|
|
1348
|
+
.pe-evd-formacts { display: flex; gap: 8px; }
|
|
1349
|
+
.pe-ev-note.is-error { color: var(--red); }
|
package/src/styles/schedule.css
CHANGED
|
@@ -205,6 +205,8 @@ kbd { font: 10.5px ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--
|
|
|
205
205
|
.sch-toast button { border: 0; background: none; color: var(--accent); font: inherit; font-weight: 650; cursor: pointer; padding: 0; }
|
|
206
206
|
:root[data-theme='dark'] .sch-toast { background: #2e2b27; color: var(--text); border: 1px solid var(--line-strong); }
|
|
207
207
|
.sch-toast.err { background: var(--red); color: #fff; }
|
|
208
|
+
/* a room clash after a move: both events keep the room */
|
|
209
|
+
.sch-toast-warn { display: block; max-width: 340px; font-size: 12px; color: var(--amber, #c98a16); }
|
|
208
210
|
|
|
209
211
|
/* ---- quick add ---- */
|
|
210
212
|
.qa-layer { position: fixed; inset: 0; z-index: 75; }
|
package/src/styles/site.css
CHANGED
|
@@ -806,6 +806,10 @@ li::marker { color: var(--muted); }
|
|
|
806
806
|
@layer base {
|
|
807
807
|
.pc-cal-event.is-cancelled b, .pc-cal-row.is-cancelled .ti { text-decoration: line-through; color: var(--cal-muted); }
|
|
808
808
|
.pc-cal-pop-flag { display: inline-block; font-size: 11px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--danger); margin-bottom: 3px; }
|
|
809
|
+
/* a moved date: flagged, never struck through */
|
|
810
|
+
.pc-cal-pop-flag.is-moved, .pc-ev-flag.is-moved { color: var(--accent); }
|
|
811
|
+
.pc-cal-pop-moved { font-size: 12.5px; color: var(--cal-muted); margin-top: 2px; }
|
|
812
|
+
.pc-cal-event.is-moved b::before { content: '↷ '; }
|
|
809
813
|
.pc-cal-pop-link { display: inline-block; margin-top: 9px; font-weight: 600; font-size: 12.5px; color: var(--cal-accent); text-decoration: none; }
|
|
810
814
|
.pc-cal-pop-link:hover { text-decoration: underline; }
|
|
811
815
|
|
|
@@ -867,6 +871,8 @@ li::marker { color: var(--muted); }
|
|
|
867
871
|
.pc-ev-page.is-cancelled .pc-ev-title { text-decoration: line-through; text-decoration-thickness: 2px; color: var(--muted); }
|
|
868
872
|
.pc-ev-cancelled { background: color-mix(in srgb, var(--danger) 10%, var(--panel)); border-radius: var(--r-sm); padding: 10px 14px; font-size: 14.5px; margin: 0 0 16px; }
|
|
869
873
|
.pc-ev-cancelled b { color: var(--danger); }
|
|
874
|
+
.pc-ev-moved { background: color-mix(in srgb, var(--accent) 10%, var(--panel)); border-radius: var(--r-sm); padding: 10px 14px; font-size: 14.5px; margin: 0 0 16px; }
|
|
875
|
+
.pc-ev-moved b { color: var(--accent); }
|
|
870
876
|
.pc-ev-lede { font-size: 19px; line-height: 1.55; color: var(--muted); margin: 0 0 22px; }
|
|
871
877
|
.pc-ev-side {
|
|
872
878
|
position: sticky; top: 96px; background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-lg);
|
|
@@ -930,6 +936,16 @@ li::marker { color: var(--muted); }
|
|
|
930
936
|
.pc-calpage-chips a:hover { color: var(--ink); }
|
|
931
937
|
.pc-calpage-chips a.is-on { color: var(--ink); font-weight: 600; box-shadow: inset 0 -2px 0 var(--ink); }
|
|
932
938
|
.pc-calpage-chips i { width: 7px; height: 7px; border-radius: 2px; }
|
|
939
|
+
/* active filters from the address (tag, left-out tag, room): removable chips */
|
|
940
|
+
.pc-calpage-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: -2px 0 16px; }
|
|
941
|
+
.pc-calpage-filter {
|
|
942
|
+
display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 8px 0 11px; border-radius: var(--r-sm);
|
|
943
|
+
background: var(--soft); color: var(--ink); font-size: 13px; font-weight: 550; text-decoration: none;
|
|
944
|
+
}
|
|
945
|
+
.pc-calpage-filter svg { width: 13px; height: 13px; color: var(--muted); }
|
|
946
|
+
.pc-calpage-filter:hover svg { color: var(--ink); }
|
|
947
|
+
.pc-calpage-clear { padding: 0 6px; font-size: 13px; color: var(--muted); text-decoration: none; }
|
|
948
|
+
.pc-calpage-clear:hover { color: var(--ink); }
|
|
933
949
|
.pc-calpage-tools { display: flex; gap: 8px; align-items: center; margin-left: auto; }
|
|
934
950
|
.pc-calpage-views { display: inline-flex; background: var(--soft); border-radius: 10px; padding: 3px; }
|
|
935
951
|
.pc-calpage-views a { padding: 5px 12px; border-radius: 7px; font-size: 13px; font-weight: 550; color: var(--muted); text-decoration: none; }
|
|
@@ -29,9 +29,10 @@ export interface UpcomingEventsOptions {
|
|
|
29
29
|
/**
|
|
30
30
|
* Occurrence start/end are Unix milliseconds; event retains its authored
|
|
31
31
|
* fields except private custom fields. `cancelled` is true when the whole
|
|
32
|
-
* event or just this date is cancelled.
|
|
32
|
+
* event or just this date is cancelled. `moved` is set when this date of a
|
|
33
|
+
* series was moved here: `from` (its old start and end) and staff's `note`.
|
|
33
34
|
*/
|
|
34
|
-
export type UpcomingEvent = Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days' | 'cancelled'>;
|
|
35
|
+
export type UpcomingEvent = Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days' | 'cancelled'> & Pick<Partial<Occurrence>, 'moved'>;
|
|
35
36
|
|
|
36
37
|
/** Server-side helper for Astro templates and endpoints. No repository access needed. */
|
|
37
38
|
export async function upcomingEvents(
|