@acms-subir/core 0.3.52 → 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.
Files changed (66) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/cli/migrations.generated.mjs +18 -1
  3. package/docs/calendar.md +20 -2
  4. package/docs/mobile.md +1 -1
  5. package/docs/server.md +61 -1
  6. package/integration.mjs +11 -5
  7. package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +2 -2
  8. package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +2 -2
  9. package/package.json +1 -1
  10. package/src/components/ThemeSections.astro +2 -2
  11. package/src/components/editor/PostEditor.tsx +2 -0
  12. package/src/components/editor/event/EventSheet.tsx +6 -2
  13. package/src/components/editor/event/RegistrationCard.tsx +3 -3
  14. package/src/components/editor/event/RegistrationFormEditor.tsx +6 -3
  15. package/src/components/forms/Canvas.tsx +31 -19
  16. package/src/components/forms/FormBuildPage.tsx +12 -6
  17. package/src/components/forms/FormBuilder.tsx +60 -11
  18. package/src/components/forms/Inspector.tsx +116 -8
  19. package/src/components/forms/TypeIcon.tsx +1 -0
  20. package/src/components/forms/meta.ts +71 -9
  21. package/src/lib/api.ts +3 -0
  22. package/src/lib/backup/content.ts +4 -2
  23. package/src/lib/calendar/api.ts +3 -1
  24. package/src/lib/calendar/host-requests.ts +247 -0
  25. package/src/lib/calendar/member-form.ts +17 -11
  26. package/src/lib/calendar/member-words.ts +473 -0
  27. package/src/lib/calendar/members-config.ts +2 -2
  28. package/src/lib/calendar/moves.ts +81 -33
  29. package/src/lib/calendar/request-migration.ts +17 -0
  30. package/src/lib/calendar/request-store.ts +104 -0
  31. package/src/lib/calendar/requests.ts +24 -24
  32. package/src/lib/dynamicBlocks.ts +4 -2
  33. package/src/lib/email/service.ts +12 -2
  34. package/src/lib/email/templates.ts +12 -2
  35. package/src/lib/forms/fields.ts +14 -9
  36. package/src/lib/forms/localize.ts +68 -0
  37. package/src/lib/forms/render.ts +8 -2
  38. package/src/lib/forms/schema.ts +31 -2
  39. package/src/lib/forms/service.ts +9 -1
  40. package/src/lib/forms/validate.ts +16 -4
  41. package/src/lib/forms/words.ts +32 -0
  42. package/src/lib/mobile/forms.ts +45 -13
  43. package/src/lib/mobile/openapi.ts +3 -3
  44. package/src/lib/mobile/rsvp.ts +4 -2
  45. package/src/lib/mobile/schemas.ts +3 -1
  46. package/src/lib/repo/migrations.ts +4 -0
  47. package/src/lib/repo/sql.ts +7 -0
  48. package/src/lib/repo/types.ts +6 -1
  49. package/src/lib/schema.ts +47 -0
  50. package/src/pages/[...slug].astro +1 -1
  51. package/src/pages/admin/calendar/events/index.astro +82 -6
  52. package/src/pages/admin/forms/[id].astro +6 -0
  53. package/src/pages/admin/index.astro +6 -2
  54. package/src/pages/blog/[slug].astro +1 -1
  55. package/src/pages/events/[slug].astro +9 -3
  56. package/src/pages/forms/[id].astro +22 -4
  57. package/src/pages/index.astro +1 -1
  58. package/src/pages/members/_EventForm.astro +33 -27
  59. package/src/pages/members/_Layout.astro +25 -5
  60. package/src/pages/members/_http.ts +29 -6
  61. package/src/pages/members/events/[id].astro +21 -20
  62. package/src/pages/members/events/index.astro +295 -101
  63. package/src/pages/members/events/new.astro +11 -10
  64. package/src/server/index.ts +53 -0
  65. package/src/styles/forms.css +8 -0
  66. package/src/virtual.d.ts +1 -1
@@ -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 { ApiError } from '../../../lib/api';
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 home = memberEventsPath();
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 new ApiError(400, 'That action isn’t available here.');
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 ? 'Suggest changes' : 'Edit your request';
57
- const lede = live
58
- ? `“${event.title}” stays as it is on the site until the team approves your changes.`
59
- : r?.state === 'declined' ? 'Make your changes and send it again.'
60
- : r ? 'It’s waiting for approval. Saving sends the updated request.'
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}>← My events</a>
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">The changes you sent are waiting for approval; they’re filled in below. Sending again replaces them.</p>}
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' ? 'The team declined your last changes.' : 'The team declined this request.'}</strong>{reply && <> They said: {reply}</>}</p>
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 ? 'Send changes for approval' : 'Submit for approval'} error={error} />
79
- : <p class="pc-bk-error" role="alert">Your role doesn’t include “Request events”. Ask the site’s team if you need it.</p>}
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' ? "return confirm('Withdraw this request?')" : "return confirm('Withdraw these changes?')"}>
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' ? 'Withdraw request' : 'Withdraw changes'}</button>
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>
@@ -2,36 +2,72 @@
2
2
  import Layout from '../_Layout.astro';
