@acms-subir/core 0.3.53 → 0.3.55

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 (62) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/cli/main.mjs +2 -1
  3. package/cli/migrations.generated.mjs +76 -6
  4. package/docs/calendar.md +49 -2
  5. package/docs/google-calendar.md +302 -0
  6. package/docs/server.md +68 -1
  7. package/integration.mjs +15 -5
  8. package/package.json +1 -1
  9. package/src/components/LegalSettings.tsx +1 -1
  10. package/src/lib/api.ts +3 -0
  11. package/src/lib/backup/content.ts +4 -2
  12. package/src/lib/calendar/google/client.ts +167 -0
  13. package/src/lib/calendar/google/connect.ts +161 -0
  14. package/src/lib/calendar/google/context.ts +50 -0
  15. package/src/lib/calendar/google/inbound.ts +493 -0
  16. package/src/lib/calendar/google/links.ts +139 -0
  17. package/src/lib/calendar/google/map.ts +543 -0
  18. package/src/lib/calendar/google/match.ts +103 -0
  19. package/src/lib/calendar/google/migration.ts +71 -0
  20. package/src/lib/calendar/google/outbound.ts +272 -0
  21. package/src/lib/calendar/google/service.ts +193 -0
  22. package/src/lib/calendar/google/store.ts +241 -0
  23. package/src/lib/calendar/google/types.ts +188 -0
  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/calendar/schedule.ts +8 -8
  33. package/src/lib/display/board.ts +3 -1
  34. package/src/lib/email/service.ts +22 -2
  35. package/src/lib/email/templates.ts +20 -2
  36. package/src/lib/legal/admin.ts +1 -1
  37. package/src/lib/legal/facts.ts +3 -0
  38. package/src/lib/legal/text-en.ts +4 -0
  39. package/src/lib/legal/text-es.ts +4 -0
  40. package/src/lib/live/crypto.ts +7 -28
  41. package/src/lib/repo/migrations.ts +8 -0
  42. package/src/lib/repo/sql.ts +25 -9
  43. package/src/lib/repo/types.ts +12 -3
  44. package/src/lib/scheduler.ts +3 -0
  45. package/src/lib/schema.ts +47 -0
  46. package/src/lib/sealed.ts +46 -0
  47. package/src/pages/admin/calendar/events/[id].astro +42 -1
  48. package/src/pages/admin/calendar/events/index.astro +82 -6
  49. package/src/pages/admin/calendar/google/match.astro +160 -0
  50. package/src/pages/admin/calendar/google.astro +107 -0
  51. package/src/pages/admin/calendar/settings.astro +273 -4
  52. package/src/pages/admin/index.astro +6 -2
  53. package/src/pages/admin/integrations.astro +6 -0
  54. package/src/pages/api/calendar/google/oauth/callback.ts +16 -0
  55. package/src/pages/members/_EventForm.astro +33 -27
  56. package/src/pages/members/_Layout.astro +25 -5
  57. package/src/pages/members/_http.ts +29 -6
  58. package/src/pages/members/events/[id].astro +21 -20
  59. package/src/pages/members/events/index.astro +295 -101
  60. package/src/pages/members/events/new.astro +11 -10
  61. package/src/server/index.ts +102 -0
  62. package/src/virtual.d.ts +1 -1
@@ -16,12 +16,16 @@ import { placeUsage, savePlace } from '../../../lib/maps/places';
16
16
  import { parseMapLink } from '../../../lib/maps/parse';
17
17
  import { ApiError } from '../../../lib/api';
18
18
  import type { Place } from '../../../lib/schema';
19
- import { forbiddenUnless } from '../../../lib/permissions/access';
19
+ import { accessFor, forbiddenUnless } from '../../../lib/permissions/access';
20
+ import { googleSyncState } from '../../../lib/calendar/google/service';
21
+ import { PAUSE_WORDS } from '../../../lib/calendar/google/links';
22
+ import type { GcalLink } from '../../../lib/calendar/google/types';
20
23
 
21
24
  // Calendar settings — one page behind the ⚙ on Schedule and Bookings:
22
25
  // calendars (groups + colors), saved places (picked in an event's Where),
23
- // subscribed calendars (read-only, synced in),
24
- // share links (out to Google/Apple/Outlook), booking approval, defaults.
26
+ // subscribed calendars (read-only, synced in), the two-way Google Calendar
27
+ // sync (its forms post to /admin/calendar/google), share links (out to
28
+ // Google/Apple/Outlook), booking approval, defaults.
25
29
  // Replaces the old Calendars page and the duplicate subscribe dialogs.
