@acms-subir/core 0.3.45 → 0.3.47
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 +28 -0
- package/cli/migrations.generated.mjs +117 -16
- package/docs/environment-sync.md +6 -0
- package/package.json +1 -1
- package/src/actions/index.ts +2 -4
- package/src/lib/calendar/agent.ts +3 -4
- package/src/lib/calendar/cards.ts +9 -6
- package/src/lib/calendar/change-migration.ts +65 -0
- package/src/lib/calendar/data.ts +25 -9
- package/src/lib/calendar/event-words.ts +124 -0
- package/src/lib/calendar/feeds.ts +11 -3
- package/src/lib/calendar/filter.ts +140 -0
- package/src/lib/calendar/hooks.ts +76 -31
- package/src/lib/calendar/render.ts +20 -16
- package/src/lib/calendar/requests.ts +2 -3
- package/src/lib/calendar/schedule.ts +4 -4
- package/src/lib/content-service.ts +15 -12
- package/src/lib/mobile/calendar.ts +27 -12
- package/src/lib/mobile/http.ts +5 -3
- package/src/lib/push/service.ts +1 -1
- package/src/lib/repo/migrations.ts +4 -0
- package/src/lib/repo/sql.ts +29 -0
- package/src/lib/repo/types.ts +21 -0
- package/src/lib/scheduler.ts +3 -0
- package/src/lib/sync/store.ts +4 -0
- package/src/middleware.ts +13 -2
- package/src/pages/admin/calendar/events/index.astro +4 -9
- package/src/pages/api/calendar/docs.ts +2 -0
- package/src/pages/api/calendar/events/[id].ts +3 -3
- package/src/pages/api/calendar/events/index.ts +18 -12
- package/src/pages/api/webhooks/docs.ts +5 -0
- 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/styles/site.css +10 -0
|
@@ -3,50 +3,69 @@ import Site from '../../layouts/Site.astro';
|
|
|
3
3
|
import { getRepo } from '../../lib/repo';
|
|
4
4
|
import { effectiveSettings } from '../../lib/designPreview';
|
|
5
5
|
import { siteOrigin } from '../../lib/seo';
|
|
6
|
-
import { siteLocale } from '../../lib/dates';
|
|
7
|
-
import {
|
|
6
|
+
import { siteLang, siteLocale } from '../../lib/dates';
|
|
7
|
+
import { I18N, localePath, requestLocale } from '../../lib/i18n';
|
|
8
|
+
import { isExtraLocale } from '../../lib/translations';
|
|
9
|
+
import { fill } from '../../lib/live/words';
|
|
10
|
+
import { eventStrings } from '../../lib/calendar/event-words';
|
|
11
|
+
import { CALENDAR, calendarFeedUrl, eventUrl } from '../../lib/calendar/config';
|
|
8
12
|
import { calendarWindow, renderCalendarView } from '../../lib/calendar/render';
|
|
9
13
|
import { collectViewEvents, type ViewEvent } from '../../lib/calendar/data';
|
|
10
14
|
import { eventCardHtml } from '../../lib/calendar/cards';
|
|
11
15
|
import { safeColor } from '../../lib/calendar/events';
|
|
12
16
|
import { tzParts } from '../../lib/calendar/ics';
|
|
17
|
+
import { eventFilterQuery, hasEventFilter, readEventFilter, resolveEventFilter, type EventFilter } from '../../lib/calendar/filter';
|
|
13
18
|
|
|
14
19
|
// The main calendar page: filter chips per public calendar, Month / Week /
|
|
15
20
|
// List views (the same renderer as the calendar card), image cards for
|
|
16
21
|
// what's coming up, and subscribe links for every calendar app. On phones the
|
|
17
22
|
// month grid gives way to a two-week day strip over the agenda.
|
|
23
|
+
// The address carries the filters (filter.ts): ?cal=youth,events, ?tag=Class,
|
|
24
|
+
// ?notag=Class, ?room=chapel. Active ones show as removable chips, and the
|
|
25
|
+
// subscribe links carry them. /es/calendar speaks Spanish (the same languages
|
|
26
|
+
// as event pages) and keeps its links under /es.
|
|
18
27
|
if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
19
28
|
|
|
20
29
|
const repo = await getRepo(Astro.locals);
|
|
21
|
-
const
|
|
30
|
+
const reqLocale = I18N.enabled ? requestLocale(Astro.url, Astro.locals) : '';
|
|
31
|
+
const baseSettings = await effectiveSettings(await repo.getSettings(), Astro.url, Astro.cookies, Astro.locals);
|
|
32
|
+
// dates and the renderer's words follow the page's language, like event pages
|
|
33
|
+
const settings = isExtraLocale(reqLocale) ? { ...baseSettings, locale: reqLocale } : baseSettings;
|
|
34
|
+
const lang = reqLocale || siteLang(settings);
|
|
35
|
+
const w = eventStrings(lang);
|
|
36
|
+
const cw = w.calendar;
|
|
37
|
+
const here = (path: string) => localePath(path, reqLocale);
|
|
22
38
|
const now = Date.now();
|
|
23
39
|
const tz = settings.timezone || 'UTC';
|
|
24
40
|
const locale = siteLocale(settings);
|
|
25
41
|
const origin = siteOrigin(settings, Astro.url);
|
|
26
42
|
|
|
27
|
-
const
|
|
43
|
+
const filter = readEventFilter(Astro.url.searchParams);
|
|
44
|
+
const filtered = hasEventFilter(filter);
|
|
45
|
+
const [all, resources] = await Promise.all([repo.listCalendars(), filter.room.length ? repo.listResources() : []]);
|
|
46
|
+
const resolved = resolveEventFilter(filter, { calendars: all, resources });
|
|
28
47
|
const listed = all.filter((c) => c.visibility === 'public');
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
48
|
+
// exactly one calendar picked: its name, description and own feed
|
|
49
|
+
const active = filter.cal.length === 1 && resolved.calendars.length === 1 ? resolved.calendars[0] : null;
|
|
50
|
+
const picked = new Set(resolved.calendarIds ?? []);
|
|
32
51
|
const viewParam = Astro.url.searchParams.get('view');
|
|
33
52
|
const view = (['month', 'week', 'list'] as const).find((v) => v === viewParam) ?? CALENDAR.defaultView;
|
|
34
|
-
const explicitView =
|
|
35
|
-
const
|
|
53
|
+
const explicitView = viewParam === view;
|
|
54
|
+
const monthRaw = Astro.url.searchParams.get('m') ?? '';
|
|
55
|
+
const monthParam = /^(\d{4})-(\d{2})$/.exec(monthRaw);
|
|
36
56
|
|
|
37
57
|
const win = calendarWindow(settings, now);
|
|
38
|
-
const { events, calendars, errors } = await collectViewEvents(repo, settings, win, {
|
|
39
|
-
audience: 'public',
|
|
40
|
-
...(active ? { calendarIds: [active.id] } : {}),
|
|
41
|
-
});
|
|
58
|
+
const { events, calendars, errors } = await collectViewEvents(repo, settings, win, { audience: 'public', filter: resolved, locale: lang });
|
|
42
59
|
|
|
43
|
-
const
|
|
60
|
+
const gridHtml = renderCalendarView(events, settings, now, {
|
|
44
61
|
view: view === 'list' ? 'upcoming' : view,
|
|
45
62
|
limit: 50,
|
|
46
|
-
multi:
|
|
47
|
-
emptyText:
|
|
63
|
+
multi: calendars.length > 1,
|
|
64
|
+
emptyText: filtered ? cw.noMatch : cw.empty,
|
|
48
65
|
focus: monthParam ? { y: Number(monthParam[1]), m: Number(monthParam[2]) } : undefined,
|
|
49
66
|
});
|
|
67
|
+
// an empty month or week grid says why (the list says it itself)
|
|
68
|
+
const calendarHtml = (filtered && !events.length && view !== 'list' ? '<p class="pc-cal-empty">' + cw.noMatch + '</p>' : '') + gridHtml;
|
|
50
69
|
|
|
51
70
|
// coming up: the next occurrence of each distinct native event
|
|
52
71
|
const seen = new Set<string>();
|
|
@@ -79,19 +98,44 @@ const stripDays = Array.from({ length: 14 }, (_, i) => {
|
|
|
79
98
|
return { k, dow: fmt({ weekday: 'narrow' }).format(ms), d: tzParts(ms, tz).d, has: agenda.has(k), today: i === 0 };
|
|
80
99
|
});
|
|
81
100
|
|
|
82
|
-
|
|
101
|
+
// links keep the filters, the view, the month and the page's language
|
|
102
|
+
const pageHref = (f: EventFilter, opts: { view?: string | null; m?: string | null } = {}) => {
|
|
103
|
+
const v = opts.view === undefined ? (explicitView ? view : null) : opts.view;
|
|
104
|
+
const m = opts.m === undefined ? (monthParam ? monthRaw : null) : opts.m;
|
|
105
|
+
const q = [eventFilterQuery(f), v ? `view=${v}` : '', m ? `m=${m}` : ''].filter(Boolean).join('&');
|
|
106
|
+
return here(CALENDAR.base) + (q ? `?${q}` : '');
|
|
107
|
+
};
|
|
108
|
+
const viewHref = (v: string) => pageHref(filter, { view: v, m: null });
|
|
109
|
+
const without = (key: keyof EventFilter, value: string) => pageHref({ ...filter, [key]: filter[key].filter((x) => x !== value) });
|
|
110
|
+
// calendar chips switch a calendar on or off; "All" drops the calendar filter
|
|
111
|
+
const toggleCal = (slug: string) => {
|
|
112
|
+
const on = filter.cal.some((s) => s.toLowerCase() === slug);
|
|
113
|
+
return pageHref({ ...filter, cal: on ? filter.cal.filter((s) => s.toLowerCase() !== slug) : [...filter.cal, slug] });
|
|
114
|
+
};
|
|
115
|
+
const offered = [...listed, ...resolved.calendars.filter((c) => c.visibility !== 'public')];
|
|
116
|
+
const roomName = (slug: string) => resolved.rooms.find((r) => r.slug === slug.toLowerCase())?.name ?? slug;
|
|
117
|
+
// the removable chips: tags, left-out tags, rooms, and calendars that didn't resolve
|
|
118
|
+
const chips = [
|
|
119
|
+
...filter.tag.map((t) => ({ label: t, href: without('tag', t) })),
|
|
120
|
+
...filter.notag.map((t) => ({ label: fill(cw.without, { tag: t }), href: without('notag', t) })),
|
|
121
|
+
...filter.room.map((r) => ({ label: fill(cw.room, { room: roomName(r) }), href: without('room', r) })),
|
|
122
|
+
...filter.cal.filter((s) => !resolved.calendars.some((c) => c.slug === s.toLowerCase())).map((s) => ({ label: s, href: without('cal', s) })),
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
// subscribe: one calendar alone keeps its own feed; anything else is the
|
|
126
|
+
// site feed with the same filters
|
|
127
|
+
const ownFeed = active && !filter.tag.length && !filter.notag.length && !filter.room.length;
|
|
128
|
+
const feedQuery = eventFilterQuery(filter);
|
|
129
|
+
const feed = origin + (ownFeed ? calendarFeedUrl(active.slug) : calendarFeedUrl() + (feedQuery ? `?${feedQuery}` : ''));
|
|
83
130
|
const webcal = feed.replace(/^https?:\/\//, 'webcal://');
|
|
84
131
|
const googleAdd = `https://calendar.google.com/calendar/r?cid=${encodeURIComponent(webcal)}`;
|
|
85
132
|
const outlookAdd = `https://outlook.live.com/calendar/0/addfromweb?url=${encodeURIComponent(feed)}&name=${encodeURIComponent(active?.name ?? settings.siteTitle)}`;
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
p.delete('m');
|
|
90
|
-
return `${CALENDAR.base}?${p}`;
|
|
91
|
-
};
|
|
92
|
-
const title = active ? active.name : 'Calendar';
|
|
93
|
-
const lede = active?.description || `What’s happening at ${settings.siteTitle}. Subscribe to keep it in your own calendar.`;
|
|
133
|
+
const subscribeNote = ownFeed ? fill(cw.subscribeNamed, { name: active.name }) : filtered ? cw.subscribeFiltered : cw.subscribeAll;
|
|
134
|
+
const title = active ? active.name : cw.title;
|
|
135
|
+
const lede = active?.description || fill(cw.lede, { site: settings.siteTitle });
|
|
94
136
|
const zoneName = fmt({ timeZoneName: 'long' }).formatToParts(now).find((p) => p.type === 'timeZoneName')?.value ?? tz;
|
|
137
|
+
const agendaEmpty = filtered && !events.length ? cw.noMatch : cw.emptySoon;
|
|
138
|
+
const cardHref = (v: ViewEvent) => here(eventUrl(v.event!.slug));
|
|
95
139
|
---
|
|
96
140
|
|
|
97
141
|
<Site title={title} description={lede}>
|
|
@@ -102,40 +146,51 @@ const zoneName = fmt({ timeZoneName: 'long' }).formatToParts(now).find((p) => p.
|
|
|
102
146
|
</header>
|
|
103
147
|
|
|
104
148
|
<div class="pc-calpage-bar">
|
|
105
|
-
{(
|
|
106
|
-
<nav class="pc-calpage-chips" aria-label=
|
|
107
|
-
<a class={!
|
|
108
|
-
{
|
|
109
|
-
<a class={
|
|
149
|
+
{(offered.length > 1 || picked.size > 0) && (
|
|
150
|
+
<nav class="pc-calpage-chips" aria-label={cw.calendars}>
|
|
151
|
+
<a class={!filter.cal.length ? 'is-on' : ''} href={pageHref({ ...filter, cal: [] })} aria-current={!filter.cal.length ? 'page' : undefined}>{cw.all}</a>
|
|
152
|
+
{offered.map((c) => (
|
|
153
|
+
<a class={picked.has(c.id) ? 'is-on' : ''} href={toggleCal(c.slug)} aria-current={picked.has(c.id) ? 'true' : undefined}>
|
|
110
154
|
<i style={`background:${safeColor(c.color)}`} aria-hidden="true" />{c.name}
|
|
111
155
|
</a>
|
|
112
156
|
))}
|
|
113
|
-
{active && active.visibility !== 'public' && <a class="is-on" href={calendarPageUrl(active.slug)}><i style={`background:${safeColor(active.color)}`} aria-hidden="true" />{active.name}</a>}
|
|
114
157
|
</nav>
|
|
115
158
|
)}
|
|
116
159
|
<div class="pc-calpage-tools">
|
|
117
|
-
<nav class="pc-calpage-views" aria-label=
|
|
118
|
-
<a class={view === 'month' ? 'is-on' : ''} href={viewHref('month')}>
|
|
119
|
-
<a class={view === 'week' ? 'is-on' : ''} href={viewHref('week')}>
|
|
120
|
-
<a class={view === 'list' ? 'is-on' : ''} href={viewHref('list')}>
|
|
160
|
+
<nav class="pc-calpage-views" aria-label={cw.views}>
|
|
161
|
+
<a class={view === 'month' ? 'is-on' : ''} href={viewHref('month')}>{cw.month}</a>
|
|
162
|
+
<a class={view === 'week' ? 'is-on' : ''} href={viewHref('week')}>{cw.week}</a>
|
|
163
|
+
<a class={view === 'list' ? 'is-on' : ''} href={viewHref('list')}>{cw.list}</a>
|
|
121
164
|
</nav>
|
|
122
165
|
<details class="pc-calpage-sub">
|
|
123
166
|
<summary>
|
|
124
167
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11M7 10l5 5 5-5M5 20h14" /></svg>
|
|
125
|
-
|
|
168
|
+
{cw.subscribe}
|
|
126
169
|
</summary>
|
|
127
170
|
<div class="pc-calpage-sub-menu">
|
|
128
|
-
<p>
|
|
171
|
+
<p>{subscribeNote}</p>
|
|
129
172
|
<a href={googleAdd} target="_blank" rel="noopener">Google Calendar</a>
|
|
130
173
|
<a href={webcal}>Apple Calendar</a>
|
|
131
174
|
<a href={outlookAdd} target="_blank" rel="noopener">Outlook</a>
|
|
132
|
-
<button type="button" data-copy={feed}>
|
|
175
|
+
<button type="button" data-copy={feed} data-copied={w.linkCopied}>{cw.copyLink}</button>
|
|
133
176
|
</div>
|
|
134
177
|
</details>
|
|
135
178
|
</div>
|
|
136
179
|
</div>
|
|
137
180
|
|
|
138
|
-
|
|
181
|
+
{chips.length > 0 && (
|
|
182
|
+
<nav class="pc-calpage-filters" aria-label={cw.filters}>
|
|
183
|
+
{chips.map((c) => (
|
|
184
|
+
<a class="pc-calpage-filter" href={c.href} aria-label={fill(cw.remove, { filter: c.label })}>
|
|
185
|
+
<span>{c.label}</span>
|
|
186
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M7 7l10 10M17 7L7 17" /></svg>
|
|
187
|
+
</a>
|
|
188
|
+
))}
|
|
189
|
+
{chips.length > 1 && <a class="pc-calpage-clear" href={pageHref({ cal: [], tag: [], notag: [], room: [] })}>{cw.clear}</a>}
|
|
190
|
+
</nav>
|
|
191
|
+
)}
|
|
192
|
+
|
|
193
|
+
<div class="pc-calpage-strip" aria-label={cw.nextTwoWeeks}>
|
|
139
194
|
{stripDays.map((d) => (
|
|
140
195
|
<a class={`${d.has ? 'has' : ''}${d.today ? ' today' : ''}`} href={d.has ? `#d-${d.k}` : undefined} aria-label={d.k}>
|
|
141
196
|
<small>{d.dow}</small><b>{d.d}</b><i aria-hidden="true" />
|
|
@@ -145,13 +200,13 @@ const zoneName = fmt({ timeZoneName: 'long' }).formatToParts(now).find((p) => p.
|
|
|
145
200
|
|
|
146
201
|
<div class="pc-calpage-view" set:html={calendarHtml} />
|
|
147
202
|
|
|
148
|
-
<section class="pc-calpage-agenda" aria-label=
|
|
149
|
-
{agenda.size === 0 ? <p class="pc-cal-empty">
|
|
203
|
+
<section class="pc-calpage-agenda" aria-label={cw.agenda}>
|
|
204
|
+
{agenda.size === 0 ? <p class="pc-cal-empty">{agendaEmpty}</p> : [...agenda.entries()].map(([k, list]) => (
|
|
150
205
|
<div class="pc-agenda-day" id={`d-${k}`}>
|
|
151
206
|
<h3>{dayLabel.format(list[0].start < now ? now : list[0].start)}</h3>
|
|
152
207
|
{list.map((v) => (
|
|
153
208
|
<a class={`pc-agenda-row${v.cancelled ? ' is-cancelled' : ''}`} href={v.url ?? undefined}>
|
|
154
|
-
<span class="t">{v.allDay ?
|
|
209
|
+
<span class="t">{v.allDay ? w.allDay : timeLabel.format(v.start)}</span>
|
|
155
210
|
<span class="ti">
|
|
156
211
|
{v.color && <i style={`background:${v.color}`} aria-hidden="true" />}
|
|
157
212
|
<b>{v.title}</b>
|
|
@@ -165,14 +220,14 @@ const zoneName = fmt({ timeZoneName: 'long' }).formatToParts(now).find((p) => p.
|
|
|
165
220
|
|
|
166
221
|
{view !== 'list' && comingUp.length > 0 && (
|
|
167
222
|
<section class="pc-calpage-up">
|
|
168
|
-
<h2>
|
|
169
|
-
<div class="pc-ev-cards" data-layout="grid" set:html={comingUp.map((v) => eventCardHtml({ event: v.event!, start: v.start, end: v.end, allDay: v.allDay, days: v.days, cancelled: v.cancelled }, settings, { showImage: true })).join('')} />
|
|
223
|
+
<h2>{cw.comingUp}</h2>
|
|
224
|
+
<div class="pc-ev-cards" data-layout="grid" set:html={comingUp.map((v) => eventCardHtml({ event: v.event!, start: v.start, end: v.end, allDay: v.allDay, days: v.days, cancelled: v.cancelled }, settings, { showImage: true, href: cardHref(v) })).join('')} />
|
|
170
225
|
</section>
|
|
171
226
|
)}
|
|
172
227
|
|
|
173
228
|
<footer class="pc-calpage-foot">
|
|
174
|
-
<span>
|
|
175
|
-
{errors.length > 0 && <span>
|
|
229
|
+
<span>{fill(cw.timesIn, { zone: zoneName })}</span>
|
|
230
|
+
{errors.length > 0 && <span>{cw.feedErrors}</span>}
|
|
176
231
|
</footer>
|
|
177
232
|
</div>
|
|
178
233
|
<script>
|
|
@@ -180,8 +235,9 @@ const zoneName = fmt({ timeZoneName: 'long' }).formatToParts(now).find((p) => p.
|
|
|
180
235
|
const b = e.target instanceof Element ? e.target.closest<HTMLButtonElement>('[data-copy]') : null;
|
|
181
236
|
if (b) {
|
|
182
237
|
await navigator.clipboard.writeText(b.dataset.copy ?? '');
|
|
183
|
-
b.textContent
|
|
184
|
-
|
|
238
|
+
const label = (b.dataset.label ??= b.textContent ?? '');
|
|
239
|
+
b.textContent = b.dataset.copied ?? label;
|
|
240
|
+
setTimeout(() => (b.textContent = label), 1400);
|
|
185
241
|
}
|
|
186
242
|
document.querySelectorAll<HTMLDetailsElement>('details.pc-calpage-sub[open]').forEach((d) => {
|
|
187
243
|
if (!(e.target instanceof Node && d.contains(e.target))) d.open = false;
|
|
@@ -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>
|
package/src/styles/site.css
CHANGED
|
@@ -930,6 +930,16 @@ li::marker { color: var(--muted); }
|
|
|
930
930
|
.pc-calpage-chips a:hover { color: var(--ink); }
|
|
931
931
|
.pc-calpage-chips a.is-on { color: var(--ink); font-weight: 600; box-shadow: inset 0 -2px 0 var(--ink); }
|
|
932
932
|
.pc-calpage-chips i { width: 7px; height: 7px; border-radius: 2px; }
|
|
933
|
+
/* active filters from the address (tag, left-out tag, room): removable chips */
|
|
934
|
+
.pc-calpage-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: -2px 0 16px; }
|
|
935
|
+
.pc-calpage-filter {
|
|
936
|
+
display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 8px 0 11px; border-radius: var(--r-sm);
|
|
937
|
+
background: var(--soft); color: var(--ink); font-size: 13px; font-weight: 550; text-decoration: none;
|
|
938
|
+
}
|
|
939
|
+
.pc-calpage-filter svg { width: 13px; height: 13px; color: var(--muted); }
|
|
940
|
+
.pc-calpage-filter:hover svg { color: var(--ink); }
|
|
941
|
+
.pc-calpage-clear { padding: 0 6px; font-size: 13px; color: var(--muted); text-decoration: none; }
|
|
942
|
+
.pc-calpage-clear:hover { color: var(--ink); }
|
|
933
943
|
.pc-calpage-tools { display: flex; gap: 8px; align-items: center; margin-left: auto; }
|
|
934
944
|
.pc-calpage-views { display: inline-flex; background: var(--soft); border-radius: 10px; padding: 3px; }
|
|
935
945
|
.pc-calpage-views a { padding: 5px 12px; border-radius: 7px; font-size: 13px; font-weight: 550; color: var(--muted); text-decoration: none; }
|