3
3
  import { getRepo } from '../../../lib/repo';
4
4
  import { currentUser } from '../../../server';
5
- import { ApiError } from '../../../lib/api';
5
+ import { coded } from '../../../lib/api';
6
6
  import { readForm } from '../../../lib/forms/http';
7
7
  import { MEMBERS, memberEventsPath, hostedEvents, withdrawEventRequest, dismissEventRequest, setHostedOccurrence } from '../../../lib/calendar/requests';
8
+ import { dismissDateRequest, moveHostedDate, moveHostedDateBack, myDateRequests, requestDateMove, withdrawDateRequest, type MoveRequestData } from '../../../lib/calendar/host-requests';
8
9
  import { eventUrl } from '../../../lib/calendar/config';
9
- import { eventIsPublic, eventTimeZone, expandEvents, featuredOccurrence, formatOccurrence, type Occurrence } from '../../../lib/calendar/events';
10
+ import { eventIsPublic, eventRoomIds, eventTimeZone, expandEvents, featuredOccurrence, findOccurrence, formatOccurrence, type Occurrence } from '../../../lib/calendar/events';
11
+ import { tzParts } from '../../../lib/calendar/ics';
10
12
  import { describeRepeat } from '../../../lib/calendar/rrule';
11
13
  import { requestLabel } from '../../../lib/calendar/member-form';
12
14
  import { mergeEventDraft } from '../../../lib/drafts';
13
- import { siteLocale } from '../../../lib/dates';
14
- import type { CalEvent } from '../../../lib/schema';
15
- import { failure, keepPrivate, notFound, redirectTo, signInRedirect } from '../_http';
15
+ import { fill } from '../../../lib/live/words';
16
+ import { localizeDoc } from '../../../lib/translations';
17
+ import type { CalEvent, RequestRecord } from '../../../lib/schema';
18
+ import type { MoveInput } from '../../../lib/calendar/moves';
19
+ import { failure, keepPrivate, memberLanguage, notFound, redirectTo, signInRedirect } from '../_http';
16
20
 
17
21
  // "My events": the person's requests, the events they host (each upcoming
18
- // date with a Cancel / Restore control) and their past events. The rules
19
- // live in lib/calendar/requests.ts; this page only shows them and posts.
20
- // A site replaces it with its own file at the same path.
22
+ // date with Cancel / Restore, Move… and Move back) and their past events.
23
+ // The rules live in lib/calendar/requests.ts and host-requests.ts; this page
24
+ // only shows them and posts. It follows the page's language (/es/…). A site
25
+ // replaces it with its own file at the same path.
21
26
  if (!MEMBERS.enabled) return notFound();
22
27
  const user = await currentUser(Astro);
23
- if (!user) return signInRedirect(Astro.url);
28
+ if (!user) return signInRedirect(Astro.url, Astro.locals);
24
29
  keepPrivate(Astro.response.headers);
25
30
  const repo = await getRepo(Astro.locals);
26
- const home = memberEventsPath();
31
+ const settings = await repo.getSettings();
32
+ const { w, lang, locale: pageLocale, here, dateLocale } = memberLanguage(Astro, settings);
33
+ const home = here(memberEventsPath());
27
34
  const hooks = { url: Astro.url, locals: Astro.locals };
35
+ // a host's moves wait for staff with members.hostMoves 'approve' (staff who host move at once)
36
+ const movesNeedApproval = MEMBERS.hostMoves === 'approve' && !user.can('content.edit');
37
+
38
+ /** the move form's fields → MoveInput (times are wall-clock in the event's zone) */
39
+ function moveInput(form: FormData, e: CalEvent | null): MoveInput {
40
+ const s = (name: string) => { const v = form.get(name); return typeof v === 'string' ? v.trim() : ''; };
41
+ const date = s('date');
42
+ // an end at or before the start runs past midnight
43
+ const endDay = s('endTime') && s('endTime') <= s('startTime') ? nextDay(date) : date;
44
+ const input: MoveInput = e?.allDay
45
+ ? { start: date }
46
+ : { start: `${date}T${s('startTime')}`, ...(s('endTime') ? { end: `${endDay}T${s('endTime')}` } : {}) };
47
+ // the rooms fieldset was on the form: what's ticked is the date's rooms
48
+ if (form.get('rooms') === '1') input.roomIds = form.getAll('roomIds').map(String).filter(Boolean);
49
+ if (form.get('note') !== null) input.note = s('note');
50
+ return input;
51
+ }
52
+
53
+ function nextDay(day: string): string {
54
+ const d = new Date(`${day}T00:00:00Z`);
55
+ if (Number.isNaN(+d)) return day;
56
+ d.setUTCDate(d.getUTCDate() + 1);
57
+ return d.toISOString().slice(0, 10);
58
+ }
28
59
 
29
60
  let error = '';