26
30
  if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
27
31
 
@@ -196,8 +200,51 @@ const I = {
196
200
  ext: 'M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5',
197
201
  pin: 'M12 22s7-7 7-12a7 7 0 0 0-14 0c0 5 7 12 7 12zM12 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z',
198
202
  };
203
+ // Synced with Google: connecting needs "Connected services", linking and reviewing "Manage rooms and calendars"
204
+ const access = await accessFor(Astro.request, Astro.cookies, repo);
205
+ const canConnect = access?.can('services.manage') ?? false;
206
+ const canLink = access?.can('schedule.manage') ?? false;
207
+ const canUse = canLink || (access?.can('content.publish') ?? false);
208
+ const google = await googleSyncState(repo, Astro.url, Astro.locals);
209
+ const gAccount = google.account;
210
+ const gConnected = gAccount?.status === 'connected';
211
+ const gStep = Astro.url.searchParams.get('google') ?? '';
212
+ const errorNotice = Astro.url.searchParams.get('error') ?? '';
213
+ const calById = new Map(calendars.map((c) => [c.id, c]));
214
+ const roomName = new Map(google.resources.map((r) => [r.id, r.name]));
215
+ const freeGoogle = (gAccount?.calendars ?? []).filter((c) => !google.links.some((l) => l.googleId === c.id));
216
+ const ARROW: Record<GcalLink['direction'], string> = { both: '⇄', out: '→', in: '←' };
217
+ const DIRECTIONS: [GcalLink['direction'], string, string][] = [
218
+ ['both', 'Both ways', 'Edits on either side copy over.'],
219
+ ['out', 'Site → Google', 'Google mirrors the site. Start here, then switch to both ways.'],
220
+ ['in', 'Google → site', 'The site follows Google. Site edits stay on the site.'],
221
+ ];
222
+ const agoText = (ts: number) => {
223
+ const m = Math.max(0, Math.round((Date.now() - ts) / 60_000));
224
+ return m < 1 ? 'just now' : m < 60 ? `${m} min ago` : m < 1440 ? `${Math.round(m / 60)} h ago` : `${Math.round(m / 1440)} d ago`;
225
+ };
226
+ const linkLine = (l: (typeof google.links)[number]) => [
227
+ DIRECTIONS.find(([d]) => d === l.direction)![1] + (l.direction === 'out' ? ' only' : ''),
228
+ l.roomIds.length ? `holds ${l.roomIds.map((r) => roomName.get(r) ?? 'a deleted room').join(' and ')}` : 'the whole calendar',
229
+ l.noRoom && l.roomIds.length ? 'also gets events without a room' : '',
230
+ ...l.rules.map((r) => `${r.prefix} ⇄ ${r.tag}`),
231
+ l.state === 'active' && l.lastOkAt ? `last synced ${agoText(l.lastOkAt)}` : '',
232
+ `${l.pairs} event${l.pairs === 1 ? '' : 's'}`,
233
+ ].filter(Boolean).join(' · ');
234
+ const linkState = (l: (typeof google.links)[number]) =>
235
+ l.state === 'matching' ? { tone: 'warn', label: 'Waiting for the first match' }
236
+ : l.state === 'paused' ? { tone: 'muted', label: PAUSE_WORDS[l.pausedReason] ?? 'Paused' }
237
+ : !gConnected ? { tone: 'bad', label: 'Reconnect Google' }
238
+ : l.error ? { tone: 'bad', label: 'Not syncing' }
239
+ : { tone: 'ok', label: 'In sync' };
240
+ const tz = settings.timezone || 'UTC';
241
+ const dayOf = (ts: number) => new Intl.DateTimeFormat('en-CA', { timeZone: tz }).format(ts);
242
+ const logTime = (ts: number) => new Intl.DateTimeFormat(settings.locale || 'en', dayOf(ts) === dayOf(Date.now()) ? { hour: 'numeric', minute: '2-digit', timeZone: tz } : { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: tz }).format(ts);
243
+ const LOG_DIRECTION: Record<string, string> = { in: '← from Google', out: '→ to Google', clash: 'Clash', info: 'Note' };
244
+ const startDefault = new Date(Date.now() - 30 * 86_400_000).toISOString().slice(0, 10);
245
+
199
246
  const sections = [
200
- ['calendars', 'Calendars'], ['event-images', 'Event images'], ['places', 'Places'], ['subscribed', 'Subscribed calendars'], ['share', 'Share links'],
247
+ ['calendars', 'Calendars'], ['event-images', 'Event images'], ['places', 'Places'], ['subscribed', 'Subscribed calendars'], ['google', 'Synced with Google'], ['share', 'Share links'],
201
248
  ...(bookingsEnabled() ? [['bookings', 'Bookings']] : []), ['defaults', 'Defaults'],
202
249
  ] as const;
