@acms-subir/core 0.3.21 → 0.3.23
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 +24 -0
- package/cli/migrations.generated.mjs +6 -0
- package/docs/mobile.md +1 -1
- package/docs/sections.md +2 -2
- package/docs/server.md +210 -29
- package/docs/theme-authoring.md +1 -1
- package/integration.mjs +134 -1
- package/package.json +1 -1
- package/src/actions/index.ts +3 -1
- package/src/components/EmailSettings.tsx +1 -1
- package/src/components/RolesManager.tsx +15 -7
- package/src/components/calendar/CalendarBoard.tsx +5 -3
- package/src/components/sections/SectionFields.tsx +31 -10
- package/src/layouts/Admin.astro +2 -2
- package/src/lib/auth/accounts.ts +2 -2
- package/src/lib/auth/request.ts +19 -7
- package/src/lib/auth/return.ts +32 -0
- package/src/lib/auth.ts +17 -4
- package/src/lib/calendar/board.ts +12 -1
- package/src/lib/calendar/cards.ts +6 -4
- package/src/lib/calendar/member-form.ts +152 -0
- package/src/lib/calendar/requests.ts +284 -0
- package/src/lib/calendar/schedule.ts +1 -1
- package/src/lib/editor/auth.ts +5 -3
- package/src/lib/editor/schemas.ts +2 -1
- package/src/lib/editor/store.ts +5 -3
- package/src/lib/email/service.ts +16 -3
- package/src/lib/email/store.ts +3 -1
- package/src/lib/email/templates.ts +12 -0
- package/src/lib/mobile/calendar.ts +6 -2
- package/src/lib/permissions/catalog.ts +84 -36
- package/src/lib/permissions/service.ts +11 -8
- package/src/lib/repo/migrations.ts +6 -0
- package/src/lib/repo/sql.ts +23 -5
- package/src/lib/repo/types.ts +5 -1
- package/src/lib/roles.ts +46 -4
- package/src/lib/schema.ts +43 -2
- package/src/lib/section-targets.ts +10 -0
- package/src/lib/sections.ts +7 -5
- package/src/lib/sync/catalog.ts +3 -0
- package/src/middleware.ts +8 -1
- package/src/pages/admin/app-auth.astro +5 -3
- package/src/pages/admin/calendar/events/[id].astro +249 -2
- package/src/pages/admin/calendar/events/index.astro +165 -12
- package/src/pages/admin/index.astro +22 -0
- package/src/pages/admin/login.astro +37 -16
- package/src/pages/admin/roles.astro +3 -0
- package/src/pages/admin/users.astro +15 -14
- package/src/pages/api/calendar/events/index.ts +8 -1
- package/src/pages/api/search.ts +3 -1
- package/src/pages/author/[slug].astro +3 -1
- package/src/pages/members/_EventForm.astro +89 -0
- package/src/pages/members/_Layout.astro +76 -0
- package/src/pages/members/_http.ts +25 -0
- package/src/pages/members/events/[id].astro +86 -0
- package/src/pages/members/events/index.astro +218 -0
- package/src/pages/members/events/new.astro +48 -0
- package/src/pages/members/index.ts +6 -0
- package/src/server/index.ts +111 -20
- package/src/theme-sdk/calendar.ts +6 -2
- package/src/theme-sdk/fields.mjs +7 -2
- package/src/virtual.d.ts +4 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
import {
|
|
2
|
+
import { isStaffRole, roleLabel } from '../../lib/roles';
|
|
3
3
|
// Editor-app sign-in (OAuth 2.0 authorization code + PKCE, RFC 8252). The
|
|
4
4
|
// app opens this page in the system browser; the middleware has already sent
|
|
5
5
|
// signed-out visitors to /admin/login?next=… and back. Approving issues a
|
|
@@ -59,7 +59,9 @@ if (Astro.request.method === 'POST' && params && user) {
|
|
|
59
59
|
else if (!checkConsentTicket(String(form.get('ticket') ?? ''), session, params)) error = 'This sign-in page expired. Start again from the app.';
|
|
60
60
|
else if (form.get('op') === 'cancel') return leave(redirectWith(params.redirectUri, { error: 'access_denied', error_description: 'Sign-in was cancelled.', state: params.state, iss }));
|
|
61
61
|
else if (form.get('op') === 'approve') {
|
|
62
|
-
|
|
62
|
+
// only staff get app tokens (the session check already says so; belt and braces)
|
|
63
|
+
if (!isStaffRole(user.role)) error = 'This account can’t use the editor app.';
|
|
64
|
+
else if (!await repo.email.rateLimit(`editor:approve:${user.id}`, 10, 60_000)) error = 'Too many sign-ins in a minute. Wait a moment and try again.';
|
|
63
65
|
else {
|
|
64
66
|
const version = await repo.email.sessionVersion(user.id);
|
|
65
67
|
if (version === null) error = 'Your account changed. Sign in again.';
|
|
@@ -138,7 +140,7 @@ const can = [
|
|
|
138
140
|
<b>{user.name}</b>
|
|
139
141
|
<span>{user.email}</span>
|
|
140
142
|
</span>
|
|
141
|
-
<span class="consent-role"><i class={owner ? 'is-owner' : ''} aria-hidden="true"></i>{user ?
|
|
143
|
+
<span class="consent-role"><i class={owner ? 'is-owner' : ''} aria-hidden="true"></i>{user ? roleLabel(user.role) : 'Owner'}</span>
|
|
142
144
|
</div>
|
|
143
145
|
|
|
144
146
|
<p class="consent-label">The app will be able to</p>
|
|
@@ -9,7 +9,13 @@ import { CALENDAR, eventUrl } from '../../../../lib/calendar/config';
|
|
|
9
9
|
import { mergeEventDraft } from '../../../../lib/drafts';
|
|
10
10
|
import { safeColor } from '../../../../lib/calendar/events';
|
|
11
11
|
import { mapsStatus } from '../../../../lib/maps/config';
|
|
12
|
+
import { approveEventRequest, declineEventRequest, setEventHosts } from '../../../../lib/calendar/requests';
|
|
13
|
+
import { accessFor, forbiddenUnless } from '../../../../lib/permissions/access';
|
|
14
|
+
import type { Permission } from '../../../../lib/permissions/catalog';
|
|
15
|
+
import { ApiError } from '../../../../lib/api';
|
|
16
|
+
import { roleLabel } from '../../../../lib/roles';
|
|
12
17
|
import ThemeScript from '../../../../components/ThemeScript.astro';
|
|
18
|
+
import Modal from '../../../../components/Modal.astro';
|
|
13
19
|
import PostEditor from '../../../../components/editor/PostEditor';
|
|
14
20
|
import '../../../../styles/admin.css';
|
|
15
21
|
import '../../../../styles/editor.css';
|
|
@@ -18,20 +24,82 @@ import '../../../../styles/editor.css';
|
|
|
18
24
|
// publish flow as posts, with the event's settings (When · Where ·
|
|
19
25
|
// Registration · Organize) on the page above its content. Live events stage
|
|
20
26
|
// edits until Update, like posts.
|
|
27
|
+
//
|
|
28
|
+
// A bar above the editor carries what the editor island doesn't: a member's
|
|
29
|
+
// request on this event (lib/calendar/requests.ts) with Approve / Decline
|
|
30
|
+
// for staff who can publish, and the event's hosts.
|
|
21
31
|
if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
22
32
|
|
|
23
33
|
const repo = await getRepo(Astro.locals);
|
|
34
|
+
const self = `/admin/calendar/events/${encodeURIComponent(Astro.params.id!)}`;
|
|
35
|
+
|
|
36
|
+
if (Astro.request.method === 'POST') {
|
|
37
|
+
const form = await Astro.request.formData();
|
|
38
|
+
const op = String(form.get('op'));
|
|
39
|
+
const needs: Permission = op === 'approve' || op === 'decline' ? 'content.publish' : 'content.edit';
|
|
40
|
+
const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, needs);
|
|
41
|
+
if (denied) return denied;
|
|
42
|
+
const access = (await accessFor(Astro.request, Astro.cookies, repo))!;
|
|
43
|
+
const who = { id: access.userId, can: (p: string) => access.can(p as Permission) };
|
|
44
|
+
const hooks = { url: Astro.url, locals: Astro.locals };
|
|
45
|
+
const id = Astro.params.id!;
|
|
46
|
+
const back = (key: 'notice' | 'error', text: string) => Astro.redirect(`${self}?${new URLSearchParams({ [key]: text })}`);
|
|
47
|
+
try {
|
|
48
|
+
const before = await repo.getEvent(id);
|
|
49
|
+
if (op === 'approve') {
|
|
50
|
+
await approveEventRequest(repo, who, id, hooks, Number(form.get('at')) || undefined);
|
|
51
|
+
return back('notice', before?.request?.kind === 'changes' ? 'Approved. The changes are live.' : 'Approved and published.');
|
|
52
|
+
}
|
|
53
|
+
if (op === 'decline') {
|
|
54
|
+
await declineEventRequest(repo, who, id, String(form.get('reply') || ''), hooks, Number(form.get('at')) || undefined);
|
|
55
|
+
const wasLive = before?.status === 'published' || before?.status === 'scheduled';
|
|
56
|
+
return back('notice', before?.request?.kind === 'changes' ? 'Declined. The suggested changes were dropped.' : wasLive ? 'Declined.' : 'Declined. It stays a draft until it’s sent again.');
|
|
57
|
+
}
|
|
58
|
+
if (op === 'hosts') {
|
|
59
|
+
const e = await setEventHosts(repo, who, id, form.getAll('hostIds').map(String).filter(Boolean));
|
|
60
|
+
return back('notice', e.hostIds.length ? `Hosts saved (${e.hostIds.length}).` : 'Hosts removed.');
|
|
61
|
+
}
|
|
62
|
+
} catch (err) {
|
|
63
|
+
return back('error', err instanceof ApiError ? err.message : 'That didn’t work. Try again.');
|
|
64
|
+
}
|
|
65
|
+
return Astro.redirect(self);
|
|
66
|
+
}
|
|
67
|
+
|
|
24
68
|
const event = await repo.getEvent(Astro.params.id!);
|
|
25
69
|
if (!event) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
26
70
|
|
|
27
71
|
const editable = mergeEventDraft(event);
|
|
28
|
-
const [settings, calendars] = await Promise.all([repo.getSettings(), repo.listCalendars()]);
|
|
72
|
+
const [settings, calendars, users, access] = await Promise.all([repo.getSettings(), repo.listCalendars(), repo.listUsers(), accessFor(Astro.request, Astro.cookies, repo)]);
|
|
29
73
|
const previewPath = `/preview/event/${event.id}`;
|
|
30
74
|
const sharePath = `${previewPath}?token=${createPreviewToken('event', event.id)}`;
|
|
31
75
|
// other languages this document can be written in (acms({ i18n }))
|
|
32
76
|
const trLanguages = extraLocales().map((code) => ({ code, name: localeName(code, true) }));
|
|
33
77
|
const trMain = { code: I18N.defaultLocale, name: localeName(I18N.defaultLocale || 'en', true) };
|
|
34
78
|
const trDrafts = trLanguages.length > 0 && translationEngine((await repo.getSettings()).ai, Astro.locals) !== null;
|
|
79
|
+
|
|
80
|
+
// the request bar: a member's request, the event's hosts, and a one-time flash
|
|
81
|
+
const canPublish = access?.can('content.publish') ?? false;
|
|
82
|
+
const canEdit = access?.can('content.edit') ?? false;
|
|
83
|
+
const nameOf = (id: string) => { const u = users.find((x) => x.id === id); return u ? u.name || u.email : ''; };
|
|
84
|
+
const req = event.request;
|
|
85
|
+
const pending = req?.state === 'pending' ? req : null;
|
|
86
|
+
const declined = req?.state === 'declined' ? req : null;
|
|
87
|
+
const requester = req ? nameOf(req.by) || 'a member' : '';
|
|
88
|
+
const cap = (t: string) => t.charAt(0).toUpperCase() + t.slice(1);
|
|
89
|
+
const isLive = event.status === 'published' || event.status === 'scheduled';
|
|
90
|
+
const hosts = event.hostIds.map((id) => ({ id, name: nameOf(id) })).filter((h) => h.name);
|
|
91
|
+
const people = [...users].sort((a, b) => (a.name || a.email).localeCompare(b.name || b.email));
|
|
92
|
+
const notice = Astro.url.searchParams.get('notice') ?? '';
|
|
93
|
+
const error = Astro.url.searchParams.get('error') ?? '';
|
|
94
|
+
const showBar = Boolean(req) || canEdit || hosts.length > 0 || Boolean(notice || error);
|
|
95
|
+
const ago = (ts: number) => {
|
|
96
|
+
const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
|
97
|
+
if (s < 60) return 'just now';
|
|
98
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
99
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
|
100
|
+
return `${Math.floor(s / 86400)}d ago`;
|
|
101
|
+
};
|
|
102
|
+
const approveLabel = pending?.kind === 'changes' ? 'Approve changes' : isLive ? 'Approve' : 'Approve and publish';
|
|
35
103
|
---
|
|
36
104
|
|
|
37
105
|
<!doctype html>
|
|
@@ -44,8 +112,62 @@ const trDrafts = trLanguages.length > 0 && translationEngine((await repo.getSett
|
|
|
44
112
|
<title>{event.title || 'Untitled event'} · {BRAND.name} editor</title>
|
|
45
113
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
46
114
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
|
115
|
+
{showBar && (
|
|
116
|
+
<script is:inline>
|
|
117
|
+
// The bar's forms leave the editor on purpose (approving publishes what's
|
|
118
|
+
// staged, declining drops it, hosts reload the page), so the editor's
|
|
119
|
+
// "unpublished changes" prompt must not stop them. Registered before
|
|
120
|
+
// the editor (and as a capture listener, which runs first) so it can
|
|
121
|
+
// stop the editor's own beforeunload handler for these submits only.
|
|
122
|
+
window.addEventListener('beforeunload', (e) => { if (window.__evreqLeaving) e.stopImmediatePropagation(); }, { capture: true });
|
|
123
|
+
</script>
|
|
124
|
+
)}
|
|
47
125
|
</head>
|
|
48
|
-
<body>
|
|
126
|
+
<body class={showBar ? 'has-evreq' : undefined}>
|
|
127
|
+
{showBar && (
|
|
128
|
+
<header class="evreq" id="evreq" aria-label="Requests and hosts">
|
|
129
|
+
{(notice || error) && <p class={`evreq-flash${error ? ' is-error' : ''}`} role={error ? 'alert' : 'status'}>{error || notice}</p>}
|
|
130
|
+
{pending && (
|
|
131
|
+
<div class="evreq-req">
|
|
132
|
+
<div class="evreq-text">
|
|
133
|
+
<p class="evreq-line">
|
|
134
|
+
<b class="evreq-state">{pending.kind === 'new' ? 'Needs approval' : 'Changes to review'}</b>
|
|
135
|
+
<span>Requested by <b>{requester}</b> · <time datetime={new Date(pending.at).toISOString()}>{ago(pending.at)}</time></span>
|
|
136
|
+
{pending.kind === 'changes' && (
|
|
137
|
+
<span class="evreq-muted">Suggested changes to this live event · <a href={eventUrl(event.slug)} target="_blank" rel="noopener">See what’s live ↗</a></span>
|
|
138
|
+
)}
|
|
139
|
+
</p>
|
|
140
|
+
{pending.note && <p class="evreq-note">“{pending.note}”</p>}
|
|
141
|
+
</div>
|
|
142
|
+
{canPublish ? (
|
|
143
|
+
<div class="evreq-acts">
|
|
144
|
+
<button class="btn sm ghost" type="button" data-modal="evreq-decline">Decline…</button>
|
|
145
|
+
<form method="post" data-evreq>
|
|
146
|
+
<input type="hidden" name="op" value="approve" /><input type="hidden" name="at" value={pending.at} />
|
|
147
|
+
<button class="btn sm" type="submit">{approveLabel}</button>
|
|
148
|
+
</form>
|
|
149
|
+
</div>
|
|
150
|
+
) : (
|
|
151
|
+
<span class="evreq-muted">Someone whose role can publish approves this.</span>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
155
|
+
{declined && (
|
|
156
|
+
<p class="evreq-declined">
|
|
157
|
+
{declined.kind === 'new' ? 'Request declined' : 'Suggested changes declined'}
|
|
158
|
+
{declined.reviewedAt ? ` ${ago(declined.reviewedAt)}` : ''}{declined.reviewedBy && nameOf(declined.reviewedBy) ? ` by ${nameOf(declined.reviewedBy)}` : ''}.
|
|
159
|
+
{declined.reply ? <> Reply to {requester}: <q>{declined.reply}</q></> : ' No note was sent.'}
|
|
160
|
+
{declined.kind === 'new' && !isLive && ` It stays a draft until ${requester} sends it again.`}
|
|
161
|
+
</p>
|
|
162
|
+
)}
|
|
163
|
+
<div class="evreq-hosts">
|
|
164
|
+
<span class="evreq-label">Hosts</span>
|
|
165
|
+
{hosts.length ? hosts.map((h) => <span class="evreq-chip">{h.name}</span>) : <span class="evreq-muted">None</span>}
|
|
166
|
+
{canEdit && <button class="btn sm ghost" type="button" data-modal="evreq-hosts">{hosts.length ? 'Edit' : 'Add hosts'}</button>}
|
|
167
|
+
</div>
|
|
168
|
+
</header>
|
|
169
|
+
)}
|
|
170
|
+
|
|
49
171
|
<PostEditor
|
|
50
172
|
client:only="react"
|
|
51
173
|
kind="event"
|
|
@@ -64,5 +186,130 @@ const trDrafts = trLanguages.length > 0 && translationEngine((await repo.getSett
|
|
|
64
186
|
maps={mapsStatus(Astro.locals)}
|
|
65
187
|
site={{ title: settings.siteTitle, url: settings.siteUrl }}
|
|
66
188
|
/>
|
|
189
|
+
|
|
190
|
+
{pending && canPublish && (
|
|
191
|
+
<Modal id="evreq-decline" title={pending.kind === 'new' ? 'Decline this request' : 'Decline these changes'}>
|
|
192
|
+
<form method="post" data-evreq>
|
|
193
|
+
<input type="hidden" name="op" value="decline" /><input type="hidden" name="at" value={pending.at} />
|
|
194
|
+
<p class="hint evreq-modal-hint">
|
|
195
|
+
{pending.kind === 'changes'
|
|
196
|
+
? `${cap(requester)}’s suggested changes are dropped. The event stays as it is.`
|
|
197
|
+
: isLive ? `${cap(requester)} hears that it was declined. The event stays as it is.`
|
|
198
|
+
: `It stays a draft that ${requester} can change and send again.`}
|
|
199
|
+
</p>
|
|
200
|
+
<label class="field"><span>Note to {requester} <em class="hint">optional</em></span><textarea name="reply" rows="3" maxlength="1000" placeholder="Thanks for asking! That weekend is taken. Could it work the week after?"></textarea></label>
|
|
201
|
+
<div class="modal-foot">
|
|
202
|
+
<button class="btn ghost" type="button" data-close>Cancel</button>
|
|
203
|
+
<button class="btn primary" type="submit">Decline</button>
|
|
204
|
+
</div>
|
|
205
|
+
</form>
|
|
206
|
+
</Modal>
|
|
207
|
+
)}
|
|
208
|
+
|
|
209
|
+
{canEdit && (
|
|
210
|
+
<Modal id="evreq-hosts" title="Hosts">
|
|
211
|
+
<form method="post" data-evreq>
|
|
212
|
+
<input type="hidden" name="op" value="hosts" />
|
|
213
|
+
<p class="hint evreq-modal-hint">Hosts see this event on My events and can cancel its dates. Up to 10.</p>
|
|
214
|
+
{people.length > 8 && <input class="evreq-find" type="search" placeholder="Find someone…" aria-label="Find someone" data-evreq-find />}
|
|
215
|
+
<div class="evreq-people">
|
|
216
|
+
{people.map((u) => (
|
|
217
|
+
<label class="check-row" data-q={`${u.name} ${u.email} ${roleLabel(u.role)}`.toLowerCase()}>
|
|
218
|
+
<input type="checkbox" name="hostIds" value={u.id} checked={event.hostIds.includes(u.id)} />
|
|
219
|
+
<span><b>{u.name || u.email}</b> <small>{roleLabel(u.role)}</small></span>
|
|
220
|
+
</label>
|
|
221
|
+
))}
|
|
222
|
+
{people.length === 0 && <p class="hint">No one on the team yet. <a href="/admin/users?new=1">Invite someone</a></p>}
|
|
223
|
+
</div>
|
|
224
|
+
<div class="modal-foot">
|
|
225
|
+
<button class="btn ghost" type="button" data-close>Cancel</button>
|
|
226
|
+
<button class="btn primary" type="submit">Save hosts</button>
|
|
227
|
+
</div>
|
|
228
|
+
</form>
|
|
229
|
+
</Modal>
|
|
230
|
+
)}
|
|
231
|
+
|
|
232
|
+
<script>
|
|
233
|
+
const bar = document.getElementById('evreq');
|
|
234
|
+
// the editor's settings sidebar is fixed under its own top bar: move it below this bar too
|
|
235
|
+
if (bar) new ResizeObserver(() => document.body.style.setProperty('--evreq-h', `${bar.offsetHeight}px`)).observe(bar);
|
|
236
|
+
|
|
237
|
+
// a flash shows once: drop it from the address so a reload doesn't repeat it
|
|
238
|
+
const url = new URL(location.href);
|
|
239
|
+
if (url.searchParams.has('notice') || url.searchParams.has('error')) {
|
|
240
|
+
url.searchParams.delete('notice');
|
|
241
|
+
url.searchParams.delete('error');
|
|
242
|
+
history.replaceState(history.state, '', url);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// let autosave finish before leaving, then skip the editor's leave prompt
|
|
246
|
+
const busy = () => document.querySelector('.pe-savestate.dirty, .pe-savestate.saving');
|
|
247
|
+
const w = window as unknown as { __evreqLeaving?: boolean };
|
|
248
|
+
document.addEventListener('submit', async (e) => {
|
|
249
|
+
if (!bar) return;
|
|
250
|
+
const form = e.target instanceof HTMLFormElement && e.target.matches('[data-evreq]') ? e.target : null;
|
|
251
|
+
if (!form) return;
|
|
252
|
+
if (!busy()) { w.__evreqLeaving = true; return; }
|
|
253
|
+
e.preventDefault();
|
|
254
|
+
for (let i = 0; i < 50 && busy(); i++) await new Promise((r) => setTimeout(r, 120));
|
|
255
|
+
if (busy()) { alert('Your latest edits haven’t saved yet. Check the editor, then try again.'); return; }
|
|
256
|
+
w.__evreqLeaving = true;
|
|
257
|
+
form.submit();
|
|
258
|
+
});
|
|
259
|
+
// back from the next page (back/forward cache): the editor guards leaving again
|
|
260
|
+
addEventListener('pageshow', () => { w.__evreqLeaving = false; });
|
|
261
|
+
|
|
262
|
+
// find someone in a long team list
|
|
263
|
+
document.querySelector<HTMLInputElement>('[data-evreq-find]')?.addEventListener('input', (e) => {
|
|
264
|
+
const needle = (e.target as HTMLInputElement).value.trim().toLowerCase();
|
|
265
|
+
document.querySelectorAll<HTMLElement>('.evreq-people [data-q]').forEach((row) => { row.hidden = !!needle && !(row.dataset.q ?? '').includes(needle); });
|
|
266
|
+
});
|
|
267
|
+
</script>
|
|
67
268
|
</body>
|
|
68
269
|
</html>
|
|
270
|
+
|
|
271
|
+
<style is:global>
|
|
272
|
+
/* the bar above the editor; the editor fills the rest of the screen */
|
|
273
|
+
body.has-evreq { height: 100vh; height: 100dvh; margin: 0; display: flex; flex-direction: column; overflow: hidden; }
|
|
274
|
+
body.has-evreq > astro-island { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
|
275
|
+
body.has-evreq .pe-shell { flex: 1; min-height: 0; height: auto; }
|
|
276
|
+
@media (min-width: 768px) { body.has-evreq .pe-sidebar { top: calc(52px + var(--evreq-h, 0px)); } }
|
|
277
|
+
@media (max-width: 767px) { body.has-evreq .pe-topbar { padding-top: 8px; } }
|
|
278
|
+
|
|
279
|
+
.evreq {
|
|
280
|
+
flex: none; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 20px;
|
|
281
|
+
padding: 8px 14px; background: var(--panel-2); border-bottom: 1px solid var(--line);
|
|
282
|
+
font-size: 13px; color: var(--text-2); max-height: 45vh; overflow-y: auto;
|
|
283
|
+
}
|
|
284
|
+
.evreq p { margin: 0; }
|
|
285
|
+
.evreq-flash { flex: 1 1 100%; font-weight: 600; color: var(--text); }
|
|
286
|
+
.evreq-flash.is-error { color: var(--red); }
|
|
287
|
+
.evreq-req { flex: 1 1 480px; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; }
|
|
288
|
+
.evreq-text { flex: 1 1 320px; min-width: 0; }
|
|
289
|
+
.evreq-line { display: flex; flex-wrap: wrap; gap: 2px 10px; align-items: baseline; }
|
|
290
|
+
.evreq-line b { color: var(--text); font-weight: 600; }
|
|
291
|
+
.evreq-line .evreq-state { color: var(--amber); }
|
|
292
|
+
.evreq-muted { color: var(--muted); }
|
|
293
|
+
.evreq-muted a { color: var(--accent-deep); }
|
|
294
|
+
.evreq .evreq-note { margin-top: 2px; color: var(--text-2); font-style: italic; overflow-wrap: anywhere; }
|
|
295
|
+
.evreq-acts { display: flex; align-items: center; gap: 6px; flex: none; }
|
|
296
|
+
.evreq-acts form { margin: 0; }
|
|
297
|
+
.evreq-declined { flex: 1 1 480px; min-width: 0; color: var(--muted); overflow-wrap: anywhere; }
|
|
298
|
+
.evreq-declined q { color: var(--text-2); }
|
|
299
|
+
.evreq-hosts { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-left: auto; }
|
|
300
|
+
.evreq-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); margin-right: 2px; }
|
|
301
|
+
.evreq-chip { font-size: 12px; font-weight: 500; color: var(--text-2); background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-full); padding: 1px 9px; white-space: nowrap; }
|
|
302
|
+
.evreq-modal-hint { margin: 0 0 12px; }
|
|
303
|
+
.evreq-find { width: 100%; margin-bottom: 10px; }
|
|
304
|
+
.evreq-people { max-height: min(320px, 50vh); overflow-y: auto; margin: 0 -4px; padding: 2px 4px; }
|
|
305
|
+
.evreq-people .check-row { align-items: center; margin: 0; padding: 7px 4px; border-bottom: 1px solid var(--line); }
|
|
306
|
+
.evreq-people .check-row:last-child { border-bottom: 0; }
|
|
307
|
+
.evreq-people .check-row[hidden] { display: none; }
|
|
308
|
+
.evreq-people .check-row b { font-weight: 600; color: var(--text); }
|
|
309
|
+
.evreq-people .check-row small { color: var(--muted); font-size: 12px; margin-left: 4px; }
|
|
310
|
+
@media (max-width: 767px) {
|
|
311
|
+
.evreq { padding: calc(8px + env(safe-area-inset-top)) 12px 8px; }
|
|
312
|
+
.evreq-hosts { margin-left: 0; }
|
|
313
|
+
.evreq-acts .btn { min-height: 36px; }
|
|
314
|
+
}
|
|
315
|
+
</style>
|
|
@@ -19,13 +19,18 @@ import { siteOrigin } from '../../../../lib/seo';
|
|
|
19
19
|
import { siteLocale } from '../../../../lib/dates';
|
|
20
20
|
import { waitUntilFrom } from '../../../../lib/webhooks';
|
|
21
21
|
import { invalidateRedirectCache } from '../../../../middleware';
|
|
22
|
-
import { forbiddenUnless } from '../../../../lib/permissions/access';
|
|
22
|
+
import { accessFor, forbiddenUnless } from '../../../../lib/permissions/access';
|
|
23
23
|
import type { Permission } from '../../../../lib/permissions/catalog';
|
|
24
|
+
import { approveEventRequest, declineEventRequest, pendingRequests } from '../../../../lib/calendar/requests';
|
|
25
|
+
import { mergeEventDraft } from '../../../../lib/drafts';
|
|
26
|
+
import { ApiError } from '../../../../lib/api';
|
|
24
27
|
|
|
25
28
|
// Schedule → List: upcoming first, grouped by the month each event next
|
|
26
29
|
// happens. Subscribed calendars appear as read-only rows with a "Make an
|
|
27
30
|
// editable copy" action. New events open the quick-add dialog (with its mini
|
|
28
|
-
// month); subscribing lives in Calendar settings.
|
|
31
|
+
// month); subscribing lives in Calendar settings. Requests (shown while any
|
|
32
|
+
// wait) lists members' event requests (lib/calendar/requests.ts) for staff
|
|
33
|
+
// who can publish to approve or decline.
|
|
29
34
|
if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
30
35
|
|
|
31
36
|
const repo = await getRepo(Astro.locals);
|
|
@@ -67,6 +72,8 @@ if (Astro.request.method === 'POST') {
|
|
|
67
72
|
const hookOpts = { origin: siteOrigin(settings, Astro.url), waitUntil: waitUntilFrom(Astro.locals), onRedirect: invalidateRedirectCache };
|
|
68
73
|
// create, copy and import make drafts; bulk status changes that touch live events need Publish
|
|
69
74
|
let needs: Permission = 'content.edit';
|
|
75
|
+
// answering members' event requests needs Publish (approving puts them live)
|
|
76
|
+
if (op === 'approve' || op === 'decline') needs = 'content.publish';
|
|
70
77
|
if (op === 'bulk') {
|
|
71
78
|
const to = String(form.get('to'));
|
|
72
79
|
const before = await Promise.all(form.getAll('ids').map(String).filter(Boolean).map((id) => repo.getEvent(id)));
|
|
@@ -75,6 +82,30 @@ if (Astro.request.method === 'POST') {
|
|
|
75
82
|
const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, needs);
|
|
76
83
|
if (denied) return denied;
|
|
77
84
|
|
|
85
|
+
if (op === 'approve' || op === 'decline') {
|
|
86
|
+
const access = (await accessFor(Astro.request, Astro.cookies, repo))!;
|
|
87
|
+
const who = { id: access.userId, can: (p: string) => access.can(p as Permission) };
|
|
88
|
+
const hooks = { url: Astro.url, locals: Astro.locals };
|
|
89
|
+
const id = String(form.get('id') || '');
|
|
90
|
+
const toRequests = (notice: string) => {
|
|
91
|
+
const p = new URLSearchParams(Astro.url.search);
|
|
92
|
+
p.set('scope', 'requests');
|
|
93
|
+
p.set('notice', notice);
|
|
94
|
+
return Astro.redirect(`/admin/calendar/events?${p}`);
|
|
95
|
+
};
|
|
96
|
+
try {
|
|
97
|
+
const before = await repo.getEvent(id);
|
|
98
|
+
if (op === 'approve') {
|
|
99
|
+
const e = await approveEventRequest(repo, who, id, hooks, Number(form.get('at')) || undefined);
|
|
100
|
+
return toRequests(before?.request?.kind === 'changes' ? `Approved the changes to “${e.title}”. They’re live now.` : `Approved “${e.title}”. It’s published.`);
|
|
101
|
+
}
|
|
102
|
+
const e = await declineEventRequest(repo, who, id, String(form.get('reply') || ''), hooks, Number(form.get('at')) || undefined);
|
|
103
|
+
return toRequests(before?.request?.kind === 'changes' ? `Declined the changes to “${e.title}”.` : `Declined “${e.title}”.`);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
return toRequests(err instanceof ApiError ? err.message : 'Couldn’t answer that request. Try again.');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
78
109
|
if (op === 'create') {
|
|
79
110
|
const title = String(form.get('title') || 'Untitled event').slice(0, 300);
|
|
80
111
|
const date = String(form.get('date') || '');
|
|
@@ -156,12 +187,15 @@ if (Astro.request.method === 'POST') {
|
|
|
156
187
|
}
|
|
157
188
|
}
|
|
158
189
|
|
|
159
|
-
const [events, calendars] = await Promise.all([
|
|
190
|
+
const [events, calendars, requests, users, access] = await Promise.all([
|
|
191
|
+
repo.listEvents(), repo.listCalendars(), pendingRequests(repo), repo.listUsers(), accessFor(Astro.request, Astro.cookies, repo),
|
|
192
|
+
]);
|
|
193
|
+
const canApprove = access?.can('content.publish') ?? false;
|
|
160
194
|
const calById = new Map(calendars.map((c) => [c.id, c]));
|
|
161
195
|
const nativeCals = calendars.filter((c) => c.kind === 'native');
|
|
162
196
|
const feedCals = calendars.filter((c) => c.kind === 'feed');
|
|
163
197
|
|
|
164
|
-
const scope = (['upcoming', 'past', 'drafts', 'all'] as const).find((s) => s === Astro.url.searchParams.get('scope')) ?? 'upcoming';
|
|
198
|
+
const scope = (['upcoming', 'past', 'drafts', 'requests', 'all'] as const).find((s) => s === Astro.url.searchParams.get('scope')) ?? 'upcoming';
|
|
165
199
|
const calFilter = Astro.url.searchParams.get('cal') ?? '';
|
|
166
200
|
const q = (Astro.url.searchParams.get('q') ?? '').toLowerCase();
|
|
167
201
|
const notice = Astro.url.searchParams.get('notice') ?? '';
|
|
@@ -172,6 +206,7 @@ const counts = {
|
|
|
172
206
|
upcoming: rows.filter(isUpcoming).length,
|
|
173
207
|
past: rows.filter((r) => !isUpcoming(r)).length,
|
|
174
208
|
drafts: rows.filter((r) => r.e.status === 'draft').length,
|
|
209
|
+
requests: requests.length,
|
|
175
210
|
all: rows.length,
|
|
176
211
|
};
|
|
177
212
|
const selected = rows
|
|
@@ -179,6 +214,7 @@ const selected = rows
|
|
|
179
214
|
if (scope === 'upcoming' && !isUpcoming(r)) return false;
|
|
180
215
|
if (scope === 'past' && isUpcoming(r)) return false;
|
|
181
216
|
if (scope === 'drafts' && r.e.status !== 'draft') return false;
|
|
217
|
+
if (scope === 'requests') return false; // listed on their own below
|
|
182
218
|
if (calFilter && !r.e.calendarIds.includes(calFilter)) return false;
|
|
183
219
|
if (q && !`${r.e.title} ${r.e.slug} ${r.e.location.name}`.toLowerCase().includes(q)) return false;
|
|
184
220
|
return true;
|
|
@@ -203,10 +239,50 @@ const items: ContentItem[] = selected.map(({ e, o }) => {
|
|
|
203
239
|
image: e.cover,
|
|
204
240
|
viewHref: e.status === 'published' ? eventUrl(e.slug) : undefined,
|
|
205
241
|
featured: e.featured,
|
|
206
|
-
badges:
|
|
242
|
+
badges: [
|
|
243
|
+
...(e.cancelled ? ['Cancelled'] : []),
|
|
244
|
+
...(e.request?.state === 'pending' ? [e.request.kind === 'new' ? 'Needs approval' : 'Changes to review'] : []),
|
|
245
|
+
],
|
|
207
246
|
};
|
|
208
247
|
});
|
|
209
248
|
|
|
249
|
+
// members' requests waiting for staff (scope=requests), oldest first; dates
|
|
250
|
+
// are what the event would be once approved
|
|
251
|
+
const nameOf = new Map(users.map((u) => [u.id, u.name || u.email]));
|
|
252
|
+
const cap = (t: string) => t.charAt(0).toUpperCase() + t.slice(1);
|
|
253
|
+
const ago = (ts: number) => {
|
|
254
|
+
const s = Math.max(0, Math.floor((now - ts) / 1000));
|
|
255
|
+
if (s < 60) return 'just now';
|
|
256
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
257
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
|
258
|
+
return `${Math.floor(s / 86400)}d ago`;
|
|
259
|
+
};
|
|
260
|
+
const dayFmt = (() => {
|
|
261
|
+
try { return new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz }); }
|
|
262
|
+
catch { return new Intl.DateTimeFormat('en-US', { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' }); }
|
|
263
|
+
})();
|
|
264
|
+
const requestRows = scope !== 'requests' ? [] : requests
|
|
265
|
+
.map((e) => ({ e, shown: mergeEventDraft(e), req: e.request! }))
|
|
266
|
+
.filter(({ shown }) => (!calFilter || shown.calendarIds.includes(calFilter)) && (!q || `${shown.title} ${shown.slug} ${shown.location.name}`.toLowerCase().includes(q)))
|
|
267
|
+
.map(({ e, shown, req }) => {
|
|
268
|
+
const o = featuredOccurrence(shown, settings, now);
|
|
269
|
+
const f = o ? formatOccurrence(o, tz, locale) : null;
|
|
270
|
+
const from = nameOf.get(req.by) ?? 'a member';
|
|
271
|
+
return {
|
|
272
|
+
id: e.id,
|
|
273
|
+
href: `/admin/calendar/events/${e.id}`,
|
|
274
|
+
title: shown.title || 'Untitled event',
|
|
275
|
+
kind: req.kind,
|
|
276
|
+
at: req.at,
|
|
277
|
+
live: e.status === 'published' || e.status === 'scheduled',
|
|
278
|
+
from,
|
|
279
|
+
line: `${req.kind === 'new' ? 'New event' : 'Changes'} from ${from} · ${ago(req.at)}`,
|
|
280
|
+
note: req.note,
|
|
281
|
+
day: o ? dayFmt.format(o.start) : '',
|
|
282
|
+
time: f ? [f.time, describeRepeat(shown.rrule, shown.start)].filter(Boolean).join(' · ') : '',
|
|
283
|
+
};
|
|
284
|
+
});
|
|
285
|
+
|
|
210
286
|
// feed calendars: the next 60 days as read-only rows (upcoming/all scopes)
|
|
211
287
|
const feedErrors: string[] = [];
|
|
212
288
|
if ((scope === 'upcoming' || scope === 'all') && feedCals.length) {
|
|
@@ -255,13 +331,16 @@ const calHref = (id: string) => {
|
|
|
255
331
|
const str = p.toString();
|
|
256
332
|
return `/admin/calendar/events${str ? `?${str}` : ''}`;
|
|
257
333
|
};
|
|
258
|
-
|
|
334
|
+
// Requests shows while any wait (or while you're looking at it)
|
|
335
|
+
const SCOPES = ([['upcoming', 'Upcoming'], ['past', 'Past'], ['drafts', 'Drafts'], ['requests', 'Requests'], ['all', 'All']] as const)
|
|
336
|
+
.filter(([k]) => k !== 'requests' || counts.requests > 0 || scope === 'requests');
|
|
259
337
|
const chev = 'M6 9l6 6 6-6';
|
|
260
338
|
const tomorrow = tzParts(now + 86_400_000, tz);
|
|
261
339
|
const tomorrowIso = `${tomorrow.y}-${String(tomorrow.m).padStart(2, '0')}-${String(tomorrow.d).padStart(2, '0')}`;
|
|
262
340
|
const subBits = [
|
|
263
341
|
`${counts.upcoming} upcoming`,
|
|
264
342
|
counts.drafts ? `${counts.drafts} draft${counts.drafts === 1 ? '' : 's'}` : '',
|
|
343
|
+
counts.requests ? `${counts.requests} request${counts.requests === 1 ? '' : 's'} waiting` : '',
|
|
265
344
|
feedCals.length ? `${feedCals.length} subscribed calendar${feedCals.length === 1 ? '' : 's'}` : '',
|
|
266
345
|
].filter(Boolean);
|
|
267
346
|
---
|
|
@@ -335,7 +414,11 @@ const subBits = [
|
|
|
335
414
|
<div class="list-tools">
|
|
336
415
|
<div class="list-lead">
|
|
337
416
|
<nav class="seg ev-scope" aria-label="Show">
|
|
338
|
-
{SCOPES.map(([k, l]) =>
|
|
417
|
+
{SCOPES.map(([k, l]) => (
|
|
418
|
+
<a href={scopeHref(k)} aria-current={scope === k ? 'page' : undefined} class={scope === k ? 'on' : ''}>
|
|
419
|
+
{l}{k === 'requests' && counts.requests > 0 && <span class="ev-count" aria-label={`${counts.requests} waiting`}>{counts.requests}</span>}
|
|
420
|
+
</a>
|
|
421
|
+
))}
|
|
339
422
|
</nav>
|
|
340
423
|
<form method="get" class="list-search" role="search">
|
|
341
424
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" /></svg>
|
|
@@ -356,16 +439,86 @@ const subBits = [
|
|
|
356
439
|
</div>
|
|
357
440
|
)}
|
|
358
441
|
</div>
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
442
|
+
{scope === 'requests' ? (
|
|
443
|
+
<>
|
|
444
|
+
{requestRows.length > 0 && (
|
|
445
|
+
<section class="card bk-group">
|
|
446
|
+
<div class="bk-dayh"><b>Waiting for approval</b><span>{requestRows.length}</span></div>
|
|
447
|
+
{requestRows.map((r) => (
|
|
448
|
+
<div class="bk-row ev-req">
|
|
449
|
+
<a class="bk-when" href={r.href}>{r.day || 'No date'}<small>{r.time}</small></a>
|
|
450
|
+
<a class="bk-who" href={r.href}>
|
|
451
|
+
<b>{r.title}</b>
|
|
452
|
+
<small>{r.line}</small>
|
|
453
|
+
{r.note && <small class="ev-req-note">“{r.note}”</small>}
|
|
454
|
+
</a>
|
|
455
|
+
<span class="bk-st is-pending">{r.kind === 'new' ? 'Needs approval' : 'Changes to review'}</span>
|
|
456
|
+
<div class="bk-acts">
|
|
457
|
+
<a class="btn sm ghost" href={r.href}>Open</a>
|
|
458
|
+
{canApprove && (
|
|
459
|
+
<>
|
|
460
|
+
<button class="btn sm ghost" type="button" data-modal={`decline-${r.id}`}>Decline…</button>
|
|
461
|
+
<form method="post">
|
|
462
|
+
<input type="hidden" name="op" value="approve" /><input type="hidden" name="id" value={r.id} /><input type="hidden" name="at" value={r.at} />
|
|
463
|
+
<button class="btn sm" type="submit">Approve</button>
|
|
464
|
+
</form>
|
|
465
|
+
</>
|
|
466
|
+
)}
|
|
467
|
+
</div>
|
|
468
|
+
</div>
|
|
469
|
+
))}
|
|
470
|
+
</section>
|
|
471
|
+
)}
|
|
472
|
+
{requestRows.length > 0 && !canApprove && <p class="hint">Someone whose role can publish content approves these.</p>}
|
|
473
|
+
{requestRows.length === 0 && (
|
|
474
|
+
<p class="hint ev-req-empty">{counts.requests ? 'No requests match the current filters.' : 'Nothing waiting. When members ask for events or suggest changes, they show up here.'}</p>
|
|
475
|
+
)}
|
|
476
|
+
{canApprove && requestRows.map((r) => (
|
|
477
|
+
<Modal id={`decline-${r.id}`} title={r.kind === 'new' ? 'Decline this request' : 'Decline these changes'}>
|
|
478
|
+
<form method="post">
|
|
479
|
+
<input type="hidden" name="op" value="decline" /><input type="hidden" name="id" value={r.id} /><input type="hidden" name="at" value={r.at} />
|
|
480
|
+
<p class="hint">
|
|
481
|
+
{r.kind === 'changes'
|
|
482
|
+
? `${cap(r.from)}’s suggested changes to “${r.title}” are dropped. The event stays as it is.`
|
|
483
|
+
: r.live ? `${cap(r.from)} hears that it was declined. “${r.title}” stays as it is.`
|
|
484
|
+
: `“${r.title}” stays a draft that ${r.from} can change and send again.`}
|
|
485
|
+
</p>
|
|
486
|
+
<label class="field"><span>Note to {r.from} <em class="hint">optional</em></span><textarea name="reply" rows="3" maxlength="1000" placeholder="Thanks for asking! That weekend is taken. Could it work the week after?"></textarea></label>
|
|
487
|
+
<div class="modal-foot">
|
|
488
|
+
<button class="btn ghost" type="button" data-close>Cancel</button>
|
|
489
|
+
<button class="btn primary" type="submit">Decline</button>
|
|
490
|
+
</div>
|
|
491
|
+
</form>
|
|
492
|
+
</Modal>
|
|
493
|
+
))}
|
|
494
|
+
</>
|
|
495
|
+
) : (
|
|
496
|
+
<>
|
|
497
|
+
<ContentList items={items} noun="event" timeZone={settings.timezone || undefined} groupByMonth={true} empty="No events match the current filters." />
|
|
498
|
+
{items.length === 0 && (
|
|
499
|
+
<p class="hint" style="padding:24px 8px">
|
|
500
|
+
{scope === 'upcoming' ? 'Nothing coming up.' : 'No events match the current filters.'} <button class="linklike" type="button" data-new-event>Add an event</button>
|
|
501
|
+
</p>
|
|
502
|
+
)}
|
|
503
|
+
</>
|
|
364
504
|
)}
|
|
365
505
|
</>
|
|
366
506
|
)}
|
|
367
507
|
</Admin>
|
|
368
508
|
|
|
509
|
+
<style>
|
|
510
|
+
/* Requests: the waiting count on its tab, and each row's note */
|
|
511
|
+
.ev-count { font-size: 11px; font-weight: 700; color: var(--amber); background: var(--amber-soft); border-radius: var(--r-full); padding: 0 6px; min-width: 18px; text-align: center; }
|
|
512
|
+
.ev-req .bk-who small.ev-req-note { color: var(--text-2); font-style: italic; }
|
|
513
|
+
.ev-req .bk-acts { align-items: center; }
|
|
514
|
+
.ev-req .bk-acts form { margin: 0; }
|
|
515
|
+
.ev-req-empty { padding: 24px 8px; }
|
|
516
|
+
@media (max-width: 767px) {
|
|
517
|
+
.ev-req .bk-acts { grid-column: 1 / -1; justify-content: flex-end; }
|
|
518
|
+
.ev-req .bk-acts .btn { min-height: 36px; }
|
|
519
|
+
}
|
|
520
|
+
</style>
|
|
521
|
+
|
|
369
522
|
<script>
|
|
370
523
|
document.addEventListener('click', (e) => {
|
|
371
524
|
document.querySelectorAll<HTMLDetailsElement>('details.fmenu[open]').forEach((d) => {
|
|
@@ -7,6 +7,9 @@ import { OWNER_ENV } from '../../lib/auth';
|
|
|
7
7
|
import { validateRequestSession } from '../../lib/auth/request';
|
|
8
8
|
import { getTraffic, umamiReadEnabled } from '../../lib/umami';
|
|
9
9
|
import { collectionUrl } from '../../lib/content';
|
|
10
|
+
import { CALENDAR } from '../../lib/calendar/config';
|
|
11
|
+
import { pendingRequests } from '../../lib/calendar/requests';
|
|
12
|
+
import { accessFor } from '../../lib/permissions/access';
|
|
10
13
|
|
|
11
14
|
const coreVersion = userConfig.coreVersion;
|
|
12
15
|
const repo = await getRepo(Astro.locals);
|
|
@@ -21,6 +24,15 @@ const [stats, pages, posts, links, settings, nav, users] = await Promise.all([
|
|
|
21
24
|
repo.listUsers(),
|
|
22
25
|
]);
|
|
23
26
|
const traffic = await getTraffic(settings, 30);
|
|
27
|
+
// members' event requests waiting for someone who can approve them
|
|
28
|
+
const access = await accessFor(Astro.request, Astro.cookies, repo);
|
|
29
|
+
const eventRequests = CALENDAR.enabled && access?.can('content.publish') ? await pendingRequests(repo) : [];
|
|
30
|
+
const newRequests = eventRequests.filter((e) => e.request?.kind === 'new').length;
|
|
31
|
+
const changeRequests = eventRequests.length - newRequests;
|
|
32
|
+
const requestDetail = [
|
|
33
|
+
newRequests ? `${newRequests} new event${newRequests === 1 ? '' : 's'}` : '',
|
|
34
|
+
changeRequests ? `changes to ${changeRequests} event${changeRequests === 1 ? '' : 's'}` : '',
|
|
35
|
+
].filter(Boolean).join(' · ');
|
|
24
36
|
const me = sessionUserId && sessionUserId !== OWNER_ENV ? users.find((u) => u.id === sessionUserId) : null;
|
|
25
37
|
const firstName = me?.name.split(/\s+/)[0] ?? '';
|
|
26
38
|
|
|
@@ -155,6 +167,16 @@ const tiles = [
|
|
|
155
167
|
</div>
|
|
156
168
|
)}
|
|
157
169
|
|
|
170
|
+
{eventRequests.length > 0 && (
|
|
171
|
+
<div class="notice">
|
|
172
|
+
<div class="grow-text">
|
|
173
|
+
<b>{eventRequests.length} event request{eventRequests.length === 1 ? '' : 's'} waiting</b>
|
|
174
|
+
<span class="hint">{requestDetail}</span>
|
|
175
|
+
</div>
|
|
176
|
+
<a class="btn sm" href="/admin/calendar/events?scope=requests">Review</a>
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
|
|
158
180
|
<div class="kpis">
|
|
159
181
|
{kpis.map((k) => (
|
|
160
182
|
<div class="kpi">
|