@acms-subir/core 0.3.53 → 0.3.54
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 +12 -0
- package/cli/migrations.generated.mjs +18 -1
- package/docs/calendar.md +20 -2
- package/docs/server.md +61 -1
- package/integration.mjs +11 -5
- package/package.json +1 -1
- package/src/lib/api.ts +3 -0
- package/src/lib/backup/content.ts +4 -2
- package/src/lib/calendar/host-requests.ts +247 -0
- package/src/lib/calendar/member-form.ts +17 -11
- package/src/lib/calendar/member-words.ts +473 -0
- package/src/lib/calendar/members-config.ts +2 -2
- package/src/lib/calendar/moves.ts +81 -33
- package/src/lib/calendar/request-migration.ts +17 -0
- package/src/lib/calendar/request-store.ts +104 -0
- package/src/lib/calendar/requests.ts +24 -24
- package/src/lib/email/service.ts +12 -2
- package/src/lib/email/templates.ts +12 -2
- package/src/lib/repo/migrations.ts +4 -0
- package/src/lib/repo/sql.ts +7 -0
- package/src/lib/repo/types.ts +6 -1
- package/src/lib/schema.ts +47 -0
- package/src/pages/admin/calendar/events/index.astro +82 -6
- package/src/pages/admin/index.astro +6 -2
- package/src/pages/members/_EventForm.astro +33 -27
- package/src/pages/members/_Layout.astro +25 -5
- package/src/pages/members/_http.ts +29 -6
- package/src/pages/members/events/[id].astro +21 -20
- package/src/pages/members/events/index.astro +295 -101
- package/src/pages/members/events/new.astro +11 -10
- package/src/server/index.ts +53 -0
- package/src/virtual.d.ts +1 -1
package/src/lib/repo/types.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { EmailStore } from '../email/store';
|
|
|
10
10
|
import type { RsvpStaffNotice } from '../email/types';
|
|
11
11
|
import type { PushStore } from '../push/store';
|
|
12
12
|
import type { AnnouncementStore } from '../announcements/store';
|
|
13
|
+
import type { RequestStore } from '../calendar/request-store';
|
|
13
14
|
import type { Announcement } from '../announcements/types';
|
|
14
15
|
import type { EditorStore } from '../editor/store';
|
|
15
16
|
import type { PermissionStore } from '../permissions/store';
|
|
@@ -19,7 +20,7 @@ export type ImportRecordKind = 'post' | 'page' | 'user' | 'tag' | 'media' | 'red
|
|
|
19
20
|
export interface ImportRecord { runId: string; kind: ImportRecordKind; sourceId: string; acmsId: string; action: 'created' | 'updated'; stamp: number }
|
|
20
21
|
export interface DesignVersion { id: string; createdAt: number; author: string; summary: string; snapshot: Record<string, unknown> }
|
|
21
22
|
|
|
22
|
-
import type { Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Role, WidgetInstance, Calendar, CalEvent, ApiKey, ApiScope, Webhook, WebhookDelivery, Resource, BookingLink, Booking, BookingStatus, Place, EventLocation, EventRequest } from '../schema';
|
|
23
|
+
import type { Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Role, WidgetInstance, Calendar, CalEvent, ApiKey, ApiScope, Webhook, WebhookDelivery, Resource, BookingLink, Booking, BookingStatus, Place, EventLocation, EventRequest, RequestRecord } from '../schema';
|
|
23
24
|
|
|
24
25
|
export interface Stats {
|
|
25
26
|
publishedPages: number;
|
|
@@ -82,6 +83,8 @@ export interface ContentRepository {
|
|
|
82
83
|
readonly live: LiveStore;
|
|
83
84
|
readonly push: PushStore;
|
|
84
85
|
readonly announcements: AnnouncementStore;
|
|
86
|
+
// calendar requests on their own rows (lib/calendar/request-store.ts)
|
|
87
|
+
readonly requests: RequestStore;
|
|
85
88
|
readonly editor: EditorStore;
|
|
86
89
|
readonly permissions: PermissionStore;
|
|
87
90
|
readonly screens: ScreenStore;
|
|
@@ -372,6 +375,8 @@ export interface ContentRepository {
|
|
|
372
375
|
// saved TV screens, without their keys (each gets a new one); a backup
|
|
373
376
|
// without them leaves the current ones
|
|
374
377
|
screens?: Screen[];
|
|
378
|
+
// added with calendar requests; a backup without them leaves the current ones
|
|
379
|
+
eventRequests?: RequestRecord[];
|
|
375
380
|
}, authRecovery?: { issuer: string; subject: string; userId: string }): Promise<void>;
|
|
376
381
|
|
|
377
382
|
// imports (lib/import/apply.ts drives these). writeImported is the
|
package/src/lib/schema.ts
CHANGED
|
@@ -395,6 +395,53 @@ export const EventRequest = z.object({
|
|
|
395
395
|
});
|
|
396
396
|
export type EventRequest = z.infer<typeof EventRequest>;
|
|
397
397
|
|
|
398
|
+
// A request about the calendar kept on its own row (table event_requests,
|
|
399
|
+
// lib/calendar/request-store.ts), unlike the member requests above that
|
|
400
|
+
// stage an event draft:
|
|
401
|
+
// - 'new': a new event from anyone, without an account (source 'app' or 'web').
|
|
402
|
+
// Approving makes a draft event and stores its id in `eventId`.
|
|
403
|
+
// - 'change': anyone asks for a change to an event (`eventId`, one date in
|
|
404
|
+
// `key` or '' for the whole event); `note` says what should change.
|
|
405
|
+
// - 'move' / 'from-date': a host's move of one date, or a change from a date
|
|
406
|
+
// on (C3/C4); `data` holds the move.
|
|
407
|
+
// `userId` is the member who asked; `requester` is the person without an
|
|
408
|
+
// account (never both). `note` is their own words to staff, `reply` staff's
|
|
409
|
+
// last answer. Decided rows (and withdrawn ones, which set reviewedAt too)
|
|
410
|
+
// are deleted some days after `reviewedAt` (CalendarSettings.requests).
|
|
411
|
+
export const RequestKind = z.enum(['new', 'change', 'move', 'from-date']);
|
|
412
|
+
export type RequestKind = z.infer<typeof RequestKind>;
|
|
413
|
+
export const RequestState = z.enum(['pending', 'approved', 'declined', 'withdrawn']);
|
|
414
|
+
export type RequestState = z.infer<typeof RequestState>;
|
|
415
|
+
export const RequestSource = z.enum(['member', 'web', 'app']);
|
|
416
|
+
export type RequestSource = z.infer<typeof RequestSource>;
|
|
417
|
+
export const Requester = z.object({
|
|
418
|
+
name: z.string(),
|
|
419
|
+
email: z.string(),
|
|
420
|
+
phone: z.string().default(''),
|
|
421
|
+
// the language they asked in, for replies ('' = the main language)
|
|
422
|
+
locale: z.string().default(''),
|
|
423
|
+
});
|
|
424
|
+
export type Requester = z.infer<typeof Requester>;
|
|
425
|
+
export const RequestRecord = z.object({
|
|
426
|
+
id: z.string(),
|
|
427
|
+
kind: RequestKind,
|
|
428
|
+
state: RequestState.default('pending'),
|
|
429
|
+
source: RequestSource,
|
|
430
|
+
userId: z.string().default(''),
|
|
431
|
+
requester: Requester.nullable().default(null),
|
|
432
|
+
eventId: z.string().default(''),
|
|
433
|
+
// an occurrence key (same format as exdates) when it's about one date
|
|
434
|
+
key: z.string().default(''),
|
|
435
|
+
data: z.record(z.any()).default({}),
|
|
436
|
+
note: z.string().default(''),
|
|
437
|
+
reply: z.string().default(''),
|
|
438
|
+
reviewedBy: z.string().default(''),
|
|
439
|
+
reviewedAt: z.number().default(0),
|
|
440
|
+
createdAt: z.number(),
|
|
441
|
+
updatedAt: z.number(),
|
|
442
|
+
});
|
|
443
|
+
export type RequestRecord = z.infer<typeof RequestRecord>;
|
|
444
|
+
|
|
398
445
|
export const CalEvent = z.object({
|
|
399
446
|
id: z.string(),
|
|
400
447
|
slug: z.string(),
|
|
@@ -22,6 +22,7 @@ import { invalidateRedirectCache } from '../../../../middleware';
|
|
|
22
22
|
import { accessFor, forbiddenUnless } from '../../../../lib/permissions/access';
|
|
23
23
|
import type { Permission } from '../../../../lib/permissions/catalog';
|
|
24
24
|
import { approveEventRequest, declineEventRequest, pendingRequests } from '../../../../lib/calendar/requests';
|
|
25
|
+
import { approveDateRequest, dateRequestClashes, dateRequestTimes, declineDateRequest, type MoveRequestData } from '../../../../lib/calendar/host-requests';
|
|
25
26
|
import { mergeEventDraft } from '../../../../lib/drafts';
|
|
26
27
|
import { ApiError } from '../../../../lib/api';
|
|
27
28
|
|
|
@@ -72,7 +73,7 @@ if (Astro.request.method === 'POST') {
|
|
|
72
73
|
// create, copy and import make drafts; bulk status changes that touch live events need Publish
|
|
73
74
|
let needs: Permission = 'content.edit';
|
|
74
75
|
// answering members' event requests needs Publish (approving puts them live)
|
|
75
|
-
if (op === 'approve' || op === 'decline') needs = 'content.publish';
|
|
76
|
+
if (op === 'approve' || op === 'decline' || op === 'approve-date' || op === 'decline-date') needs = 'content.publish';
|
|
76
77
|
if (op === 'bulk') {
|
|
77
78
|
const to = String(form.get('to'));
|
|
78
79
|
const before = await Promise.all(form.getAll('ids').map(String).filter(Boolean).map((id) => repo.getEvent(id)));
|
|
@@ -81,7 +82,7 @@ if (Astro.request.method === 'POST') {
|
|
|
81
82
|
const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, needs);
|
|
82
83
|
if (denied) return denied;
|
|
83
84
|
|
|
84
|
-
if (op === 'approve' || op === 'decline') {
|
|
85
|
+
if (op === 'approve' || op === 'decline' || op === 'approve-date' || op === 'decline-date') {
|
|
85
86
|
const access = (await accessFor(Astro.request, Astro.cookies, repo))!;
|
|
86
87
|
const who = { id: access.userId, can: (p: string) => access.can(p as Permission) };
|
|
87
88
|
const hooks = { url: Astro.url, locals: Astro.locals };
|
|
@@ -93,6 +94,17 @@ if (Astro.request.method === 'POST') {
|
|
|
93
94
|
return Astro.redirect(`/admin/calendar/events?${p}`);
|
|
94
95
|
};
|
|
95
96
|
try {
|
|
97
|
+
// a host's date move (host-requests.ts): `id` is the request's
|
|
98
|
+
if (op === 'approve-date') {
|
|
99
|
+
const { moved } = await approveDateRequest(repo, who, id, hooks, Number(form.get('at')) || undefined);
|
|
100
|
+
const n = moved.clashes.length;
|
|
101
|
+
const shared = n ? ', and it shares a room with ' + n + ' other booking' + (n === 1 ? '' : 's') + ' at that time' : '';
|
|
102
|
+
return toRequests('Approved. “' + moved.event.title + '” has the date moved' + shared + '.');
|
|
103
|
+
}
|
|
104
|
+
if (op === 'decline-date') {
|
|
105
|
+
await declineDateRequest(repo, who, id, String(form.get('reply') || ''), hooks, Number(form.get('at')) || undefined);
|
|
106
|
+
return toRequests('Declined. The date stays as it was.');
|
|
107
|
+
}
|
|
96
108
|
const before = await repo.getEvent(id);
|
|
97
109
|
if (op === 'approve') {
|
|
98
110
|
const e = await approveEventRequest(repo, who, id, hooks, Number(form.get('at')) || undefined);
|
|
@@ -182,8 +194,9 @@ if (Astro.request.method === 'POST') {
|
|
|
182
194
|
}
|
|
183
195
|
}
|
|
184
196
|
|
|
185
|
-
const [events, calendars, requests, users, access] = await Promise.all([
|
|
197
|
+
const [events, calendars, requests, users, access, waitingDates] = await Promise.all([
|
|
186
198
|
repo.listEvents(), repo.listCalendars(), pendingRequests(repo), repo.listUsers(), accessFor(Astro.request, Astro.cookies, repo),
|
|
199
|
+
repo.requests.list({ kinds: ['move', 'from-date'], states: ['pending'], order: 'oldest', limit: -1 }),
|
|
187
200
|
]);
|
|
188
201
|
const canApprove = access?.can('content.publish') ?? false;
|
|
189
202
|
const calById = new Map(calendars.map((c) => [c.id, c]));
|
|
@@ -201,7 +214,7 @@ const counts = {
|
|
|
201
214
|
upcoming: rows.filter(isUpcoming).length,
|
|
202
215
|
past: rows.filter((r) => !isUpcoming(r)).length,
|
|
203
216
|
drafts: rows.filter((r) => r.e.status === 'draft').length,
|
|
204
|
-
requests: requests.length,
|
|
217
|
+
requests: requests.length + waitingDates.length,
|
|
205
218
|
all: rows.length,
|
|
206
219
|
};
|
|
207
220
|
const selected = rows
|
|
@@ -278,6 +291,26 @@ const requestRows = scope !== 'requests' ? [] : requests
|
|
|
278
291
|
};
|
|
279
292
|
});
|
|
280
293
|
|
|
294
|
+
// hosts' date moves waiting (scope=requests): what moves where, and what
|
|
295
|
+
// else holds its rooms then (C2 clashes; names are fine for staff)
|
|
296
|
+
const eventById = new Map(events.map((e) => [e.id, e]));
|
|
297
|
+
const roomNames = new Map((await repo.listResources()).map((r) => [r.id, r.name]));
|
|
298
|
+
const dateRows = scope !== 'requests' ? [] : await Promise.all(waitingDates
|
|
299
|
+
.filter((r) => eventById.has(r.eventId))
|
|
300
|
+
.filter((r) => { const e = eventById.get(r.eventId)!; return (!calFilter || e.calendarIds.includes(calFilter)) && (!q || `${e.title} ${e.slug}`.toLowerCase().includes(q)); })
|
|
301
|
+
.map(async (r) => {
|
|
302
|
+
const e = eventById.get(r.eventId)!;
|
|
303
|
+
const d = r.data as MoveRequestData;
|
|
304
|
+
const host = nameOf.get(r.userId) ?? 'a host';
|
|
305
|
+
const clashes = await dateRequestClashes(repo, r);
|
|
306
|
+
const clash = clashes.length ? `${[...new Set(clashes.map((c) => roomNames.get(c.resourceId) ?? 'A room'))].join(', ')} also held then by ${[...new Set(clashes.map((c) => c.title || 'something else'))].slice(0, 3).join(', ')}` : '';
|
|
307
|
+
return {
|
|
308
|
+
id: r.id, href: `/admin/calendar/events/${e.id}`, title: e.title || 'Untitled event', at: r.updatedAt, host,
|
|
309
|
+
...(await dateRequestTimes(repo, r)), back: Boolean(d.back),
|
|
310
|
+
line: `${d.back ? 'Move back' : 'Date move'} from ${host} · ${ago(r.createdAt)}`, note: r.note, publicNote: d.note, clash,
|
|
311
|
+
};
|
|
312
|
+
}));
|
|
313
|
+
|
|
281
314
|
// feed calendars: the next 60 days as read-only rows (upcoming/all scopes)
|
|
282
315
|
const feedErrors: string[] = [];
|
|
283
316
|
if ((scope === 'upcoming' || scope === 'all') && feedCals.length) {
|
|
@@ -464,8 +497,51 @@ const subBits = [
|
|
|
464
497
|
))}
|
|
465
498
|
</section>
|
|
466
499
|
)}
|
|
467
|
-
{
|
|
468
|
-
|
|
500
|
+
{dateRows.length > 0 && (
|
|
501
|
+
<section class="card bk-group">
|
|
502
|
+
<div class="bk-dayh"><b>Date changes from hosts</b><span>{dateRows.length}</span></div>
|
|
503
|
+
{dateRows.map((r) => (
|
|
504
|
+
<div class="bk-row ev-req">
|
|
505
|
+
<a class="bk-when" href={r.href}>{r.from || 'No date'}<small>{r.to ? '→ ' + r.to : ''}</small></a>
|
|
506
|
+
<a class="bk-who" href={r.href}>
|
|
507
|
+
<b>{r.title}</b>
|
|
508
|
+
<small>{r.line}</small>
|
|
509
|
+
{r.note && <small class="ev-req-note">“{r.note}”</small>}
|
|
510
|
+
{r.publicNote && <small>Note on the date: {r.publicNote}</small>}
|
|
511
|
+
{r.clash && <small class="ev-req-clash">{r.clash}</small>}
|
|
512
|
+
</a>
|
|
513
|
+
<span class="bk-st is-pending">{r.back ? 'Move back' : 'Date move'}</span>
|
|
514
|
+
<div class="bk-acts">
|
|
515
|
+
<a class="btn sm ghost" href={r.href}>Open</a>
|
|
516
|
+
{canApprove && (
|
|
517
|
+
<>
|
|
518
|
+
<button class="btn sm ghost" type="button" data-modal={`decline-date-${r.id}`}>Decline…</button>
|
|
519
|
+
<form method="post">
|
|
520
|
+
<input type="hidden" name="op" value="approve-date" /><input type="hidden" name="id" value={r.id} /><input type="hidden" name="at" value={r.at} />
|
|
521
|
+
<button class="btn sm" type="submit">Approve</button>
|
|
522
|
+
</form>
|
|
523
|
+
</>
|
|
524
|
+
)}
|
|
525
|
+
</div>
|
|
526
|
+
</div>
|
|
527
|
+
))}
|
|
528
|
+
</section>
|
|
529
|
+
)}
|
|
530
|
+
{(requestRows.length > 0 || dateRows.length > 0) && !canApprove && <p class="hint">Someone whose role can publish content approves these.</p>}
|
|
531
|
+
{canApprove && dateRows.map((r) => (
|
|
532
|
+
<Modal id={`decline-date-${r.id}`} title="Decline this date change">
|
|
533
|
+
<form method="post">
|
|
534
|
+
<input type="hidden" name="op" value="decline-date" /><input type="hidden" name="id" value={r.id} /><input type="hidden" name="at" value={r.at} />
|
|
535
|
+
<p class="hint">The date stays as it is, and {r.host} hears that it wasn’t approved.</p>
|
|
536
|
+
<label class="field"><span>Note to {r.host} <em class="hint">optional</em></span><textarea name="reply" rows="3" maxlength="1000" placeholder="That room is booked for the youth night. Could the week after work?"></textarea></label>
|
|
537
|
+
<div class="modal-foot">
|
|
538
|
+
<button class="btn ghost" type="button" data-close>Cancel</button>
|
|
539
|
+
<button class="btn primary" type="submit">Decline</button>
|
|
540
|
+
</div>
|
|
541
|
+
</form>
|
|
542
|
+
</Modal>
|
|
543
|
+
))}
|
|
544
|
+
{requestRows.length === 0 && dateRows.length === 0 && (
|
|
469
545
|
<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>
|
|
470
546
|
)}
|
|
471
547
|
{canApprove && requestRows.map((r) => (
|
|
@@ -29,9 +29,13 @@ const access = await accessFor(Astro.request, Astro.cookies, repo);
|
|
|
29
29
|
const eventRequests = CALENDAR.enabled && access?.can('content.publish') ? await pendingRequests(repo) : [];
|
|
30
30
|
const newRequests = eventRequests.filter((e) => e.request?.kind === 'new').length;
|
|
31
31
|
const changeRequests = eventRequests.length - newRequests;
|
|
32
|
+
// hosts' date moves waiting (lib/calendar/host-requests.ts)
|
|
33
|
+
const dateRequests = CALENDAR.enabled && access?.can('content.publish') ? await repo.requests.count({ kinds: ['move', 'from-date'], states: ['pending'] }) : 0;
|
|
34
|
+
const waitingRequests = eventRequests.length + dateRequests;
|
|
32
35
|
const requestDetail = [
|
|
33
36
|
newRequests ? `${newRequests} new event${newRequests === 1 ? '' : 's'}` : '',
|
|
34
37
|
changeRequests ? `changes to ${changeRequests} event${changeRequests === 1 ? '' : 's'}` : '',
|
|
38
|
+
dateRequests ? `${dateRequests} date change${dateRequests === 1 ? '' : 's'}` : '',
|
|
35
39
|
].filter(Boolean).join(' · ');
|
|
36
40
|
const me = sessionUserId && sessionUserId !== OWNER_ENV ? users.find((u) => u.id === sessionUserId) : null;
|
|
37
41
|
const firstName = me?.name.split(/\s+/)[0] ?? '';
|
|
@@ -167,10 +171,10 @@ const tiles = [
|
|
|
167
171
|
</div>
|
|
168
172
|
)}
|
|
169
173
|
|
|
170
|
-
{
|
|
174
|
+
{waitingRequests > 0 && (
|
|
171
175
|
<div class="notice">
|
|
172
176
|
<div class="grow-text">
|
|
173
|
-
<b>{
|
|
177
|
+
<b>{waitingRequests} event request{waitingRequests === 1 ? '' : 's'} waiting</b>
|
|
174
178
|
<span class="hint">{requestDetail}</span>
|
|
175
179
|
</div>
|
|
176
180
|
<a class="btn sm" href="/admin/calendar/events?scope=requests">Review</a>
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
import type { EventFieldConfig } from '../../lib/calendar/config';
|
|
3
3
|
import { MEMBER_FORM_LIMITS as L, repeatChoices, type MemberEventForm } from '../../lib/calendar/member-form';
|
|
4
|
+
import { memberStrings, type MemberStrings } from '../../lib/calendar/member-words';
|
|
5
|
+
import { fill } from '../../lib/live/words';
|
|
4
6
|
|
|
5
7
|
// The event request form (new requests and suggested changes). Plain HTML:
|
|
6
|
-
// it posts to the page it's on, with `op` saying what to do.
|
|
8
|
+
// it posts to the page it's on, with `op` saying what to do. `w` and `lang`
|
|
9
|
+
// word it in the page's language.
|
|
7
10
|
interface Props {
|
|
8
11
|
values: MemberEventForm;
|
|
9
12
|
/** the calendars members may choose (native, public); none hides the choice */
|
|
@@ -13,9 +16,12 @@ interface Props {
|
|
|
13
16
|
op: 'submit' | 'update';
|
|
14
17
|
submitLabel: string;
|
|
15
18
|
error?: string;
|
|
19
|
+
w?: MemberStrings;
|
|
20
|
+
lang?: string;
|
|
16
21
|
}
|
|
17
|
-
const { values: v, calendars, fields, op, submitLabel, error = '' } = Astro.props;
|
|
18
|
-
const
|
|
22
|
+
const { values: v, calendars, fields, op, submitLabel, error = '', w = memberStrings('en'), lang } = Astro.props;
|
|
23
|
+
const f = w.form;
|
|
24
|
+
const repeat = repeatChoices(v.repeat, v.date, lang);
|
|
19
25
|
const fieldType = (t: EventFieldConfig['type']) => (t === 'number' ? 'number' : t === 'url' ? 'url' : 'text');
|
|
20
26
|
---
|
|
21
27
|
|
|
@@ -23,25 +29,25 @@ const fieldType = (t: EventFieldConfig['type']) => (t === 'number' ? 'number' :
|
|
|
23
29
|
{error && <p class="pc-bk-error" role="alert">{error}</p>}
|
|
24
30
|
<input type="hidden" name="op" value={op} />
|
|
25
31
|
<label class="pc-bk-field">
|
|
26
|
-
<span>
|
|
32
|
+
<span>{f.title}</span>
|
|
27
33
|
<input name="title" required maxlength={L.title} value={v.title} />
|
|
28
34
|
</label>
|
|
29
35
|
<fieldset class="pc-mem-when">
|
|
30
|
-
<legend>
|
|
36
|
+
<legend>{f.when}</legend>
|
|
31
37
|
<div class="pc-mem-row">
|
|
32
|
-
<label class="pc-bk-field"><span>
|
|
33
|
-
<label class="pc-bk-field pc-mem-time"><span>
|
|
34
|
-
<label class="pc-bk-field pc-mem-time"><span>
|
|
38
|
+
<label class="pc-bk-field"><span>{f.date}</span><input type="date" name="date" required value={v.date} /></label>
|
|
39
|
+
<label class="pc-bk-field pc-mem-time"><span>{f.starts}</span><input type="time" name="startTime" value={v.startTime} /></label>
|
|
40
|
+
<label class="pc-bk-field pc-mem-time"><span>{f.ends} <em>{f.optional}</em></span><input type="time" name="endTime" value={v.endTime} /></label>
|
|
35
41
|
</div>
|
|
36
|
-
<label class="pc-bk-check"><input type="checkbox" name="allDay" value="1" checked={v.allDay} /> <span>
|
|
42
|
+
<label class="pc-bk-check"><input type="checkbox" name="allDay" value="1" checked={v.allDay} /> <span>{f.allDay} <small>{f.allDayHint}</small></span></label>
|
|
37
43
|
{v.days > 0 && (
|
|
38
44
|
<>
|
|
39
45
|
<input type="hidden" name="days" value={String(v.days)} />
|
|
40
|
-
<p class="pc-mem-hint">
|
|
46
|
+
<p class="pc-mem-hint">{fill(f.multiDay, { days: String(v.days + 1) })}</p>
|
|
41
47
|
</>
|
|
42
48
|
)}
|
|
43
49
|
<label class="pc-bk-field">
|
|
44
|
-
<span>
|
|
50
|
+
<span>{f.repeats}</span>
|
|
45
51
|
<select name="repeat">
|
|
46
52
|
{repeat.options.map((o) => <option value={o.value} selected={o.value === repeat.value}>{o.label}</option>)}
|
|
47
53
|
</select>
|
|
@@ -49,40 +55,40 @@ const fieldType = (t: EventFieldConfig['type']) => (t === 'number' ? 'number' :
|
|
|
49
55
|
</label>
|
|
50
56
|
</fieldset>
|
|
51
57
|
<label class="pc-bk-field">
|
|
52
|
-
<span>
|
|
53
|
-
<input name="where" maxlength={L.where} value={v.where} placeholder=
|
|
58
|
+
<span>{f.where}</span>
|
|
59
|
+
<input name="where" maxlength={L.where} value={v.where} placeholder={f.wherePlaceholder} />
|
|
54
60
|
</label>
|
|
55
61
|
<label class="pc-bk-field">
|
|
56
|
-
<span>
|
|
62
|
+
<span>{f.description}</span>
|
|
57
63
|
<textarea name="description" rows="6" maxlength={L.description}>{v.description}</textarea>
|
|
58
|
-
<small>
|
|
64
|
+
<small>{f.descriptionHint}</small>
|
|
59
65
|
</label>
|
|
60
66
|
{calendars.length > 0 && (
|
|
61
67
|
<label class="pc-bk-field">
|
|
62
|
-
<span>
|
|
68
|
+
<span>{f.calendar}</span>
|
|
63
69
|
<select name="calendarId">
|
|
64
|
-
<option value="">
|
|
70
|
+
<option value="">{f.notSure}</option>
|
|
65
71
|
{calendars.map((c) => <option value={c.id} selected={c.id === v.calendarId}>{c.name}</option>)}
|
|
66
72
|
</select>
|
|
67
73
|
</label>
|
|
68
74
|
)}
|
|
69
|
-
{fields.map((
|
|
75
|
+
{fields.map((fd) => (
|
|
70
76
|
<label class="pc-bk-field">
|
|
71
|
-
<span>{
|
|
72
|
-
{
|
|
73
|
-
<textarea name={`field_${
|
|
74
|
-
) :
|
|
75
|
-
<select name={`field_${
|
|
76
|
-
<option value="">
|
|
77
|
-
{
|
|
77
|
+
<span>{fd.label}</span>
|
|
78
|
+
{fd.type === 'textarea' ? (
|
|
79
|
+
<textarea name={`field_${fd.key}`} rows="3" maxlength={L.field}>{v.fields[fd.key] ?? ''}</textarea>
|
|
80
|
+
) : fd.type === 'select' ? (
|
|
81
|
+
<select name={`field_${fd.key}`}>
|
|
82
|
+
<option value="">{f.choose}</option>
|
|
83
|
+
{fd.options.map((o) => <option value={o} selected={o === v.fields[fd.key]}>{o}</option>)}
|
|
78
84
|
</select>
|
|
79
85
|
) : (
|
|
80
|
-
<input type={fieldType(
|
|
86
|
+
<input type={fieldType(fd.type)} name={`field_${fd.key}`} maxlength={fd.type === 'number' ? undefined : L.field} step={fd.type === 'number' ? 'any' : undefined} value={v.fields[fd.key] ?? ''} />
|
|
81
87
|
)}
|
|
82
88
|
</label>
|
|
83
89
|
))}
|
|
84
90
|
<label class="pc-bk-field">
|
|
85
|
-
<span>
|
|
91
|
+
<span>{f.note} <em>{f.staffOnly}</em></span>
|
|
86
92
|
<textarea name="note" rows="3" maxlength={L.note}>{v.note}</textarea>
|
|
87
93
|
</label>
|
|
88
94
|
<button class="pc-bk-submit" type="submit">{submitLabel}</button>
|
|
@@ -1,23 +1,27 @@
|
|
|
1
1
|
---
|
|
2
2
|
import Site from '../../layouts/Site.astro';
|
|
3
|
+
import { fill } from '../../lib/live/words';
|
|
4
|
+
import { memberStrings, type MemberStrings } from '../../lib/calendar/member-words';
|
|
3
5
|
|
|
4
6
|
// The frame of the built-in member pages: the site's own layout and theme,
|
|
5
7
|
// never indexed, plus who's signed in. `.pc-mem-*` classes are theme hooks;
|
|
6
|
-
// form controls reuse the booking pages' `.pc-bk-*` ones.
|
|
8
|
+
// form controls reuse the booking pages' `.pc-bk-*` ones. `w`: the page's
|
|
9
|
+
// words (memberLanguage in _http.ts), English by default.
|
|
7
10
|
interface Props {
|
|
8
11
|
title: string;
|
|
9
12
|
user: { name: string; email: string; staff: boolean };
|
|
13
|
+
w?: MemberStrings;
|
|
10
14
|
}
|
|
11
|
-
const { title, user } = Astro.props;
|
|
15
|
+
const { title, user, w = memberStrings('en') } = Astro.props;
|
|
12
16
|
---
|
|
13
17
|
|
|
14
18
|
<Site title={title} noindex staffPreview>
|
|
15
19
|
<div class="pc-mem">
|
|
16
20
|
<div class="pc-mem-top">
|
|
17
|
-
<span>
|
|
21
|
+
<span>{fill(w.signedInAs, { name: user.name || user.email })}</span>
|
|
18
22
|
<span class="pc-mem-top-links">
|
|
19
|
-
{user.staff && <a href="/admin">
|
|
20
|
-
<form method="post" action="/admin/logout"><button type="submit">
|
|
23
|
+
{user.staff && <a href="/admin">{w.openAdmin}</a>}
|
|
24
|
+
<form method="post" action="/admin/logout"><button type="submit">{w.signOut}</button></form>
|
|
21
25
|
</span>
|
|
22
26
|
</div>
|
|
23
27
|
<slot />
|
|
@@ -48,6 +52,7 @@ const { title, user } = Astro.props;
|
|
|
48
52
|
.pc-mem-state::before { content: ''; width: 7px; height: 7px; border-radius: 2px; background: currentColor; }
|
|
49
53
|
.pc-mem-state.is-pending { color: var(--warning); }
|
|
50
54
|
.pc-mem-state.is-declined { color: var(--danger); }
|
|
55
|
+
.pc-mem-state.is-approved { color: var(--success); }
|
|
51
56
|
.pc-mem-reply { margin: 10px 0 0; padding: 9px 12px; background: var(--soft); border-radius: var(--r-sm); font-size: 14px; white-space: pre-line; }
|
|
52
57
|
.pc-mem > .pc-mem-reply { margin: 0; }
|
|
53
58
|
.pc-mem-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
|
@@ -73,5 +78,20 @@ const { title, user } = Astro.props;
|
|
|
73
78
|
.pc-mem-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; }
|
|
74
79
|
.pc-mem-when:has(input[name='allDay']:checked) .pc-mem-time { opacity: 0.45; }
|
|
75
80
|
.pc-mem-hint { color: var(--muted); font-size: 13px; margin: 0; }
|
|
81
|
+
/* one date's controls on its row (Move…, then Cancel); an open form takes the line under it */
|
|
82
|
+
.pc-mem-dates li { flex-wrap: wrap; justify-content: flex-start; column-gap: 6px; }
|
|
83
|
+
.pc-mem-dates li > span:first-child { flex: 1 1 16rem; }
|
|
84
|
+
.pc-mem-date-acts { display: inline-flex; flex-wrap: wrap; gap: 6px; align-items: center; order: 3; }
|
|
85
|
+
.pc-mem-dates em.is-pending { color: var(--warning); font-weight: 500; }
|
|
86
|
+
.pc-mem-move { order: 2; }
|
|
87
|
+
.pc-mem-move[open], .pc-mem-clash { order: 4; flex-basis: 100%; }
|
|
88
|
+
.pc-mem-move > summary { list-style: none; cursor: pointer; display: inline-flex; }
|
|
89
|
+
.pc-mem-move > summary::-webkit-details-marker { display: none; }
|
|
90
|
+
.pc-mem-move[open] > summary { margin-bottom: 8px; }
|
|
91
|
+
.pc-mem-move form { display: grid; gap: 12px; padding: 12px; background: var(--soft); border-radius: var(--r-sm); margin-bottom: 6px; }
|
|
92
|
+
.pc-mem-move fieldset { border: 0; margin: 0; padding: 0; min-width: 0; display: flex; flex-wrap: wrap; gap: 6px 14px; }
|
|
93
|
+
.pc-mem-move fieldset > legend { padding: 0; margin-bottom: 6px; font-size: 13px; font-weight: 600; }
|
|
94
|
+
.pc-mem-clash { display: grid; gap: 10px; padding: 12px 14px; border: 1px solid color-mix(in srgb, var(--warning) 40%, var(--line)); border-radius: var(--r-sm); background: var(--panel); }
|
|
95
|
+
.pc-mem-clash p { margin: 0; font-size: 14px; }
|
|
76
96
|
}
|
|
77
97
|
</style>
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { ApiError } from '../../lib/api';
|
|
2
|
+
import type { AstroGlobal } from 'astro';
|
|
3
|
+
import type { Settings } from '../../lib/schema';
|
|
4
|
+
import { I18N, localePath, requestLocale } from '../../lib/i18n';
|
|
5
|
+
import { isExtraLocale } from '../../lib/translations';
|
|
6
|
+
import { siteLang, siteLocale } from '../../lib/dates';
|
|
7
|
+
import { memberError, memberStrings, type MemberStrings } from '../../lib/calendar/member-words';
|
|
2
8
|
|
|
3
9
|
// Shared by the built-in "My events" pages: they're personal, so nothing
|
|
4
10
|
// they answer (redirects included) may be cached or indexed.
|
|
@@ -12,14 +18,31 @@ export function keepPrivate(headers: Headers) {
|
|
|
12
18
|
/** 303 after a POST (the browser GETs the list), 302 otherwise */
|
|
13
19
|
export const redirectTo = (location: string, status = 303) => new Response(null, { status, headers: { Location: location, ...NO_STORE } });
|
|
14
20
|
|
|
15
|
-
/** signed out: sign in, then come back here */
|
|
16
|
-
export const signInRedirect = (url: URL) =>
|
|
21
|
+
/** signed out: sign in, then come back here (the address they used: /es/… is rewritten to these pages) */
|
|
22
|
+
export const signInRedirect = (url: URL, locals?: unknown) => {
|
|
23
|
+
const path = (locals as { acmsPath?: string } | undefined)?.acmsPath ?? url.pathname;
|
|
24
|
+
return redirectTo(`/admin/login?return=${encodeURIComponent(path + url.search)}`, 302);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The page's language (/es/members/events is these pages in Spanish): its
|
|
29
|
+
* words, links that keep the visitor in it, and the locale dates are written
|
|
30
|
+
* in (the site's own locale setting for the main language).
|
|
31
|
+
*/
|
|
32
|
+
export function memberLanguage(Astro: Pick<AstroGlobal, 'url' | 'locals'>, settings: Pick<Settings, 'locale'>): { locale: string; lang: string; w: MemberStrings; here: (path: string) => string; dateLocale: string } {
|
|
33
|
+
const locale = I18N.enabled ? requestLocale(Astro.url, Astro.locals) : '';
|
|
34
|
+
const lang = locale || siteLang(settings);
|
|
35
|
+
return { locale, lang, w: memberStrings(lang), here: (path: string) => localePath(path, locale), dateLocale: isExtraLocale(locale) ? locale : siteLocale(settings) };
|
|
36
|
+
}
|
|
17
37
|
|
|
18
38
|
export const notFound = () => new Response(null, { status: 404, statusText: 'Not found', headers: NO_STORE });
|
|
19
39
|
|
|
20
|
-
/** an error from requests.ts → a message safe to show, and the status to answer with */
|
|
21
|
-
export function failure(err: unknown): { message: string; status: number } {
|
|
22
|
-
if (err instanceof ApiError)
|
|
40
|
+
/** an error from requests.ts (or moves.ts) → a message safe to show, in the page's words when given, and the status to answer with */
|
|
41
|
+
export function failure(err: unknown, w?: MemberStrings, lang?: string): { message: string; status: number; code: string } {
|
|
42
|
+
if (err instanceof ApiError) {
|
|
43
|
+
const code = String((err as { code?: unknown }).code ?? '');
|
|
44
|
+
return { message: w ? memberError(w, err, lang) : err.message, status: err.status, code };
|
|
45
|
+
}
|
|
23
46
|
console.error(err);
|
|
24
|
-
return { message: 'Something went wrong. Please try again.', status: 500 };
|
|
47
|
+
return { message: w?.somethingWrong ?? 'Something went wrong. Please try again.', status: 500, code: '' };
|
|
25
48
|
}
|
|
@@ -3,22 +3,24 @@ import Layout from '../_Layout.astro';
|
|
|
3
3
|
import EventForm from '../_EventForm.astro';
|
|
4
4
|
import { getRepo } from '../../../lib/repo';
|
|
5
5
|
import { currentUser } from '../../../server';
|
|
6
|
-
import {
|
|
6
|
+
import { coded } from '../../../lib/api';
|
|
7
7
|
import { readForm } from '../../../lib/forms/http';
|
|
8
8
|
import { MEMBERS, memberEventsPath, bodyToText, isHost, updateEventRequest, withdrawEventRequest } from '../../../lib/calendar/requests';
|
|
9
9
|
import { eventFormValues, eventRequestInput, memberEventFields, readEventForm } from '../../../lib/calendar/member-form';
|
|
10
10
|
import { mergeEventDraft } from '../../../lib/drafts';
|
|
11
|
-
import { failure, keepPrivate, notFound, redirectTo, signInRedirect } from '../_http';
|
|
11
|
+
import { failure, keepPrivate, memberLanguage, notFound, redirectTo, signInRedirect } from '../_http';
|
|
12
|
+
import { fill } from '../../../lib/live/words';
|
|
12
13
|
|
|
13
14
|
// Edit a request, or suggest changes to a live event the member hosts: the
|
|
14
15
|
// form starts from the event with any changes already waiting merged in.
|
|
15
16
|
// A live event stays as it is until the team approves (updateEventRequest).
|
|
16
17
|
if (!MEMBERS.enabled) return notFound();
|
|
17
18
|
const user = await currentUser(Astro);
|
|
18
|
-
if (!user) return signInRedirect(Astro.url);
|
|
19
|
+
if (!user) return signInRedirect(Astro.url, Astro.locals);
|
|
19
20
|
keepPrivate(Astro.response.headers);
|
|
20
21
|
const repo = await getRepo(Astro.locals);
|
|
21
|
-
const
|
|
22
|
+
const { w, lang, here } = memberLanguage(Astro, await repo.getSettings());
|
|
23
|
+
const home = here(memberEventsPath());
|
|
22
24
|
const hooks = { url: Astro.url, locals: Astro.locals };
|
|
23
25
|
const id = Astro.params.id ?? '';
|
|
24
26
|
const event = await repo.getEvent(id);
|
|
@@ -40,12 +42,12 @@ if (Astro.request.method === 'POST') {
|
|
|
40
42
|
await withdrawEventRequest(repo, user, id, hooks);
|
|
41
43
|
return redirectTo(`${home}?withdrawn=1`);
|
|
42
44
|
}
|
|
43
|
-
if (op !== 'update') throw
|
|
45
|
+
if (op !== 'update') throw coded(400, 'unavailable', 'That action isn’t available here.');
|
|
44
46
|
values = readEventForm(form, fields);
|
|
45
47
|
await updateEventRequest(repo, user, id, eventRequestInput(values), hooks);
|
|
46
48
|
return redirectTo(`${home}?${live ? 'changes' : 'updated'}=1`);
|
|
47
49
|
} catch (err) {
|
|
48
|
-
const f = failure(err);
|
|
50
|
+
const f = failure(err, w, lang);
|
|
49
51
|
error = f.message;
|
|
50
52
|
Astro.response.status = f.status;
|
|
51
53
|
}
|
|
@@ -53,34 +55,33 @@ if (Astro.request.method === 'POST') {
|
|
|
53
55
|
|
|
54
56
|
const r = event.request;
|
|
55
57
|
const canRequest = user.can('events.request');
|
|
56
|
-
const heading = live ?
|
|
57
|
-
const lede = live
|
|
58
|
-
|
|
59
|
-
: r
|
|
60
|
-
:
|
|
61
|
-
: 'It isn’t on the calendar yet. Send it to the team when it’s ready.';
|
|
58
|
+
const heading = live ? w.suggestTitle : w.editTitle;
|
|
59
|
+
const lede = live ? fill(w.suggestLede, { title: event.title })
|
|
60
|
+
: r?.state === 'declined' ? w.declinedLede
|
|
61
|
+
: r ? w.waitingLede
|
|
62
|
+
: w.draftLede;
|
|
62
63
|
const waiting = live && r?.kind === 'changes' && r.state === 'pending';
|
|
63
64
|
const reply = r?.state === 'declined' ? r.reply : '';
|
|
64
65
|
const canWithdraw = canRequest && r && !(r.kind === 'changes' && r.state === 'declined');
|
|
65
66
|
---
|
|
66
67
|
|
|
67
|
-
<Layout title={heading} user={user}>
|
|
68
|
+
<Layout title={heading} user={user} w={w}>
|
|
68
69
|
<div>
|
|
69
|
-
<a class="pc-mem-back" href={home}
|
|
70
|
+
<a class="pc-mem-back" href={home}>{w.back}</a>
|
|
70
71
|
<div class="pc-mem-head"><h1>{heading}</h1></div>
|
|
71
72
|
<p class="pc-mem-lede">{lede}</p>
|
|
72
73
|
</div>
|
|
73
|
-
{waiting && <p class="pc-mem-reply">
|
|
74
|
+
{waiting && <p class="pc-mem-reply">{w.changesWaitingNote}</p>}
|
|
74
75
|
{r?.state === 'declined' && (
|
|
75
|
-
<p class="pc-mem-reply"><strong>{r.kind === 'changes' ?
|
|
76
|
+
<p class="pc-mem-reply"><strong>{r.kind === 'changes' ? w.declinedChanges : w.declinedRequest}</strong>{reply && <> {w.theySaid} {reply}</>}</p>
|
|
76
77
|
)}
|
|
77
78
|
{canRequest
|
|
78
|
-
? <EventForm values={values} calendars={calendars} fields={fields} op="update" submitLabel={live ?
|
|
79
|
-
: <p class="pc-bk-error" role="alert">
|
|
79
|
+
? <EventForm values={values} calendars={calendars} fields={fields} op="update" submitLabel={live ? w.sendChanges : w.submit} error={error} w={w} lang={lang} />
|
|
80
|
+
: <p class="pc-bk-error" role="alert">{w.noRequestRole}</p>}
|
|
80
81
|
{canWithdraw && (
|
|
81
|
-
<form method="post" onsubmit={r!.kind === 'new' ?
|
|
82
|
+
<form method="post" onsubmit={'return confirm(' + JSON.stringify(r!.kind === 'new' ? w.confirmWithdrawRequest : w.confirmWithdrawChanges) + ')'}>
|
|
82
83
|
<input type="hidden" name="op" value="withdraw" />
|
|
83
|
-
<button class="pc-mem-btn is-danger" type="submit">{r!.kind === 'new' ?
|
|
84
|
+
<button class="pc-mem-btn is-danger" type="submit">{r!.kind === 'new' ? w.withdrawRequest : w.withdrawChanges}</button>
|
|
84
85
|
</form>
|
|
85
86
|
)}
|
|
86
87
|
</Layout>
|