203
250
  ---
@@ -212,6 +259,7 @@ const sections = [
212
259
  </div>
213
260
 
214
261
  {notice && <p class="notice" role="status">{notice}</p>}
262
+ {errorNotice && <p class="notice warn" role="alert">{errorNotice}</p>}
215
263
 
216
264
  <div class="cs">
217
265
  <nav class="cs-nav" aria-label="Sections">
@@ -306,6 +354,119 @@ const sections = [
306
354
  ))}
307
355
  </section>
308
356
 
357
+ <!-- Synced with Google -->
358
+ <section class="cs-sect" id="google">
359
+ <header>
360
+ <div><h2>Synced with Google</h2><p>Editable copies of your calendars in Google Calendar. Changes on either side show up on the other within a few minutes.</p></div>
361
+ {gConnected && canLink && <button class="btn" type="button" data-modal="gcal-link-new" disabled={!freeGoogle.length} title={freeGoogle.length ? undefined : 'Every Google calendar this account can edit is linked'}>+ Link a Google calendar</button>}
362
+ </header>
363
+ {gStep === 'finish' && canConnect && (
364
+ <form method="post" action="/admin/calendar/google" class="cs-form" data-gcal-finish>
365
+ <input type="hidden" name="op" value="finish" />
366
+ <p class="gcal-note">Google sent you back. Finishing the connection…</p>
367
+ <div><button class="btn primary" type="submit">Finish connecting</button></div>
368
+ </form>
369
+ )}
370
+ {gStep === 'error' && <p class="gcal-note is-bad">Google didn’t finish the sign-in. Check that the return address in Google Cloud matches exactly, then connect again.</p>}
371
+ {!google.encryptionReady && <p class="gcal-note is-bad">Set SESSION_SECRET (or LIVE_ENCRYPTION_KEY) to at least 32 random characters on your host before connecting Google.</p>}
372
+ {!gAccount || gAccount.status === 'setup' ? (
373
+ <div>
374
+ <div class="cs-row">
375
+ <i class="gcal-dot" aria-hidden="true" />
376
+ <div class="cs-main"><b>Connect a Google account</b><small>Use the account that owns your calendars (for a church, its Google Workspace account). The site asks to see and edit events, and to list your calendars. Nothing else.</small></div>
377
+ <div class="cs-actions">
378
+ {canConnect ? (gAccount && (
379
+ <form method="post" action="/admin/calendar/google"><input type="hidden" name="op" value="connect" /><button class="btn primary" type="submit">Connect Google</button></form>
380
+ )) : <small class="hint">Someone who can manage connected services connects Google.</small>}
381
+ </div>
382
+ </div>
383
+ {canConnect && (
384
+ <details class="gcal-steps" open={!gAccount}>
385
+ <summary>Before you connect: Google Cloud setup</summary>
386
+ <ol>
387
+ <li>Use the Google Cloud project you made for YouTube (or any project) and add a <b>second OAuth client</b> (type “Web application”) named “Calendar”. With its own client, disconnecting YouTube can’t cut off the calendar.</li>
388
+ <li>Turn on the <b>Google Calendar API</b> in that project.</li>
389
+ <li>Add this return address under “Authorized redirect URIs”:
390
+ <span class="cs-share gcal-copy"><input type="text" class="mono" readonly value={google.callbackUrl} aria-label="Return address" /><button class="btn sm" type="button" data-copy={google.callbackUrl}>Copy</button></span>
391
+ </li>
392
+ <li>Audience: <b>Internal</b> if the project belongs to your Google Workspace organization (no “unverified app” screen, no expiry). Otherwise choose <b>External</b> and set it to <b>In production</b>: in “Testing”, Google ends the connection every 7 days.</li>
393
+ <li>If your Workspace admin limits outside apps, mark this one as trusted in the Google Admin console → Security → API controls.</li>
394
+ </ol>
395
+ <form method="post" action="/admin/calendar/google" class="gcal-client">
396
+ <input type="hidden" name="op" value="client" />
397
+ <div class="field-row">
398
+ <label class="field"><span>Client ID</span><input class="mono" type="text" name="clientId" value={gAccount?.clientId ?? ''} placeholder="…apps.googleusercontent.com" required /></label>
399
+ <label class="field"><span>Client secret</span><input class="mono" type="password" name="clientSecret" placeholder={gAccount ? 'Saved: leave empty to keep it' : ''} autocomplete="off" /></label>
400
+ </div>
401
+ <div><button class="btn" type="submit">{gAccount ? 'Save client' : 'Save, then connect'}</button></div>
402
+ </form>
403
+ </details>
404
+ )}
405
+ </div>
406
+ ) : (
407
+ <div>
408
+ <div class="cs-row">
409
+ <i class={`gcal-dot ${gConnected ? 'ok' : 'bad'}`} aria-hidden="true" />
410
+ <div class="cs-main">
411
+ <b>{gAccount.email || 'Google account'}</b>
412
+ <small>{gConnected ? `Connected ${gAccount.connectedAt ? agoText(gAccount.connectedAt) : ''} · can see and edit events` : gAccount.error || 'Google refused the saved sign-in. Changes wait until you reconnect.'}</small>
413
+ </div>
414
+ <div class="cs-actions">
415
+ <span class={`gcal-chip ${gConnected ? 'ok' : 'bad'}`}>{gConnected ? 'Connected' : 'Reconnect Google'}</span>
416
+ {canConnect && !gConnected && <form method="post" action="/admin/calendar/google"><input type="hidden" name="op" value="connect" /><button class="btn sm primary" type="submit">Reconnect</button></form>}
417
+ {canLink && gConnected && <form method="post" action="/admin/calendar/google"><input type="hidden" name="op" value="refresh" /><button class="btn sm ghost" type="submit" title="List the Google calendars this account can edit again">Refresh calendars</button></form>}
418
+ {canConnect && <form method="post" action="/admin/calendar/google"><input type="hidden" name="op" value="disconnect" /><button class="btn sm ghost" type="submit" data-confirm="Disconnect Google? Links pause, and every event stays in both places.">Disconnect</button></form>}
419
+ </div>
420
+ </div>
421
+ {google.links.map((l) => { const st = linkState(l); const cal = calById.get(l.calendarId); return (
422
+ <div class="cs-row" id={`gcal-${l.id}`}>
423
+ <i class="cal-dot lg" style={`background:${safeColor(cal?.color ?? '#8a8a8a')}`} aria-hidden="true" />
424
+ <div class="cs-main">
425
+ <b>{cal?.name ?? 'A deleted calendar'} <span aria-label={l.direction === 'both' ? 'syncs both ways with' : l.direction === 'out' ? 'copies to' : 'copies from'}>{ARROW[l.direction]}</span> {l.googleName}</b>
426
+ <small>{linkLine(l)}</small>
427
+ {l.state === 'active' && l.error && <small class="gcal-err">{l.error}</small>}
428
+ {l.guard && <small class="gcal-err">This copy isn’t the site that linked Google ({l.siteUrl}). It stays paused here.</small>}
429
+ </div>
430
+ <div class="cs-actions">
431
+ <span class={`gcal-chip ${st.tone}`}>{st.label}</span>
432
+ {canLink && (l.state === 'matching'
433
+ ? <a class="btn sm primary" href={`/admin/calendar/google/match?link=${encodeURIComponent(l.id)}`}>Review matches</a>
434
+ : <form method="post" action="/admin/calendar/google"><input type="hidden" name="link" value={l.id} /><button class="btn sm" type="submit" name="op" value={l.state === 'paused' ? 'resume' : 'sync'}
435
+ data-confirm={l.pausedReason === 'restored' ? 'Resume after the restore? Google’s copies take the restored details (the site is the main calendar), replacing changes made since the backup on either side. Events Google has that the backup doesn’t come back to the site.' : undefined}>{l.state === 'paused' ? 'Resume' : 'Sync now'}</button></form>)}
436
+ {canLink && <button class="btn sm ghost" type="button" data-modal={`gcal-edit-${l.id}`}>Edit</button>}
437
+ </div>
438
+ </div>
439
+ ); })}
440
+ {gConnected && !google.links.length && <p class="cs-empty">No calendars linked yet. Link one, review what’s already on both sides, then start syncing. Nothing is written before that.</p>}
441
+ {google.links.some((l) => l.state === 'active') && !google.scheduler.healthy && <p class="gcal-note is-bad">The scheduler hasn’t run in the last few minutes, so the sync is waiting. Check the scheduler on your host.</p>}
442
+ {google.queue.failing > 0 && <p class="gcal-note">{google.queue.failing} change{google.queue.failing === 1 ? '' : 's'} waiting to go to Google after an error. They’re retried on their own.</p>}
443
+ </div>
444
+ )}
445
+ {google.log.length > 0 && (
446
+ <div class="gcal-log">
447
+ <div class="gcal-log-head"><b>Recent changes</b><small>Kept for 90 days</small></div>
448
+ <div class="gcal-table">
449
+ <table>
450
+ <thead><tr><th>When</th><th>Direction</th><th>Event</th><th>What changed</th><th><span class="sr-only">Actions</span></th></tr></thead>
451
+ <tbody>
452
+ {google.log.map((x) => (
453
+ <tr>
454
+ <td class="gcal-when">{logTime(x.at)}</td>
455
+ <td><span class={`gcal-dir is-${x.direction}`}>{LOG_DIRECTION[x.direction] ?? x.direction}</span></td>
456
+ <td>{x.eventId ? <a href={`/admin/calendar/events/${encodeURIComponent(x.eventId)}`}>{x.title || 'Untitled event'}</a> : x.title}</td>
457
+ <td>{x.summary}</td>
458
+ <td>{x.direction === 'clash' && x.data && (x.data as { value?: unknown }).value && !x.resolved && canUse && (
459
+ <form method="post" action="/admin/calendar/google"><input type="hidden" name="op" value="use" /><input type="hidden" name="log" value={x.id} /><button class="btn sm ghost" type="submit">Use Google’s</button></form>
460
+ )}</td>
461
+ </tr>
462
+ ))}
463
+ </tbody>
464
+ </table>
465
+ </div>
466
+ </div>
467
+ )}
468
+ </section>
469
+
309
470
  <!-- Share links -->