61
+ // a move refused because a room is taken: shown under its date, with "Send as a request"
62
+ let clash: { id: string; key: string; back: boolean; message: string; input: MoveInput | null } | null = null;
63
+ // the date whose move form was posted (re-opened with what was typed when it failed)
64
+ let posted: { id: string; key: string; form: FormData } | null = null;
30
65
  if (Astro.request.method === 'POST') {
66
+ const form = await readForm(Astro.request);
67
+ const op = String(form.get('op') ?? '');
68
+ const id = String(form.get('id') ?? '');
69
+ const key = String(form.get('key') ?? '');
31
70
  try {
32
- const form = await readForm(Astro.request);
33
- const op = String(form.get('op') ?? '');
34
- const id = String(form.get('id') ?? '');
35
71
  if (op === 'withdraw') {
36
72
  await withdrawEventRequest(repo, user, id, hooks);
37
73
  return redirectTo(`${home}?withdrawn=1`);
@@ -41,59 +77,118 @@ if (Astro.request.method === 'POST') {
41
77
  return redirectTo(home);
42
78
  }
43
79
  if (op === 'cancel-date' || op === 'restore-date') {
44
- await setHostedOccurrence(repo, user, id, String(form.get('key') ?? ''), op === 'cancel-date', hooks);
80
+ await setHostedOccurrence(repo, user, id, key, op === 'cancel-date', hooks);
45
81
  return redirectTo(`${home}?${op === 'cancel-date' ? 'cancelled' : 'restored'}=1`);
46
82
  }
47
- throw new ApiError(400, 'That action isn’t available here.');
83
+ if (op === 'move-date' || op === 'request-move') {
84
+ posted = { id, key, form };
85
+ const input = moveInput(form, await repo.getEvent(id));
86
+ if (op === 'request-move') {
87
+ await requestDateMove(repo, user, id, key, input, String(form.get('message') ?? ''), hooks);
88
+ return redirectTo(`${home}?moveSent=1`);
89
+ }
90
+ try {
91
+ const r = await moveHostedDate(repo, user, id, key, input, hooks);
92
+ return redirectTo(`${home}?${r.request ? 'moveSent' : 'moved'}=1`);
93
+ } catch (err) {
94
+ if ((err as { code?: string }).code !== 'room-taken') throw err;
95
+ clash = { id, key, back: false, message: failure(err, w, lang).message, input };
96
+ Astro.response.status = 409;
97
+ }
98
+ } else if (op === 'move-back' || op === 'request-back') {
99
+ if (op === 'request-back') {
100
+ await requestDateMove(repo, user, id, key, null, String(form.get('message') ?? ''), hooks);
101
+ return redirectTo(`${home}?moveSent=1`);
102
+ }
103
+ try {
104
+ const r = await moveHostedDateBack(repo, user, id, key, hooks);
105
+ return redirectTo(`${home}?${r.request ? 'moveSent' : 'movedBack'}=1`);
106
+ } catch (err) {
107
+ if ((err as { code?: string }).code !== 'room-taken') throw err;
108
+ clash = { id, key, back: true, message: failure(err, w, lang).message, input: null };
109
+ Astro.response.status = 409;
110
+ }
111
+ } else if (op === 'withdraw-request') {
112
+ await withdrawDateRequest(repo, user, String(form.get('request') ?? ''));
113
+ return redirectTo(`${home}?withdrawn=1`);
114
+ } else if (op === 'dismiss-request') {
115
+ await dismissDateRequest(repo, user, String(form.get('request') ?? ''));
116
+ return redirectTo(home);
117
+ } else throw coded(400, 'unavailable', 'That action isn’t available here.');
48
118
  } catch (err) {
49
- const f = failure(err);
119
+ const f = failure(err, w, lang);
50
120
  error = f.message;
51
121
  Astro.response.status = f.status;
52
122
  }
53
123
  }
54
124
 
55
- const FLASH: [string, string][] = [
56
- ['sent', 'Thanks! Your request is with the team. You’ll hear back once they’ve looked at it.'],
57
- ['updated', 'Your request is updated and waiting for approval.'],
58
- ['changes', 'Your changes are with the team. The event stays as it is until they approve them.'],
59
- ['withdrawn', 'Request withdrawn.'],
60
- ['cancelled', 'That date is cancelled. The calendar shows it as cancelled.'],
61
- ['restored', 'That date is back on.'],
62
- ];
63
- const flash = error ? '' : FLASH.find(([k]) => Astro.url.searchParams.has(k))?.[1] ?? '';
125
+ const FLASH: [keyof typeof w.flash, string][] = (['sent', 'updated', 'changes', 'withdrawn', 'cancelled', 'restored', 'moved', 'movedBack', 'moveSent', 'fromDateSent'] as const).map((k) => [k, w.flash[k]]);
126
+ const flash = error || clash ? '' : FLASH.find(([k]) => Astro.url.searchParams.has(k))?.[1] ?? '';
64
127
 
65
128
  const DAY = 86_400_000;
66
129
  const now = Date.now();
67
- const [settings, calendars, mine] = await Promise.all([repo.getSettings(), repo.listCalendars(), hostedEvents(repo, user.id)]);
130
+ const [calendars, mine, dateRequests, resources] = await Promise.all([repo.listCalendars(), hostedEvents(repo, user.id), myDateRequests(repo, user.id), repo.listResources()]);
68
131
  const calById = new Map(calendars.map((c) => [c.id, c]));
69
132
  const siteTz = settings.timezone || 'UTC';
70
- const locale = siteLocale(settings);
71
133
  const canRequest = user.can('events.request');
72
134
  const isLive = (e: CalEvent) => e.status === 'published' || e.status === 'scheduled';
135
+ // titles and places in the page's language when the event has them
136
+ const shown = (e: CalEvent) => localizeDoc(e, pageLocale || undefined);
73
137
 
74
138
  const format = (opts: Intl.DateTimeFormatOptions, tz: string) => {
75
- try { return new Intl.DateTimeFormat(locale, { ...opts, timeZone: tz }); }
139
+ try { return new Intl.DateTimeFormat(dateLocale, { ...opts, timeZone: tz }); }
76
140
  catch { return new Intl.DateTimeFormat('en-US', { ...opts, timeZone: 'UTC' }); }
77
141
  };
78
142
  /** "Tue, Oct 21 · 7:00 PM – 9:00 PM", or "Tue, Oct 21 · All day" */
79
- const when = (o: Occurrence) => {
143
+ const when = (o: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'>) => {
80
144
  const tz = o.allDay ? siteTz : eventTimeZone(o.event, settings);
81
- const f = formatOccurrence(o, tz, locale);
145
+ const f = formatOccurrence(o, tz, dateLocale);
82
146
  return o.allDay ? f.range : `${f.range} – ${format({ hour: 'numeric', minute: '2-digit' }, tz).format(o.end)}`;
83
147
  };
84
148
  const day = (ms: number) => format({ month: 'short', day: 'numeric' }, siteTz).format(ms);
85
149
  const meta = (...parts: string[]) => parts.filter(Boolean).join(' · ');
150
+ const repeats = (e: CalEvent) => describeRepeat(e.rrule, e.start, undefined, lang);
151
+ const pad = (n: number) => String(n).padStart(2, '0');
152
+ /** an instant as the move form's date and time, in the event's zone */
153
+ const wallParts = (e: CalEvent, ms: number) => {
154
+ const p = tzParts(ms, e.allDay ? siteTz : eventTimeZone(e, settings));
155
+ return { date: `${p.y}-${pad(p.m)}-${pad(p.d)}`, time: `${pad(p.hh)}:${pad(p.mm)}` };
156
+ };
157
+ const roomName = new Map(resources.map((r) => [r.id, r.name]));
158
+ const requestable = resources.filter((r) => r.requestable).map((r) => r.id);
159
+ /** the rooms a host may pick for a date: the event's own (and its dates'), and the requestable ones */
160
+ const roomChoices = (e: CalEvent) => [...new Set([...eventRoomIds(e), ...requestable])].filter((id) => roomName.has(id)).map((id) => ({ id, name: roomName.get(id)! }));
161
+
162
+ // a host's date request: where the date would go
163
+ const byId = new Map(mine.map((e) => [e.id, e]));
164
+ const requestTarget = (e: CalEvent, r: RequestRecord) => {
165
+ const d = r.data as MoveRequestData;
166
+ return d.back ? findOccurrence({ ...e, movedDates: {} }, r.key, settings)
167
+ : findOccurrence({ ...e, movedDates: { ...e.movedDates, [r.key]: { start: d.start, end: d.end, note: d.note, by: '', at: 0, ...(d.roomIds ? { roomIds: d.roomIds } : {}) } } }, r.key, settings);
168
+ };
169
+ // …worded for its card: "Thu, Jan 10 · 6:00 – 7:00 PM → Wed, Jan 9 · 5:00 – 6:00 PM" while it waits, else the date as it is
170
+ const requestWhen = (r: RequestRecord) => {
171
+ const e = byId.get(r.eventId);
172
+ const at = e && findOccurrence(e, r.key, settings);
173
+ if (!e || !at) return '';
174
+ const to = r.state === 'pending' ? requestTarget(e, r) : null;
175
+ return to ? `${when(at)} → ${when(to)}` : when(at);
176
+ };
177
+ const waitingMove = new Map(dateRequests.filter((r) => r.kind === 'move' && r.state === 'pending').map((r) => [`${r.eventId}|${r.key}`, r]));
86
178
 
87
179
  // requests: waiting ones first, newest first; shown as they'd be once approved
88
- const requests = mine
89
- .filter((e) => e.request)
90
- .sort((a, b) => Number(b.request!.state === 'pending') - Number(a.request!.state === 'pending') || b.request!.at - a.request!.at)
91
- .map((e) => {
180
+ const requests = [
181
+ ...mine.filter((e) => e.request).map((e) => {
92
182
  // the host's own waiting changes, never staff's unpublished edits
93
- const shown = e.request!.kind === 'changes' && e.request!.state === 'pending' ? mergeEventDraft(e) : e;
94
- const o = featuredOccurrence(shown, settings, now);
95
- return { e, r: e.request!, title: shown.title, line: meta(o ? when(o) : '', describeRepeat(shown.rrule, shown.start), shown.location.name) };
96
- });
183
+ const s = shown(e.request!.kind === 'changes' && e.request!.state === 'pending' ? mergeEventDraft(e) : e);
184
+ const o = featuredOccurrence(s, settings, now);
185
+ return { kind: 'event' as const, e, state: e.request!.state, at: e.request!.at, label: requestLabel(e.request!, w), title: s.title, line: meta(o ? when(o) : '', describeRepeat(s.rrule, s.start, undefined, lang), s.location.name), reply: e.request!.reply, r: e.request! };
186
+ }),
187
+ // a host's date moves: waiting, and answered ones until dismissed (withdrawn ones drop off)
188
+ ...dateRequests.filter((r) => r.state !== 'withdrawn' && byId.has(r.eventId)).map((r) => ({
189
+ kind: 'date' as const, e: byId.get(r.eventId)!, state: r.state, at: r.createdAt, label: requestLabel(r, w), title: shown(byId.get(r.eventId)!).title, line: requestWhen(r), reply: r.reply, req: r,
190
+ })),
191
+ ].sort((a, b) => Number(b.state === 'pending') - Number(a.state === 'pending') || b.at - a.at);
97
192
 
98
193
  // live events they host: the next 8 dates, or (all dates over) the last one
99
194
  const win = { displayTz: siteTz, now, windowStartMs: now, windowEndMs: now + 366 * DAY };
@@ -112,43 +207,66 @@ for (const e of mine) {
112
207
  upcoming.sort((a, b) => a.dates[0].start - b.dates[0].start);
113
208
  past.sort((a, b) => (b.last?.start ?? b.e.startAt) - (a.last?.start ?? a.e.startAt));
114
209
  const pastShown = past.slice(0, 10);
210
+ const confirm = (text: string) => 'return confirm(' + JSON.stringify(text) + ')';
211
+
212
+ /** the move form's starting values: what was posted (a failed try), else the date as it is now */
213
+ function moveValues(e: CalEvent, o: Occurrence) {
214
+ const typed = posted && posted.id === e.id && posted.key === o.key ? posted.form : null;
215
+ const start = wallParts(e, o.start), end = wallParts(e, e.allDay ? o.end - DAY : o.end);
216
+ const get = (name: string, fallback: string) => { const v = typed?.get(name); return typeof v === 'string' ? v : fallback; };
217
+ return {
218
+ date: get('date', start.date), startTime: get('startTime', start.time), endTime: get('endTime', end.time),
219
+ roomIds: typed && typed.get('rooms') === '1' ? typed.getAll('roomIds').map(String) : o.roomIds,
220
+ note: get('note', o.moved?.note ?? ''), message: get('message', ''),
221
+ };
222
+ }
115
223
  ---
116
224
 
117
- <Layout title="My events" user={user}>
225
+ <Layout title={w.myEvents} user={user} w={w}>
118
226
  <div class="pc-mem-head">
119
- <h1>My events</h1>
120
- {canRequest && <a class="pc-mem-btn is-primary" href={`${home}/new`}>Request an event</a>}
227
+ <h1>{w.myEvents}</h1>
228
+ {canRequest && <a class="pc-mem-btn is-primary" href={`${home}/new`}>{w.requestEvent}</a>}
121
229
  </div>
122
230
  {flash && <p class="pc-mem-flash" role="status">{flash}</p>}
123
231
  {error && <p class="pc-bk-error" role="alert">{error}</p>}
124
232
 
125
233
  {requests.length > 0 && (
126
234
  <section aria-labelledby="pc-mem-requests">
127
- <h2 id="pc-mem-requests">Requests</h2>
235
+ <h2 id="pc-mem-requests">{w.requests}</h2>
128
236
  <ul class="pc-mem-list">
129
- {requests.map(({ e, r, title, line }) => (
237
+ {requests.map((q) => (
130
238
  <li class="pc-mem-card">
131
- <p class={`pc-mem-state is-${r.state}`}>{requestLabel(r)}</p>
132
- <h3>{title}</h3>
133
- {line && <p class="pc-mem-meta">{line}</p>}
134
- <p class="pc-mem-meta">Sent {day(r.at)}</p>
135
- {r.state === 'declined' && r.reply && <p class="pc-mem-reply"><strong>The team said:</strong> {r.reply}</p>}
136
- <div class="pc-mem-actions">
137
- {canRequest && <a class="pc-mem-btn is-small" href={`${home}/${e.id}`}>{r.kind === 'changes' && r.state === 'declined' ? 'Suggest changes again' : 'Edit'}</a>}
138
- {r.kind === 'changes' && r.state === 'declined' ? (
139
- <form method="post">
140
- <input type="hidden" name="op" value="dismiss" />
141
- <input type="hidden" name="id" value={e.id} />
142
- <button class="pc-mem-btn is-small" type="submit">Dismiss</button>
239
+ <p class={`pc-mem-state is-${q.state === 'approved' ? 'approved' : q.state}`}>{q.label}</p>
240
+ <h3>{q.title}</h3>
241
+ {q.line && <p class="pc-mem-meta">{q.line}</p>}
242
+ <p class="pc-mem-meta">{fill(w.sentOn, { date: day(q.at) })}</p>
243
+ {q.state === 'declined' && q.reply && <p class="pc-mem-reply"><strong>{w.teamSaid}</strong> {q.reply}</p>}
244
+ {q.kind === 'event' ? (
245
+ <div class="pc-mem-actions">
246
+ {canRequest && <a class="pc-mem-btn is-small" href={`${home}/${q.e.id}`}>{q.r.kind === 'changes' && q.r.state === 'declined' ? w.suggestAgain : w.edit}</a>}
247
+ {q.r.kind === 'changes' && q.r.state === 'declined' ? (
248
+ <form method="post">
249
+ <input type="hidden" name="op" value="dismiss" />
250
+ <input type="hidden" name="id" value={q.e.id} />
251
+ <button class="pc-mem-btn is-small" type="submit">{w.dismiss}</button>
252
+ </form>
253
+ ) : canRequest && (
254
+ <form method="post" onsubmit={confirm(q.r.kind === 'new' ? w.confirmWithdrawRequest : w.confirmWithdrawChanges)}>
255
+ <input type="hidden" name="op" value="withdraw" />
256
+ <input type="hidden" name="id" value={q.e.id} />
257
+ <button class="pc-mem-btn is-small is-danger" type="submit">{w.withdraw}</button>
258
+ </form>
259
+ )}
260
+ </div>
261
+ ) : (
262
+ <div class="pc-mem-actions">
263
+ <form method="post" onsubmit={q.state === 'pending' ? confirm(w.confirmWithdrawRequest) : undefined}>
264
+ <input type="hidden" name="op" value={q.state === 'pending' ? 'withdraw-request' : 'dismiss-request'} />
265
+ <input type="hidden" name="request" value={q.req.id} />
266
+ <button class={`pc-mem-btn is-small${q.state === 'pending' ? ' is-danger' : ''}`} type="submit">{q.state === 'pending' ? w.withdraw : w.dismiss}</button>
143
267
  </form>
144
- ) : canRequest && (
145
- <form method="post" onsubmit={r.kind === 'new' ? "return confirm('Withdraw this request?')" : "return confirm('Withdraw these changes?')"}>
146
- <input type="hidden" name="op" value="withdraw" />
147
- <input type="hidden" name="id" value={e.id} />
148
- <button class="pc-mem-btn is-small is-danger" type="submit">Withdraw</button>
149
- </form>
150
- )}
151
- </div>
268
+ </div>
269
+ )}
152
270
  </li>
153
271
  ))}
154
272
  </ul>
@@ -156,62 +274,138 @@ const pastShown = past.slice(0, 10);
156
274
  )}
157
275
 
158
276
  <section aria-labelledby="pc-mem-upcoming">
159
- <h2 id="pc-mem-upcoming">Upcoming</h2>
277
+ <h2 id="pc-mem-upcoming">{w.upcoming}</h2>
160
278
  {upcoming.length === 0 ? (
161
279
  <p class="pc-mem-empty">
162
- You’re not hosting any upcoming events.
163
- {canRequest && requests.length === 0 && <> <a href={`${home}/new`}>Request one</a> and the team will take a look.</>}
280
+ {w.noUpcoming}
281
+ {canRequest && requests.length === 0 && <> <a href={`${home}/new`}>{w.requestOne}</a>{w.requestOneRest}</>}
164
282
  </p>
165
283
  ) : (
166
284
  <ul class="pc-mem-list">
167
- {upcoming.map(({ e, dates }) => (
168
- <li class="pc-mem-card">
169
- {e.cancelled ? <p class="pc-mem-state is-declined">Cancelled</p>
170
- : e.status === 'scheduled' && <p class="pc-mem-state is-pending">{e.scheduledAt ? `Goes live ${day(e.scheduledAt)}` : 'Scheduled'}</p>}
171
- <h3>{e.title}</h3>
172
- {meta(describeRepeat(e.rrule, e.start), e.location.name) && <p class="pc-mem-meta">{meta(describeRepeat(e.rrule, e.start), e.location.name)}</p>}
173
- <ul class="pc-mem-dates">
174
- {dates.map((o) => {
175
- const off = e.cancelledDates.includes(o.key);
176
- return (
177
- <li class={o.cancelled ? 'is-cancelled' : undefined}>
178
- <span>{o.cancelled ? <s>{when(o)}</s> : when(o)}{o.cancelled && <em>Cancelled</em>}{o.moved && !o.cancelled && <em class="is-moved" title={o.moved.note || undefined}>Moved from {when({ ...o, start: o.moved.from.start, end: o.moved.from.end })}</em>}</span>
179
- {e.rrule && !e.cancelled && o.start > now && (
180
- <form method="post" onsubmit={off ? undefined : "return confirm('Cancel this date? It shows as cancelled straight away.')"}>
181
- <input type="hidden" name="op" value={off ? 'restore-date' : 'cancel-date'} />
182
- <input type="hidden" name="id" value={e.id} />
183
- <input type="hidden" name="key" value={o.key} />
184
- <button class={`pc-mem-btn is-small${off ? '' : ' is-danger'}`} type="submit" aria-label={`${off ? 'Restore' : 'Cancel'} ${when(o)}`}>{off ? 'Restore' : 'Cancel'}</button>
185
- </form>
186
- )}
187
- </li>
188
- );
189
- })}
190
- </ul>
191
- <div class="pc-mem-actions">
192
- {canRequest && <a class="pc-mem-btn is-small" href={`${home}/${e.id}`}>Suggest changes</a>}
193
- {eventIsPublic(e, calById) && <a class="pc-mem-btn is-small" href={eventUrl(e.slug)}>View</a>}
194
- </div>
195
- </li>
196
- ))}
285
+ {upcoming.map(({ e, dates }) => {
286
+ const ev = shown(e);
287
+ const rooms = roomChoices(e);
288
+ return (
289
+ <li class="pc-mem-card">
290
+ {e.cancelled ? <p class="pc-mem-state is-declined">{w.cancelledState}</p>
291
+ : e.status === 'scheduled' && <p class="pc-mem-state is-pending">{e.scheduledAt ? fill(w.goesLive, { date: day(e.scheduledAt) }) : w.scheduled}</p>}
292
+ <h3>{ev.title}</h3>
293
+ {meta(repeats(e), ev.location.name) && <p class="pc-mem-meta">{meta(repeats(e), ev.location.name)}</p>}
294
+ <ul class="pc-mem-dates">
295
+ {dates.map((o) => {
296
+ const off = e.cancelledDates.includes(o.key);
297
+ const open = e.rrule && !e.cancelled && o.start > now;
298
+ const waiting = waitingMove.get(`${e.id}|${o.key}`);
299
+ const ownMove = o.moved && e.movedDates[o.key]?.by === user.id;
300
+ const clashHere = clash && clash.id === e.id && clash.key === o.key ? clash : null;
301
+ const target = waiting && requestTarget(e, waiting);
302
+ const v = moveValues(e, o);
303
+ const reopen = Boolean(posted && posted.id === e.id && posted.key === o.key && error);
304
+ return (
305
+ <li class={o.cancelled ? 'is-cancelled' : undefined}>
306
+ <span>
307
+ {o.cancelled ? <s>{when(o)}</s> : when(o)}
308
+ {o.cancelled && <em>{w.cancelledDate}</em>}
309
+ {o.moved && !o.cancelled && <em class="is-moved" title={o.moved.note || undefined}>{fill(w.movedFrom, { when: when({ ...o, start: o.moved.from.start, end: o.moved.from.end }) })}</em>}
310
+ {waiting && <em class="is-pending">{(waiting.data as MoveRequestData).back ? w.moveBackRequested : fill(w.moveRequested, { when: target ? when(target) : '' })}</em>}
311
+ </span>
312
+ {open && (
313
+ <span class="pc-mem-date-acts">
314
+ {ownMove && !off && (
315
+ <form method="post" onsubmit={confirm(w.confirmMoveBack)}>
316
+ <input type="hidden" name="op" value="move-back" />
317
+ <input type="hidden" name="id" value={e.id} />
318
+ <input type="hidden" name="key" value={o.key} />
319
+ <button class="pc-mem-btn is-small" type="submit">{w.moveBack}</button>
320
+ </form>
321
+ )}
322
+ <form method="post" onsubmit={off ? undefined : confirm(w.confirmCancelDate)}>
323
+ <input type="hidden" name="op" value={off ? 'restore-date' : 'cancel-date'} />
324
+ <input type="hidden" name="id" value={e.id} />
325
+ <input type="hidden" name="key" value={o.key} />
326
+ <button class={`pc-mem-btn is-small${off ? '' : ' is-danger'}`} type="submit" aria-label={fill(off ? w.restoreLabel : w.cancelLabel, { when: when(o) })}>{off ? w.restore : w.cancel}</button>
327
+ </form>
328
+ </span>
329
+ )}
330
+ {clashHere && (
331
+ <div class="pc-mem-clash pc-mem-move" role="alert">
332
+ <p><strong>{clashHere.message}</strong></p>
333
+ <p>{w.sendInstead}</p>
334
+ <form method="post">
335
+ <input type="hidden" name="op" value={clashHere.back ? 'request-back' : 'request-move'} />
336
+ <input type="hidden" name="id" value={e.id} />
337
+ <input type="hidden" name="key" value={o.key} />
338
+ {!clashHere.back && (
339
+ <>
340
+ <input type="hidden" name="date" value={v.date} />
341
+ <input type="hidden" name="startTime" value={v.startTime} />
342
+ <input type="hidden" name="endTime" value={v.endTime} />
343
+ <input type="hidden" name="note" value={v.note} />
344
+ {posted?.form.get('rooms') === '1' && <input type="hidden" name="rooms" value="1" />}
345
+ {posted?.form.get('rooms') === '1' && v.roomIds.map((r) => <input type="hidden" name="roomIds" value={r} />)}
346
+ </>
347
+ )}
348
+ <label class="pc-bk-field"><span>{w.messageForTeam} <em>{w.form.optional}</em></span><textarea name="message" rows="2" maxlength="1000">{v.message}</textarea></label>
349
+ <div class="pc-mem-actions"><button class="pc-mem-btn is-small is-primary" type="submit">{w.sendAsRequest}</button></div>
350
+ </form>
351
+ </div>
352
+ )}
353
+ {open && !off && !clashHere && (
354
+ <details class="pc-mem-move" open={reopen}>
355
+ <summary class="pc-mem-btn is-small" aria-label={fill(w.moveDateLabel, { when: when(o) })}>{w.moveDate}</summary>
356
+ <form method="post">
357
+ <input type="hidden" name="op" value={movesNeedApproval ? 'request-move' : 'move-date'} />
358
+ <input type="hidden" name="id" value={e.id} />
359
+ <input type="hidden" name="key" value={o.key} />
360
+ <div class="pc-mem-row">
361
+ <label class="pc-bk-field"><span>{w.date}</span><input type="date" name="date" required value={v.date} /></label>
362
+ {!e.allDay && <label class="pc-bk-field"><span>{w.starts}</span><input type="time" name="startTime" required value={v.startTime} /></label>}
363
+ {!e.allDay && <label class="pc-bk-field"><span>{w.ends}</span><input type="time" name="endTime" value={v.endTime} /></label>}
364
+ </div>
365
+ {rooms.length > 0 && (
366
+ <fieldset>
367
+ <legend>{w.rooms}</legend>
368
+ <input type="hidden" name="rooms" value="1" />
369
+ {rooms.map((r) => <label class="pc-bk-check"><input type="checkbox" name="roomIds" value={r.id} checked={v.roomIds.includes(r.id)} /> <span>{r.name}</span></label>)}
370
+ <p class="pc-mem-hint" style="flex-basis:100%">{w.roomsHint}</p>
371
+ </fieldset>
372
+ )}
373
+ <label class="pc-bk-field"><span>{w.publicNote} <em>{w.form.optional}</em></span><input name="note" maxlength="500" value={v.note} /><small>{w.publicNoteHint}</small></label>
374
+ {movesNeedApproval && <label class="pc-bk-field"><span>{w.messageForTeam} <em>{w.form.optional}</em></span><textarea name="message" rows="2" maxlength="1000">{v.message}</textarea></label>}
375
+ {movesNeedApproval && <p class="pc-mem-hint">{w.approvalHint}</p>}
376
+ <div class="pc-mem-actions"><button class="pc-mem-btn is-small is-primary" type="submit">{movesNeedApproval ? w.sendForApproval : w.moveNow}</button></div>
377
+ </form>
378
+ </details>
379
+ )}
380
+ </li>
381
+ );
382
+ })}
383
+ </ul>
384
+ <div class="pc-mem-actions">
385
+ {canRequest && <a class="pc-mem-btn is-small" href={`${home}/${e.id}`}>{w.suggestChanges}</a>}
386
+ {eventIsPublic(e, calById) && <a class="pc-mem-btn is-small" href={here(eventUrl(e.slug))}>{w.view}</a>}
387
+ </div>
388
+ </li>
389
+ );
390
+ })}
197
391
  </ul>
198
392
  )}
199
393
  </section>
200
394
 
201
395
  {past.length > 0 && (
202
- <section aria-label="Past events">
396
+ <section aria-label={fill(w.pastEvents, { count: String(past.length) })}>
203
397
  <details class="pc-mem-past">
204
- <summary>Past events ({past.length})</summary>
398
+ <summary>{fill(w.pastEvents, { count: String(past.length) })}</summary>
205
399
  <ul class="pc-mem-list">
206
400
  {pastShown.map(({ e, last }) => (
207
401
  <li class="pc-mem-card">
208
- <h3>{e.title}</h3>
402
+ <h3>{shown(e).title}</h3>
209
403
  {last && <p class="pc-mem-meta">{when(last)}</p>}
210
- {eventIsPublic(e, calById) && <div class="pc-mem-actions"><a class="pc-mem-btn is-small" href={eventUrl(e.slug)}>View</a></div>}
404
+ {eventIsPublic(e, calById) && <div class="pc-mem-actions"><a class="pc-mem-btn is-small" href={here(eventUrl(e.slug))}>{w.view}</a></div>}
211
405
  </li>
212
406
  ))}
213
407
  </ul>
214
- {past.length > pastShown.length && <p class="pc-mem-hint">Showing the {pastShown.length} most recent.</p>}
408
+ {past.length > pastShown.length && <p class="pc-mem-hint">{fill(w.showingRecent, { count: String(pastShown.length) })}</p>}
215
409
  </details>
216
410
  </section>
217
411
  )}