310
471
  <section class="cs-sect" id="share">
311
472
  <header>
@@ -457,6 +618,66 @@ const sections = [
457
618
  </Modal>
458
619
  ))}
459
620
 
621
+ {gConnected && canLink && [null, ...google.links].map((l) => (
622
+ <Modal id={l ? `gcal-edit-${l.id}` : 'gcal-link-new'} title={l ? `${calById.get(l.calendarId)?.name ?? 'Calendar'} ${ARROW[l.direction]} ${l.googleName}` : 'Link a Google calendar'}>
623
+ <form method="post" action="/admin/calendar/google" class="gcal-form">
624
+ <input type="hidden" name="op" value={l ? 'link-edit' : 'link-new'} />
625
+ {l && <input type="hidden" name="link" value={l.id} />}
626
+ {!l && <p class="hint">Step 1 of 2. Nothing is written yet: next you review what’s on both sides.</p>}
627
+ {!l && (
628
+ <div class="field-row">
629
+ <label class="field"><span>Site calendar</span>
630
+ <select name="calendarId" required>{natives.map((c) => <option value={c.id}>{c.name}</option>)}</select>
631
+ </label>
632
+ <label class="field"><span>Google calendar</span>
633
+ <select name="googleId" required>{freeGoogle.map((c) => <option value={c.id}>{c.name}{c.primary ? ' (main)' : ''}</option>)}</select>
634
+ </label>
635
+ </div>
636
+ )}
637
+ <fieldset class="gcal-fieldset">
638
+ <legend>Direction</legend>
639
+ <div class="cs-choices">
640
+ {DIRECTIONS.map(([d, label, note]) => (
641
+ <label class="cs-choice"><input type="radio" name="direction" value={d} checked={(l?.direction ?? 'both') === d} /><span><b>{label}</b><small>{note}</small></span></label>
642
+ ))}
643
+ </div>
644
+ </fieldset>
645
+ {google.resources.length > 0 && (
646
+ <fieldset class="gcal-fieldset">
647
+ <legend>This Google calendar is the room</legend>
648
+ {google.resources.map((r) => <label class="check-row"><input type="checkbox" name="roomIds" value={r.id} checked={l?.roomIds.includes(r.id) ?? false} /> {r.name}</label>)}
649
+ <label class="check-row"><input type="checkbox" name="noRoom" checked={l?.noRoom ?? false} /> Also gets this calendar’s events that hold no room</label>
650
+ <small class="hint">Events from Google hold the rooms you tick, and site events holding them go here. Tick none to sync the whole calendar.</small>
651
+ </fieldset>
652
+ )}
653
+ <fieldset class="gcal-fieldset">
654
+ <legend>Titles that set a tag</legend>
655
+ {[...(l?.rules ?? []), { prefix: '', tag: '' }, { prefix: '', tag: '' }].map((r, i) => (
656
+ <div class="gcal-rule">
657
+ <input type="text" name="rulePrefix" value={r.prefix} placeholder={i === 0 ? 'Prefix, like CLASS:' : ''} aria-label="Title prefix" maxlength="40" />
658
+ <span aria-hidden="true">⇄</span>
659
+ <input type="text" name="ruleTag" value={r.tag} placeholder={i === 0 ? 'Tag, like Class' : ''} aria-label="Tag" maxlength="80" />
660
+ </div>
661
+ ))}
662
+ <small class="hint">A Google title starting with the prefix gives the site event the tag, and a tagged site event gets the prefix in Google. The prefix stays in the title on both sides.</small>
663
+ </fieldset>
664
+ <label class="field"><span>Booking pages in this Google calendar <em class="hint">one title per line</em></span>
665
+ <textarea name="bookingTitles" rows="2" placeholder={'Band\nRehearsal Time'}>{(l?.bookingTitles ?? []).join('\n')}</textarea>
666
+ <small class="hint">Appointments booked on these Google booking pages come in as “Booked”, holding the room, without names or descriptions.</small>
667
+ </label>
668
+ {!l && <label class="field"><span>Start from</span><input type="date" name="startDate" value={startDefault} /><small class="hint">Events from this date on, plus repeating series still running.</small></label>}
669
+ <p class="hint">For every link: new Google events are published right away; deleted in Google means cancelled here; cancelled here shows as “Cancelled:” in Google. Guests are never added and Google never emails anyone.</p>
670
+ <div class="modal-foot">
671
+ {l && <button class="btn danger ghost" type="submit" name="op" value="unlink" formnovalidate data-confirm="Unlink? Every event stays in both places; linking again pairs them up.">Unlink</button>}
672
+ {l && l.state !== 'paused' && <button class="btn ghost" type="submit" name="op" value="pause" formnovalidate>Pause</button>}
673
+ <span class="spacer" />
674
+ <button class="btn ghost" type="button" data-close>Cancel</button>
675
+ <button class="btn primary" type="submit">{l ? 'Save' : 'Continue to matching'}</button>
676
+ </div>
677
+ </form>
678
+ </Modal>
679
+ ))}
680
+
460
681
  <Modal id="cal-new" title="New calendar" open={Astro.url.searchParams.has('new')}>
461
682
  <form method="post">
462
683
  <input type="hidden" name="op" value="create" />
@@ -542,6 +763,44 @@ const sections = [
542
763
  .cs-pinline.is-error span { color: var(--red); }
543
764
  .cs-map { margin: 0 0 4px; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
544
765
  .cs-map img { display: block; width: 100%; height: auto; }
766
+ .gcal-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--line-strong); justify-self: center; }
767
+ .gcal-dot.ok { background: var(--green); }
768
+ .gcal-dot.bad { background: var(--red); }
769
+ .gcal-chip { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: var(--text-2); white-space: nowrap; }
770
+ .gcal-chip::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: var(--muted); }
771
+ .gcal-chip.ok::before { background: var(--green); }
772
+ .gcal-chip.warn::before { background: var(--amber, #c98a12); }
773
+ .gcal-chip.bad { color: var(--red); }
774
+ .gcal-chip.bad::before { background: var(--red); }
775
+ .gcal-err { color: var(--red) !important; }
776
+ .gcal-note { margin: 0; padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 13px; color: var(--text-2); }
777
+ .gcal-note.is-bad { color: var(--red); }
778
+ .gcal-steps { padding: 4px 16px 14px; border-bottom: 1px solid var(--line); font-size: 13px; color: var(--text-2); }
779
+ .gcal-steps summary { cursor: pointer; padding: 8px 0; font-weight: 600; color: var(--text); }
780
+ .gcal-steps ol { margin: 4px 0 12px; padding-left: 20px; display: flex; flex-direction: column; gap: 6px; }
781
+ .gcal-copy { margin-top: 6px; }
782
+ .gcal-client { display: flex; flex-direction: column; gap: 8px; }
783
+ .gcal-log { border-top: 1px solid var(--line); }
784
+ .gcal-log-head { display: flex; align-items: baseline; gap: 10px; padding: 12px 16px 6px; }
785
+ .gcal-log-head b { font-size: 13.5px; }
786
+ .gcal-log-head small { color: var(--muted); font-size: 12px; }
787
+ .gcal-table { overflow-x: auto; }
788
+ .gcal-table table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
789
+ .gcal-table th { text-align: left; font-weight: 600; color: var(--muted); padding: 6px 16px; border-bottom: 1px solid var(--line); }
790
+ .gcal-table td { padding: 8px 16px; border-bottom: 1px solid var(--line); vertical-align: top; color: var(--text-2); }
791
+ .gcal-table tr:last-child td { border-bottom: 0; }
792
+ .gcal-when { white-space: nowrap; font-variant-numeric: tabular-nums; }
793
+ .gcal-dir { white-space: nowrap; font-weight: 600; }
794
+ .gcal-dir.is-clash { color: var(--red); }
795
+ .gcal-form { display: flex; flex-direction: column; gap: 12px; }
796
+ .gcal-fieldset { border: 1px solid var(--line); border-radius: 10px; padding: 10px 13px 12px; margin: 0; display: flex; flex-direction: column; gap: 6px; }
797
+ .gcal-fieldset legend { color: var(--muted); font-size: 12px; padding: 0 4px; }
798
+ .gcal-fieldset .check-row { margin: 0; }
799
+ .gcal-rule { display: grid; grid-template-columns: 1fr auto 1fr; gap: 8px; align-items: center; }
800
+ .gcal-rule input { padding: 6px 8px; border: 1px solid var(--line-strong); border-radius: 7px; background: var(--bg); color: var(--text); font: inherit; font-size: 13px; }
801
+ .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
802
+ #google > header .btn, .gcal-table td:last-child .btn { white-space: nowrap; }
803
+ .gcal-table td:nth-child(3) { min-width: 110px; }
545
804
  [hidden] { display: none !important; }
546
805
  @media (max-width: 860px) {
547
806
  .cs { grid-template-columns: 1fr; }
@@ -551,6 +810,13 @@ const sections = [
551
810
  .cs-actions, .cs-share { grid-column: 2; flex-wrap: wrap; }
552
811
  .cs-share input { width: 100%; }
553
812
  .cs-field { grid-template-columns: 1fr; gap: 4px; }
813
+ #google > header { flex-wrap: wrap; }
814
+ .gcal-table thead { display: none; }
815
+ .gcal-table tr { display: grid; grid-template-columns: auto 1fr; gap: 2px 10px; padding: 9px 16px; border-bottom: 1px solid var(--line); }
816
+ .gcal-table tr:last-child { border-bottom: 0; }
817
+ .gcal-table td { padding: 0; border: 0; }
818
+ .gcal-table td:nth-child(n + 3) { grid-column: 1 / -1; }
819
+ .gcal-table td:last-child:empty { display: none; }
554
820
  }
555
821
  </style>
556
822
 
@@ -606,6 +872,9 @@ const sections = [
606
872
  clear.addEventListener('click', () => { lat.value = ''; lng.value = ''; show('Pin removed — it’s gone when you save.'); });
607
873
  }
608
874
 
875
+ // back from Google's sign-in: finish it for this session at once (the button is the fallback)
876
+ document.querySelector<HTMLFormElement>('[data-gcal-finish]')?.requestSubmit();
877
+
609
878
  // approval: rules only for "unless…", the deadline only when bookings can wait
610
879
  const form = document.querySelector<HTMLFormElement>('[data-approval]');
611
880
  form?.addEventListener('change', () => {
@@ -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
- {eventRequests.length > 0 && (
174
+ {waitingRequests > 0 && (
171
175
  <div class="notice">
172
176
  <div class="grow-text">
173
- <b>{eventRequests.length} event request{eventRequests.length === 1 ? '' : 's'} waiting</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>
@@ -97,6 +97,12 @@ const SCOPE_LABEL: Record<string, string> = {
97
97
  <h2>Live video</h2><p>Connect your own Twitch or YouTube app to discover broadcasts automatically on your homepage and links page.</p>
98
98
  <a class="btn" href="/admin/live">Set up live video →</a>
99
99
  </div>
100
+ {CALENDAR.enabled && (
101
+ <div class="card" style="padding:20px;margin-bottom:20px">
102
+ <h2>Google Calendar</h2><p>Keep your calendars and Google Calendar in step, both ways: connect Google and link calendars in Calendar settings.</p>
103
+ <a class="btn" href="/admin/calendar/settings#google">Set up Google Calendar sync →</a>
104
+ </div>
105
+ )}
100
106
 
101
107
  <div class="page-head">
102
108
  <div>
@@ -0,0 +1,16 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { getRepo } from '../../../../../lib/repo';
3
+ import { receiveGoogleCallback } from '../../../../../lib/calendar/google/connect';
4
+ // Google's redirect back is a cross-site GET that may carry no usable sign-in, so, as with YouTube,
5
+ // it only parks the code on its one-use pending state; Calendar settings then finishes it with a
6
+ // same-origin POST bound to the session that began it. The code never enters a URL again.
7
+ export const GET: APIRoute = async ({ locals, url }) => {
8
+ const headers = { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' };
9
+ try {
10
+ await receiveGoogleCallback(await getRepo(locals), url, locals);
11
+ return new Response(null, { status: 303, headers: { ...headers, Location: '/admin/calendar/settings?google=finish#google' } });
12
+ } catch {
13
+ // never reflect codes, tokens or Google's error text into a page or URL
14
+ return new Response(null, { status: 303, headers: { ...headers, Location: '/admin/calendar/settings?google=error#google' } });
15
+ }
16
+ };
@@ -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 repeat = repeatChoices(v.repeat, v.date);
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>Title</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>When</legend>
36
+ <legend>{f.when}</legend>
31
37
  <div class="pc-mem-row">
32
- <label class="pc-bk-field"><span>Date</span><input type="date" name="date" required value={v.date} /></label>
33
- <label class="pc-bk-field pc-mem-time"><span>Starts</span><input type="time" name="startTime" value={v.startTime} /></label>
34
- <label class="pc-bk-field pc-mem-time"><span>Ends <em>(optional)</em></span><input type="time" name="endTime" value={v.endTime} /></label>
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>All day <small>No start or end time</small></span></label>
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">This event runs over {v.days + 1} days. Changing the date moves all of them.</p>
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>Repeats</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>Where</span>
53
- <input name="where" maxlength={L.where} value={v.where} placeholder="A room, an address, or “Online”" />
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>Description</span>
62
+ <span>{f.description}</span>
57
63
  <textarea name="description" rows="6" maxlength={L.description}>{v.description}</textarea>
58
- <small>Plain text. Leave a blank line between paragraphs.</small>
64
+ <small>{f.descriptionHint}</small>
59
65
  </label>
60
66
  {calendars.length > 0 && (
61
67
  <label class="pc-bk-field">
62
- <span>Calendar</span>
68
+ <span>{f.calendar}</span>
63
69
  <select name="calendarId">
64
- <option value="">Not sure (the team will choose)</option>
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((f) => (
75
+ {fields.map((fd) => (
70
76
  <label class="pc-bk-field">
71
- <span>{f.label}</span>
72
- {f.type === 'textarea' ? (
73
- <textarea name={`field_${f.key}`} rows="3" maxlength={L.field}>{v.fields[f.key] ?? ''}</textarea>
74
- ) : f.type === 'select' ? (
75
- <select name={`field_${f.key}`}>
76
- <option value="">Choose…</option>
77
- {f.options.map((o) => <option value={o} selected={o === v.fields[f.key]}>{o}</option>)}
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(f.type)} name={`field_${f.key}`} maxlength={f.type === 'number' ? undefined : L.field} step={f.type === 'number' ? 'any' : undefined} value={v.fields[f.key] ?? ''} />
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>Note for the team <em>(only staff see this)</em></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>Signed in as {user.name || user.email}</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">Open the admin</a>}
20
- <form method="post" action="/admin/logout"><button type="submit">Sign out</button></form>
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>