@acms-subir/core 0.3.21 → 0.3.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/cli/migrations.generated.mjs +6 -0
- package/docs/mobile.md +1 -1
- package/docs/sections.md +2 -2
- package/docs/server.md +210 -29
- package/docs/theme-authoring.md +1 -1
- package/integration.mjs +134 -1
- package/package.json +1 -1
- package/src/actions/index.ts +3 -1
- package/src/components/EmailSettings.tsx +1 -1
- package/src/components/RolesManager.tsx +15 -7
- package/src/components/calendar/CalendarBoard.tsx +5 -3
- package/src/components/sections/SectionFields.tsx +31 -10
- package/src/layouts/Admin.astro +2 -2
- package/src/lib/auth/accounts.ts +2 -2
- package/src/lib/auth/request.ts +19 -7
- package/src/lib/auth/return.ts +32 -0
- package/src/lib/auth.ts +17 -4
- package/src/lib/calendar/board.ts +12 -1
- package/src/lib/calendar/cards.ts +6 -4
- package/src/lib/calendar/member-form.ts +152 -0
- package/src/lib/calendar/requests.ts +284 -0
- package/src/lib/calendar/schedule.ts +1 -1
- package/src/lib/editor/auth.ts +5 -3
- package/src/lib/editor/schemas.ts +2 -1
- package/src/lib/editor/store.ts +5 -3
- package/src/lib/email/service.ts +16 -3
- package/src/lib/email/store.ts +3 -1
- package/src/lib/email/templates.ts +12 -0
- package/src/lib/mobile/calendar.ts +6 -2
- package/src/lib/permissions/catalog.ts +84 -36
- package/src/lib/permissions/service.ts +11 -8
- package/src/lib/repo/migrations.ts +6 -0
- package/src/lib/repo/sql.ts +23 -5
- package/src/lib/repo/types.ts +5 -1
- package/src/lib/roles.ts +46 -4
- package/src/lib/schema.ts +43 -2
- package/src/lib/section-targets.ts +10 -0
- package/src/lib/sections.ts +7 -5
- package/src/lib/sync/catalog.ts +3 -0
- package/src/middleware.ts +8 -1
- package/src/pages/admin/app-auth.astro +5 -3
- package/src/pages/admin/calendar/events/[id].astro +249 -2
- package/src/pages/admin/calendar/events/index.astro +165 -12
- package/src/pages/admin/index.astro +22 -0
- package/src/pages/admin/login.astro +37 -16
- package/src/pages/admin/roles.astro +3 -0
- package/src/pages/admin/users.astro +15 -14
- package/src/pages/api/calendar/events/index.ts +8 -1
- package/src/pages/api/search.ts +3 -1
- package/src/pages/author/[slug].astro +3 -1
- package/src/pages/members/_EventForm.astro +89 -0
- package/src/pages/members/_Layout.astro +76 -0
- package/src/pages/members/_http.ts +25 -0
- package/src/pages/members/events/[id].astro +86 -0
- package/src/pages/members/events/index.astro +218 -0
- package/src/pages/members/events/new.astro +48 -0
- package/src/pages/members/index.ts +6 -0
- package/src/server/index.ts +111 -20
- package/src/theme-sdk/calendar.ts +6 -2
- package/src/theme-sdk/fields.mjs +7 -2
- package/src/virtual.d.ts +4 -0
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { safeNext } from '../../lib/editor/next';
|
|
3
|
+
import { afterSignIn, isAdminPath, signInReturn } from '../../lib/auth/return';
|
|
4
|
+
import { isStaffRole, roleHome, roleLabel } from '../../lib/roles';
|
|
3
5
|
import { checkPassword, createSessionToken, verifyPassword, SESSION_COOKIE } from '../../lib/auth';
|
|
4
6
|
import { getRequestAuth } from '../../lib/auth/request';
|
|
5
7
|
import { AUTH } from '../../lib/auth/config';
|
|
@@ -13,23 +15,25 @@ import '../../styles/admin.css';
|
|
|
13
15
|
|
|
14
16
|
// ?next= returns to the editor-app sign-in page only (never another site)
|
|
15
17
|
const next = safeNext(Astro.url.searchParams.get('next'), Astro.url);
|
|
16
|
-
// ?return= brings
|
|
17
|
-
// (
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (url.origin !== Astro.url.origin || !/^\/admin(\/|$)/.test(url.pathname)) return null;
|
|
23
|
-
if (/^\/admin\/(login|logout|app-auth)\b/.test(url.pathname)) return null;
|
|
24
|
-
return url.pathname + url.search + url.hash;
|
|
25
|
-
}
|
|
26
|
-
const back = next ? null : adminReturn(Astro.url.searchParams.get('return'));
|
|
27
|
-
const reverify = !!back && Astro.url.searchParams.get('reverify') === '1';
|
|
18
|
+
// ?return= brings people back to the page they signed in from: any page on
|
|
19
|
+
// this site (lib/auth/return.ts), e.g. an owner re-verifying on an admin page
|
|
20
|
+
// (a fresh sign-in is the recent verification sensitive changes need) or a
|
|
21
|
+
// class host coming back to /classes
|
|
22
|
+
const back = next ? null : signInReturn(Astro.url.searchParams.get('return'), Astro.url);
|
|
23
|
+
const reverify = !!back && isAdminPath(back) && Astro.url.searchParams.get('reverify') === '1';
|
|
28
24
|
const clerkMode = AUTH.provider === 'clerk';
|
|
29
25
|
const auth = await getRequestAuth(Astro.request, Astro.cookies, Astro.locals);
|
|
30
26
|
if (auth.userId) {
|
|
31
27
|
return Astro.redirect(next ?? back ?? '/admin');
|
|
32
28
|
}
|
|
29
|
+
// signed in, but in a site role without admin access: back to the site,
|
|
30
|
+
// except from the editor app's sign-in, which can't take them (say so)
|
|
31
|
+
let member: { name: string; role: string } | null = null;
|
|
32
|
+
if (auth.person) {
|
|
33
|
+
if (!next) return Astro.redirect(afterSignIn(auth.person.role, back));
|
|
34
|
+
const who = await (await getRepo(Astro.locals)).getUser(auth.person.id);
|
|
35
|
+
member = { name: who?.name ?? '', role: auth.person.role };
|
|
36
|
+
}
|
|
33
37
|
|
|
34
38
|
// Clerk mode: the provider's prebuilt components do every credential step on
|
|
35
39
|
// this page. Local passwords, the owner password and recovery links are off.
|
|
@@ -69,6 +73,7 @@ if (!clerkMode && Astro.request.method === 'POST') {
|
|
|
69
73
|
// a user account created in /admin/users.
|
|
70
74
|
let sessionUserId: string | null = null;
|
|
71
75
|
let sessionVersion = 0;
|
|
76
|
+
let sessionRole = 'owner', sessionName = '';
|
|
72
77
|
let address = 'unknown';
|
|
73
78
|
try { address = Astro.clientAddress || address; } catch { /* shared fallback bucket */ }
|
|
74
79
|
const repo = await getRepo(Astro.locals);
|
|
@@ -79,7 +84,7 @@ if (!clerkMode && Astro.request.method === 'POST') {
|
|
|
79
84
|
sessionUserId = null; // default token = env owner
|
|
80
85
|
} else if (email) {
|
|
81
86
|
const auth = await repo.getUserAuth(email);
|
|
82
|
-
if (auth && (await verifyPassword(password, auth.passHash))) { sessionUserId = auth.user.id; sessionVersion = auth.sessionVersion; }
|
|
87
|
+
if (auth && (await verifyPassword(password, auth.passHash))) { sessionUserId = auth.user.id; sessionVersion = auth.sessionVersion; sessionRole = auth.user.role; sessionName = auth.user.name; }
|
|
83
88
|
else {
|
|
84
89
|
error = 'Wrong email or password.';
|
|
85
90
|
}
|
|
@@ -95,7 +100,8 @@ if (!clerkMode && Astro.request.method === 'POST') {
|
|
|
95
100
|
maxAge: 7 * 24 * 60 * 60,
|
|
96
101
|
secure: Astro.url.protocol === 'https:',
|
|
97
102
|
});
|
|
98
|
-
|
|
103
|
+
if (!isStaffRole(sessionRole) && next) member = { name: sessionName, role: sessionRole };
|
|
104
|
+
else return Astro.redirect(next && isStaffRole(sessionRole) ? next : afterSignIn(sessionRole, back));
|
|
99
105
|
}
|
|
100
106
|
}
|
|
101
107
|
---
|
|
@@ -120,7 +126,22 @@ if (!clerkMode && Astro.request.method === 'POST') {
|
|
|
120
126
|
<div class="brand-name">{siteName}</div>
|
|
121
127
|
</div>
|
|
122
128
|
|
|
123
|
-
{
|
|
129
|
+
{member && (
|
|
130
|
+
<section class="login-state" aria-labelledby="login-state-h">
|
|
131
|
+
<span class="login-state-icon is-warn" aria-hidden="true">
|
|
132
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 8v5M12 16h.01" /></svg>
|
|
133
|
+
</span>
|
|
134
|
+
<h1 id="login-state-h">No access to the editor app</h1>
|
|
135
|
+
<p>You’re signed in{member.name ? <> as <b>{member.name}</b></> : ''} ({roleLabel(member.role)}).</p>
|
|
136
|
+
<p class="hint">This account can sign in to the site, but not the admin or the editor app.</p>
|
|
137
|
+
<div class="login-actions">
|
|
138
|
+
<a class="btn primary block" href={roleHome(member.role)}>Go to the site</a>
|
|
139
|
+
<form method="post" action="/admin/logout"><button class="btn block" type="submit">Sign out</button></form>
|
|
140
|
+
</div>
|
|
141
|
+
</section>
|
|
142
|
+
)}
|
|
143
|
+
|
|
144
|
+
{!member && !clerkMode && (
|
|
124
145
|
<>
|
|
125
146
|
{next && !error && <p class="hint login-for-app">Sign in to connect the app to your account.</p>}
|
|
126
147
|
{error && <div class="flash error">{error}</div>}
|
|
@@ -140,7 +161,7 @@ if (!clerkMode && Astro.request.method === 'POST') {
|
|
|
140
161
|
</>
|
|
141
162
|
)}
|
|
142
163
|
|
|
143
|
-
{clerkMode && (
|
|
164
|
+
{!member && clerkMode && (
|
|
144
165
|
<div class="login-clerk" data-acms-login data-view={clerkView} data-next={next ?? back ?? '/admin'} data-keep={keep || undefined} data-logout={logoutAction} data-signed-out={signedOut ? '1' : undefined}>
|
|
145
166
|
{(clerkView === 'sign-in' || clerkView === 'sign-up') && (
|
|
146
167
|
<>
|
|
@@ -40,6 +40,9 @@ if (access?.role !== 'owner' && access?.role !== 'administrator') return Astro.r
|
|
|
40
40
|
.rp-area-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; font-size: 14px; }
|
|
41
41
|
.rp-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 14px; align-items: center; padding: 8px 10px; margin: 0 -10px; border-radius: var(--r-md); }
|
|
42
42
|
.rp-row.changed { background: var(--accent-soft); }
|
|
43
|
+
.rp-row.na .rp-text { opacity: 0.55; }
|
|
44
|
+
.rp-role-sep { font-size: 11px; font-weight: 600; color: var(--muted); padding: 12px 11px 4px; }
|
|
45
|
+
.rp-note { font-size: 12.5px; color: var(--text-2); background: var(--panel-2); border-radius: var(--r-md); padding: 8px 11px; margin: 8px 0 0; }
|
|
43
46
|
.rp-text { display: grid; gap: 1px; }
|
|
44
47
|
.rp-label { font-size: 14px; display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
|
45
48
|
.rp-text small { color: var(--muted); font-size: 12.5px; }
|
|
@@ -12,10 +12,10 @@ import { issueAccountToken } from '../../lib/email/accounts';
|
|
|
12
12
|
import { kickEmail } from '../../lib/email/service';
|
|
13
13
|
import { waitUntilFrom } from '../../lib/webhooks';
|
|
14
14
|
import { requireSession } from '../../lib/api';
|
|
15
|
-
import { slugify, authorSlug, Role } from '../../lib/schema';
|
|
16
|
-
import {
|
|
15
|
+
import { slugify, authorSlug, type Role } from '../../lib/schema';
|
|
16
|
+
import { isRole, parseRole, roleLabel, roleSummary, rolesByRank } from '../../lib/roles';
|
|
17
17
|
import { accessFor } from '../../lib/permissions/access';
|
|
18
|
-
import {
|
|
18
|
+
import { assignableRoles, ranksBelow } from '../../lib/permissions/catalog';
|
|
19
19
|
import { EDITOR } from '../../lib/editor/config';
|
|
20
20
|
|
|
21
21
|
const repo = await getRepo(Astro.locals);
|
|
@@ -39,7 +39,7 @@ const canInvite = access?.can('team.invite') ?? false;
|
|
|
39
39
|
const canManage = access?.can('team.manage') ?? false;
|
|
40
40
|
// roles this person may give out (owners: all; everyone else: below their own)
|
|
41
41
|
const assignable = access ? assignableRoles(access.role) : [];
|
|
42
|
-
const below = (role: Role) => isOwner || (!!access &&
|
|
42
|
+
const below = (role: Role) => isOwner || (!!access && ranksBelow(role, access.role));
|
|
43
43
|
const manages = (u: { id: string; role: Role }) => canManage && below(u.role) && (isOwner || u.id !== sessionUserId);
|
|
44
44
|
|
|
45
45
|
let error = '';
|
|
@@ -90,16 +90,16 @@ if (Astro.request.method === 'POST' && (canInvite || canManage) && !error) {
|
|
|
90
90
|
if (inviteOp && !canInvite) throw new Error('Your role doesn’t include “Invite people”. Ask an owner if you need it.');
|
|
91
91
|
if (!inviteOp && !canManage) throw new Error('Your role doesn’t include “Manage members”. Ask an owner if you need it.');
|
|
92
92
|
if (op === 'link' && !isOwner) throw new Error('Only owners can link sign-in accounts.');
|
|
93
|
-
if (op === 'create' && !assignable.includes(
|
|
93
|
+
if (op === 'create' && !assignable.includes(parseRole(form.get('role')))) throw new Error('You can only invite people to roles below your own.');
|
|
94
94
|
if (op === 'resend' || op === 'revoke') {
|
|
95
95
|
const target = clerkMode ? await repo.auth.invitation(issuer, String(form.get('id'))) : await repo.email.token(String(form.get('id')));
|
|
96
|
-
if (target && !below(
|
|
96
|
+
if (target && !below(String(target.role))) throw new Error('Only an owner can change invitations for this role.');
|
|
97
97
|
}
|
|
98
98
|
if (['update', 'delete', 'email'].includes(op)) {
|
|
99
99
|
const target = await repo.getUser(String(form.get('id')));
|
|
100
100
|
if (!target) throw new Error('The account no longer exists.');
|
|
101
101
|
if (!manages(target)) throw new Error('You can only manage people in roles below your own.');
|
|
102
|
-
if (op === 'update' && !assignable.includes(
|
|
102
|
+
if (op === 'update' && !assignable.includes(parseRole(form.get('role')))) throw new Error('You can only assign roles below your own.');
|
|
103
103
|
}
|
|
104
104
|
if (clerkMode) {
|
|
105
105
|
if (!clerkReady) throw new Error('Clerk sign-in isn’t fully configured, so accounts can’t be changed right now.');
|
|
@@ -132,7 +132,7 @@ if (Astro.request.method === 'POST' && (canInvite || canManage) && !error) {
|
|
|
132
132
|
if(!await repo.email.rateLimit(`invite:${sessionUserId}`,10,60000))throw new Error('Please wait before sending more invitations.');
|
|
133
133
|
const previous=op==='resend'?await repo.email.token(String(form.get('id'))):null;
|
|
134
134
|
if(op==='resend'&&(!previous||previous.purpose!=='invite'||previous.used_by))throw new Error('This invitation cannot be resent.');
|
|
135
|
-
const invitedRole =
|
|
135
|
+
const invitedRole = parseRole(previous ? previous.role : form.get('role'));
|
|
136
136
|
await issueAccountToken(repo,{purpose:'invite',email:previous?String(previous.email):String(form.get('email')||''),
|
|
137
137
|
name:previous?String(previous.name):String(form.get('name')||''),
|
|
138
138
|
role:invitedRole,invitedBy:sessionUserId},Astro.locals);
|
|
@@ -150,7 +150,7 @@ if (Astro.request.method === 'POST' && (canInvite || canManage) && !error) {
|
|
|
150
150
|
if (password && (password.length < 12 || password.length > 128)) throw new Error('Use a password between 12 and 128 characters.');
|
|
151
151
|
await repo.updateUser(id, {
|
|
152
152
|
name: String(form.get('name') ?? '').trim() || undefined,
|
|
153
|
-
role:
|
|
153
|
+
role: parseRole(form.get('role')),
|
|
154
154
|
bio: String(form.get('bio') ?? ''),
|
|
155
155
|
avatar: String(form.get('avatar') ?? '').trim(),
|
|
156
156
|
slug: String(form.get('slug') ?? '').trim() ? slugify(String(form.get('slug'))) : '',
|
|
@@ -266,12 +266,12 @@ const deviceGroups = [...new Set(devices.map((d) => d.userId))].map((uid) => ({
|
|
|
266
266
|
</label>
|
|
267
267
|
<fieldset class="invite-roles">
|
|
268
268
|
<legend>Role</legend>
|
|
269
|
-
{
|
|
269
|
+
{rolesByRank().map((role) => {
|
|
270
270
|
const allowed = assignable.includes(role);
|
|
271
271
|
return (
|
|
272
272
|
<label class:list={['invite-role', { 'is-off': !allowed }]}>
|
|
273
273
|
<input type="radio" name="role" value={role} checked={role === (assignable.includes('editor') ? 'editor' : assignable[assignable.length - 1])} disabled={!allowed} />
|
|
274
|
-
<span><b>{
|
|
274
|
+
<span><b>{roleLabel(role)}</b>{!allowed && <span class="pill archived">Owner only</span>}<small>{roleSummary(role)}</small></span>
|
|
275
275
|
</label>
|
|
276
276
|
);
|
|
277
277
|
})}
|
|
@@ -313,7 +313,7 @@ const deviceGroups = [...new Set(devices.map((d) => d.userId))].map((uid) => ({
|
|
|
313
313
|
</span>
|
|
314
314
|
</div>
|
|
315
315
|
</td>
|
|
316
|
-
<td><span class={`pill ${u.role === 'owner' ? 'draft' : 'scheduled'}`}>{
|
|
316
|
+
<td><span class={`pill ${u.role === 'owner' ? 'draft' : 'scheduled'}`}>{roleLabel(u.role)}</span>{!isRole(u.role) && <span class="pill edited" title="This site no longer defines this role. People with it can’t use the admin; choose another role for them.">No longer defined</span>}</td>
|
|
317
317
|
{clerkMode && isOwner && clerkReady && (
|
|
318
318
|
<td>{linked.has(u.id)
|
|
319
319
|
? <span class="pill published" title={linked.get(u.id)}>Connected</span>
|
|
@@ -366,7 +366,8 @@ const deviceGroups = [...new Set(devices.map((d) => d.userId))].map((uid) => ({
|
|
|
366
366
|
<label class="field">
|
|
367
367
|
<span>Role</span>
|
|
368
368
|
<select name="role" form={formId}>
|
|
369
|
-
{
|
|
369
|
+
{!isRole(u.role) && <option value="" selected disabled>{u.role} (no longer defined)</option>}
|
|
370
|
+
{rolesByRank().filter((role) => assignable.includes(role)).map(role => <option value={role} selected={u.role === role}>{roleLabel(role)}</option>)}
|
|
370
371
|
</select>
|
|
371
372
|
</label>
|
|
372
373
|
{!clerkMode && (
|
|
@@ -471,7 +472,7 @@ const deviceGroups = [...new Set(devices.map((d) => d.userId))].map((uid) => ({
|
|
|
471
472
|
{invitations.map(i=>{
|
|
472
473
|
const [label, tone] = clerkMode ? (stateLabel[i.state] ?? [i.state, 'archived']) : [i.state, ''];
|
|
473
474
|
const inviter = i.invitedBy ? (userById.get(i.invitedBy)?.name ?? (i.invitedBy === OWNER_ENV ? 'Owner' : 'Removed user')) : '';
|
|
474
|
-
return <tr><td>{i.email}{clerkMode && i.name && i.name !== i.email && <div class="t-sub">{i.name}</div>}{(inviter || i.createdAt) && <div class="t-sub">{inviter && <>Invited by {inviter}</>}{inviter && i.createdAt ? ' · ' : ''}{i.createdAt ? day(i.createdAt) : ''}</div>}</td><td>{
|
|
475
|
+
return <tr><td>{i.email}{clerkMode && i.name && i.name !== i.email && <div class="t-sub">{i.name}</div>}{(inviter || i.createdAt) && <div class="t-sub">{inviter && <>Invited by {inviter}</>}{inviter && i.createdAt ? ' · ' : ''}{i.createdAt ? day(i.createdAt) : ''}</div>}</td><td>{roleLabel(i.role)}</td>
|
|
475
476
|
<td>{clerkMode ? <span class={`pill ${tone}`}>{label}</span> : i.state}{clerkMode && i.state === 'pending' && <div class="t-sub">Expires {day(i.expiresAt)}</div>}</td>
|
|
476
477
|
<td class="td-actions">{i.state!=='accepted'&&!below(i.role)&&<span class="hint">Owner only</span>}{i.state!=='accepted'&&below(i.role)&&<form method="post" class="inline-form" data-reverify><input type="hidden" name="id" value={i.id}/><button class="btn sm" name="op" value="resend">Resend</button>{(i.state==='pending'||(clerkMode&&i.state==='sending'))&&<button class="btn sm ghost" name="op" value="revoke">Revoke</button>}</form>}</td></tr>;
|
|
477
478
|
})}
|
|
@@ -14,14 +14,16 @@ import { readJson } from '../../../../lib/api';
|
|
|
14
14
|
import { runAgentTool } from '../../../../lib/agent';
|
|
15
15
|
import { waitUntilFrom } from '../../../../lib/webhooks';
|
|
16
16
|
import { fail } from '../../../../lib/calendar/http';
|
|
17
|
+
import { publicTags, tagFilter } from '../../../../lib/schema';
|
|
17
18
|
|
|
18
19
|
// Public read API for other sites, apps and lobby screens.
|
|
19
20
|
// GET /api/calendar/events?from=2026-10-01&to=2026-10-31&calendar=youth&q=night
|
|
21
|
+
// GET /api/calendar/events?tag=class (or excludeTag=class; a tag's name or slug)
|
|
20
22
|
// → { timezone, from, to, occurrences: [{ id, start, end, allDay, cancelled, event }] }
|
|
21
23
|
// GET /api/calendar/events?expand=0 → { events: [...] } (rules, not instances)
|
|
22
24
|
// Anonymous callers see public calendars (and an unlisted one when asked by
|
|
23
25
|
// slug). A key with events:read also sees private calendars and private
|
|
24
|
-
// custom fields.
|
|
26
|
+
// custom fields, and its tag filters also match internal "#" tags.
|
|
25
27
|
const CORS = {
|
|
26
28
|
'Access-Control-Allow-Origin': '*',
|
|
27
29
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
@@ -55,6 +57,11 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
|
|
|
55
57
|
return full ? true : eventIsListed(e, map);
|
|
56
58
|
});
|
|
57
59
|
if (q) events = events.filter((e) => `${e.title} ${e.excerpt} ${e.location.name}`.toLowerCase().includes(q));
|
|
60
|
+
const tag = url.searchParams.get('tag')?.slice(0, 100), excludeTag = url.searchParams.get('excludeTag')?.slice(0, 100);
|
|
61
|
+
if (tag || excludeTag) {
|
|
62
|
+
const tagged = tagFilter(tag, excludeTag);
|
|
63
|
+
events = events.filter((e) => tagged(full ? e.tags : publicTags(e.tags)));
|
|
64
|
+
}
|
|
58
65
|
|
|
59
66
|
if (url.searchParams.get('expand') === '0') {
|
|
60
67
|
return withCors(json({ timezone: tz, events: events.map((e) => eventToJson(e, origin, map, settings.timezone, { includePrivate: full })) }));
|
package/src/pages/api/search.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { APIRoute } from 'astro';
|
|
2
|
+
import { isStaffRole } from '../../lib/roles';
|
|
2
3
|
import { getRepo } from '../../lib/repo';
|
|
3
4
|
import { authorSlug, publicTags } from '../../lib/schema';
|
|
4
5
|
import { collectionUrl, tagUrl } from '../../lib/content';
|
|
@@ -52,7 +53,8 @@ export const GET: APIRoute = async ({ locals, url }) => {
|
|
|
52
53
|
};
|
|
53
54
|
}),
|
|
54
55
|
tags: results.tags.slice(0, LIMITS.tags).map(({ tag, count }) => ({ name: tag.name, url: tagUrl(tag.slug), count })),
|
|
55
|
-
|
|
56
|
+
// staff only: people in the site's own roles don't write posts and have no public profile
|
|
57
|
+
authors: results.authors.filter((u) => isStaffRole(u.role)).slice(0, LIMITS.authors).map((u) => ({ name: u.name, url: `/author/${authorSlug(u)}`, avatar: u.avatar })),
|
|
56
58
|
};
|
|
57
59
|
return new Response(JSON.stringify(body), { headers });
|
|
58
60
|
};
|
|
@@ -3,6 +3,7 @@ import Site from '../../layouts/Site.astro';
|
|
|
3
3
|
import PostCard from '../../components/site/PostCard.astro';
|
|
4
4
|
import Pager from '../../components/site/Pager.astro';
|
|
5
5
|
import { getRepo } from '../../lib/repo';
|
|
6
|
+
import { isStaffRole } from '../../lib/roles';
|
|
6
7
|
import { tagUrl } from '../../lib/content';
|
|
7
8
|
import { publicTags, slugify } from '../../lib/schema';
|
|
8
9
|
import { authorSlug } from '../../lib/schema';
|
|
@@ -14,7 +15,8 @@ const { slug } = Astro.params;
|
|
|
14
15
|
const repo = await getRepo(Astro.locals);
|
|
15
16
|
const [users, settings, tags] = await Promise.all([repo.listUsers(), repo.getSettings(), repo.listTags()]);
|
|
16
17
|
// stored slug wins; slugified name is the fallback so profiles work untouched
|
|
17
|
-
|
|
18
|
+
// staff only: people in the site's own roles have no public profile
|
|
19
|
+
const author = users.find((u) => isStaffRole(u.role) && authorSlug(u) === slug);
|
|
18
20
|
if (!author) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
19
21
|
|
|
20
22
|
// includes co-authored posts (authorIds), not just primary authorship
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { EventFieldConfig } from '../../lib/calendar/config';
|
|
3
|
+
import { MEMBER_FORM_LIMITS as L, repeatChoices, type MemberEventForm } from '../../lib/calendar/member-form';
|
|
4
|
+
|
|
5
|
+
// 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.
|
|
7
|
+
interface Props {
|
|
8
|
+
values: MemberEventForm;
|
|
9
|
+
/** the calendars members may choose (native, public); none hides the choice */
|
|
10
|
+
calendars: { id: string; name: string }[];
|
|
11
|
+
/** the site's non-private custom event fields */
|
|
12
|
+
fields: readonly EventFieldConfig[];
|
|
13
|
+
op: 'submit' | 'update';
|
|
14
|
+
submitLabel: string;
|
|
15
|
+
error?: string;
|
|
16
|
+
}
|
|
17
|
+
const { values: v, calendars, fields, op, submitLabel, error = '' } = Astro.props;
|
|
18
|
+
const repeat = repeatChoices(v.repeat, v.date);
|
|
19
|
+
const fieldType = (t: EventFieldConfig['type']) => (t === 'number' ? 'number' : t === 'url' ? 'url' : 'text');
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
<form method="post" class="pc-mem-form">
|
|
23
|
+
{error && <p class="pc-bk-error" role="alert">{error}</p>}
|
|
24
|
+
<input type="hidden" name="op" value={op} />
|
|
25
|
+
<label class="pc-bk-field">
|
|
26
|
+
<span>Title</span>
|
|
27
|
+
<input name="title" required maxlength={L.title} value={v.title} />
|
|
28
|
+
</label>
|
|
29
|
+
<fieldset class="pc-mem-when">
|
|
30
|
+
<legend>When</legend>
|
|
31
|
+
<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>
|
|
35
|
+
</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>
|
|
37
|
+
{v.days > 0 && (
|
|
38
|
+
<>
|
|
39
|
+
<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>
|
|
41
|
+
</>
|
|
42
|
+
)}
|
|
43
|
+
<label class="pc-bk-field">
|
|
44
|
+
<span>Repeats</span>
|
|
45
|
+
<select name="repeat">
|
|
46
|
+
{repeat.options.map((o) => <option value={o.value} selected={o.value === repeat.value}>{o.label}</option>)}
|
|
47
|
+
</select>
|
|
48
|
+
{repeat.note && <small>{repeat.note}</small>}
|
|
49
|
+
</label>
|
|
50
|
+
</fieldset>
|
|
51
|
+
<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”" />
|
|
54
|
+
</label>
|
|
55
|
+
<label class="pc-bk-field">
|
|
56
|
+
<span>Description</span>
|
|
57
|
+
<textarea name="description" rows="6" maxlength={L.description}>{v.description}</textarea>
|
|
58
|
+
<small>Plain text. Leave a blank line between paragraphs.</small>
|
|
59
|
+
</label>
|
|
60
|
+
{calendars.length > 0 && (
|
|
61
|
+
<label class="pc-bk-field">
|
|
62
|
+
<span>Calendar</span>
|
|
63
|
+
<select name="calendarId">
|
|
64
|
+
<option value="">Not sure (the team will choose)</option>
|
|
65
|
+
{calendars.map((c) => <option value={c.id} selected={c.id === v.calendarId}>{c.name}</option>)}
|
|
66
|
+
</select>
|
|
67
|
+
</label>
|
|
68
|
+
)}
|
|
69
|
+
{fields.map((f) => (
|
|
70
|
+
<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>)}
|
|
78
|
+
</select>
|
|
79
|
+
) : (
|
|
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] ?? ''} />
|
|
81
|
+
)}
|
|
82
|
+
</label>
|
|
83
|
+
))}
|
|
84
|
+
<label class="pc-bk-field">
|
|
85
|
+
<span>Note for the team <em>(only staff see this)</em></span>
|
|
86
|
+
<textarea name="note" rows="3" maxlength={L.note}>{v.note}</textarea>
|
|
87
|
+
</label>
|
|
88
|
+
<button class="pc-bk-submit" type="submit">{submitLabel}</button>
|
|
89
|
+
</form>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Site from '../../layouts/Site.astro';
|
|
3
|
+
|
|
4
|
+
// The frame of the built-in member pages: the site's own layout and theme,
|
|
5
|
+
// never indexed, plus who's signed in. `.pc-mem-*` classes are theme hooks;
|
|
6
|
+
// form controls reuse the booking pages' `.pc-bk-*` ones.
|
|
7
|
+
interface Props {
|
|
8
|
+
title: string;
|
|
9
|
+
user: { name: string; email: string; staff: boolean };
|
|
10
|
+
}
|
|
11
|
+
const { title, user } = Astro.props;
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
<Site title={title} noindex staffPreview>
|
|
15
|
+
<div class="pc-mem">
|
|
16
|
+
<div class="pc-mem-top">
|
|
17
|
+
<span>Signed in as {user.name || user.email}</span>
|
|
18
|
+
<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>
|
|
21
|
+
</span>
|
|
22
|
+
</div>
|
|
23
|
+
<slot />
|
|
24
|
+
</div>
|
|
25
|
+
</Site>
|
|
26
|
+
|
|
27
|
+
<style is:global>
|
|
28
|
+
@layer base {
|
|
29
|
+
.pc-mem { display: grid; gap: 26px; }
|
|
30
|
+
.pc-mem-top { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6px 16px; font-size: 13.5px; color: var(--muted); }
|
|
31
|
+
.pc-mem-top-links { display: inline-flex; gap: 14px; align-items: baseline; }
|
|
32
|
+
.pc-mem-top a, .pc-mem-top button { color: var(--muted); }
|
|
33
|
+
.pc-mem-top form { margin: 0; }
|
|
34
|
+
.pc-mem-top button { background: none; border: 0; padding: 0; font: inherit; text-decoration: underline; text-underline-offset: 3px; cursor: pointer; }
|
|
35
|
+
.pc-mem-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; }
|
|
36
|
+
.pc-mem-head h1 { font-size: 30px; margin: 0; }
|
|
37
|
+
.pc-mem-back { font-size: 14px; color: var(--muted); }
|
|
38
|
+
.pc-mem-lede { color: var(--muted); margin: 6px 0 0; }
|
|
39
|
+
.pc-mem-flash { background: color-mix(in srgb, var(--success) 10%, var(--panel)); color: var(--success); border-radius: var(--r-sm); padding: 9px 12px; font-size: 14px; margin: 0; }
|
|
40
|
+
.pc-mem .pc-bk-error { margin: 0; }
|
|
41
|
+
.pc-mem section > h2 { font-size: 19px; margin: 0 0 10px; }
|
|
42
|
+
.pc-mem-empty { color: var(--muted); font-size: 14.5px; margin: 0; }
|
|
43
|
+
.pc-mem-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
|
|
44
|
+
.pc-mem-card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--r); padding: 14px 16px; box-shadow: var(--sh-1); }
|
|
45
|
+
.pc-mem-card h3 { font-size: 16.5px; margin: 0; }
|
|
46
|
+
.pc-mem-meta { color: var(--muted); font-size: 14px; margin: 3px 0 0; }
|
|
47
|
+
.pc-mem-state { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; font-weight: 600; margin: 0 0 4px; }
|
|
48
|
+
.pc-mem-state::before { content: ''; width: 7px; height: 7px; border-radius: 2px; background: currentColor; }
|
|
49
|
+
.pc-mem-state.is-pending { color: var(--warning); }
|
|
50
|
+
.pc-mem-state.is-declined { color: var(--danger); }
|
|
51
|
+
.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
|
+
.pc-mem > .pc-mem-reply { margin: 0; }
|
|
53
|
+
.pc-mem-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
|
54
|
+
.pc-mem-actions form, .pc-mem-dates form { margin: 0; }
|
|
55
|
+
.pc-mem-btn { display: inline-flex; align-items: center; height: 36px; padding: 0 13px; border: 1px solid var(--line-2); border-radius: 10px; background: var(--panel); font: 550 14px/1.2 var(--font-body); color: var(--ink); text-decoration: none; cursor: pointer; white-space: nowrap; }
|
|
56
|
+
.pc-mem-btn:hover { background: var(--soft); }
|
|
57
|
+
.pc-mem-btn.is-primary { background: var(--btn); color: var(--btn-ink); border-color: transparent; box-shadow: var(--sh-1); }
|
|
58
|
+
.pc-mem-btn.is-primary:hover { background: var(--btn); box-shadow: var(--sh-2); }
|
|
59
|
+
.pc-mem-btn.is-danger { color: var(--danger); }
|
|
60
|
+
.pc-mem-btn.is-small { height: 30px; padding: 0 10px; font-size: 13px; }
|
|
61
|
+
.pc-mem-dates { list-style: none; margin: 10px 0 0; padding: 0; font-size: 14px; }
|
|
62
|
+
.pc-mem-dates li { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 42px; padding: 5px 0; border-top: 1px solid var(--line); font-variant-numeric: tabular-nums; }
|
|
63
|
+
.pc-mem-dates li.is-cancelled > span { color: var(--muted); }
|
|
64
|
+
.pc-mem-dates em { font-style: normal; font-size: 12.5px; font-weight: 600; color: var(--danger); margin-left: 8px; }
|
|
65
|
+
.pc-mem-past > summary { cursor: pointer; font-weight: 600; font-size: 15px; }
|
|
66
|
+
.pc-mem-past .pc-mem-list { margin-top: 10px; }
|
|
67
|
+
/* the request form */
|
|
68
|
+
.pc-mem-form { display: grid; gap: 14px; }
|
|
69
|
+
.pc-mem-form .pc-bk-field, .pc-mem-form .pc-bk-check { margin: 0; }
|
|
70
|
+
.pc-mem-when { border: 0; margin: 0; padding: 0; min-width: 0; display: grid; gap: 10px; }
|
|
71
|
+
.pc-mem-when > legend { padding: 0; margin-bottom: 6px; font-size: 13px; font-weight: 600; }
|
|
72
|
+
.pc-mem-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; }
|
|
73
|
+
.pc-mem-when:has(input[name='allDay']:checked) .pc-mem-time { opacity: 0.45; }
|
|
74
|
+
.pc-mem-hint { color: var(--muted); font-size: 13px; margin: 0; }
|
|
75
|
+
}
|
|
76
|
+
</style>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { ApiError } from '../../lib/api';
|
|
2
|
+
|
|
3
|
+
// Shared by the built-in "My events" pages: they're personal, so nothing
|
|
4
|
+
// they answer (redirects included) may be cached or indexed.
|
|
5
|
+
|
|
6
|
+
export const NO_STORE: Record<string, string> = { 'Cache-Control': 'private, no-store', 'X-Robots-Tag': 'noindex' };
|
|
7
|
+
|
|
8
|
+
export function keepPrivate(headers: Headers) {
|
|
9
|
+
for (const [k, v] of Object.entries(NO_STORE)) headers.set(k, v);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** 303 after a POST (the browser GETs the list), 302 otherwise */
|
|
13
|
+
export const redirectTo = (location: string, status = 303) => new Response(null, { status, headers: { Location: location, ...NO_STORE } });
|
|
14
|
+
|
|
15
|
+
/** signed out: sign in, then come back here */
|
|
16
|
+
export const signInRedirect = (url: URL) => redirectTo(`/admin/login?return=${encodeURIComponent(url.pathname + url.search)}`, 302);
|
|
17
|
+
|
|
18
|
+
export const notFound = () => new Response(null, { status: 404, statusText: 'Not found', headers: NO_STORE });
|
|
19
|
+
|
|
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) return { message: err.message, status: err.status };
|
|
23
|
+
console.error(err);
|
|
24
|
+
return { message: 'Something went wrong. Please try again.', status: 500 };
|
|
25
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Layout from '../_Layout.astro';
|
|
3
|
+
import EventForm from '../_EventForm.astro';
|
|
4
|
+
import { getRepo } from '../../../lib/repo';
|
|
5
|
+
import { currentUser } from '../../../server';
|
|
6
|
+
import { ApiError } from '../../../lib/api';
|
|
7
|
+
import { readForm } from '../../../lib/forms/http';
|
|
8
|
+
import { MEMBERS, memberEventsPath, bodyToText, isHost, updateEventRequest, withdrawEventRequest } from '../../../lib/calendar/requests';
|
|
9
|
+
import { eventFormValues, eventRequestInput, memberEventFields, readEventForm } from '../../../lib/calendar/member-form';
|
|
10
|
+
import { mergeEventDraft } from '../../../lib/drafts';
|
|
11
|
+
import { failure, keepPrivate, notFound, redirectTo, signInRedirect } from '../_http';
|
|
12
|
+
|
|
13
|
+
// Edit a request, or suggest changes to a live event the member hosts: the
|
|
14
|
+
// form starts from the event with any changes already waiting merged in.
|
|
15
|
+
// A live event stays as it is until the team approves (updateEventRequest).
|
|
16
|
+
if (!MEMBERS.enabled) return notFound();
|
|
17
|
+
const user = await currentUser(Astro);
|
|
18
|
+
if (!user) return signInRedirect(Astro.url);
|
|
19
|
+
keepPrivate(Astro.response.headers);
|
|
20
|
+
const repo = await getRepo(Astro.locals);
|
|
21
|
+
const home = memberEventsPath();
|
|
22
|
+
const hooks = { url: Astro.url, locals: Astro.locals };
|
|
23
|
+
const id = Astro.params.id ?? '';
|
|
24
|
+
const event = await repo.getEvent(id);
|
|
25
|
+
if (!event || event.status === 'archived' || !isHost(event, user.id)) return notFound();
|
|
26
|
+
|
|
27
|
+
const fields = memberEventFields();
|
|
28
|
+
const calendars = (await repo.listCalendars()).filter((c) => c.kind === 'native' && c.visibility === 'public');
|
|
29
|
+
const live = event.status === 'published' || event.status === 'scheduled';
|
|
30
|
+
// the host's own waiting changes, never staff's unpublished edits
|
|
31
|
+
const merged = event.request?.kind === 'changes' && event.request.state === 'pending' ? mergeEventDraft(event) : event;
|
|
32
|
+
let values = eventFormValues(merged, { description: bodyToText(merged), calendarIds: calendars.map((c) => c.id) }, fields);
|
|
33
|
+
|
|
34
|
+
let error = '';
|
|
35
|
+
if (Astro.request.method === 'POST') {
|
|
36
|
+
try {
|
|
37
|
+
const form = await readForm(Astro.request);
|
|
38
|
+
const op = String(form.get('op') ?? '');
|
|
39
|
+
if (op === 'withdraw') {
|
|
40
|
+
await withdrawEventRequest(repo, user, id, hooks);
|
|
41
|
+
return redirectTo(`${home}?withdrawn=1`);
|
|
42
|
+
}
|
|
43
|
+
if (op !== 'update') throw new ApiError(400, 'That action isn’t available here.');
|
|
44
|
+
values = readEventForm(form, fields);
|
|
45
|
+
await updateEventRequest(repo, user, id, eventRequestInput(values), hooks);
|
|
46
|
+
return redirectTo(`${home}?${live ? 'changes' : 'updated'}=1`);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
const f = failure(err);
|
|
49
|
+
error = f.message;
|
|
50
|
+
Astro.response.status = f.status;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const r = event.request;
|
|
55
|
+
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.';
|
|
62
|
+
const waiting = live && r?.kind === 'changes' && r.state === 'pending';
|
|
63
|
+
const reply = r?.state === 'declined' ? r.reply : '';
|
|
64
|
+
const canWithdraw = canRequest && r && !(r.kind === 'changes' && r.state === 'declined');
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
<Layout title={heading} user={user}>
|
|
68
|
+
<div>
|
|
69
|
+
<a class="pc-mem-back" href={home}>← My events</a>
|
|
70
|
+
<div class="pc-mem-head"><h1>{heading}</h1></div>
|
|
71
|
+
<p class="pc-mem-lede">{lede}</p>
|
|
72
|
+
</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
|
+
{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
|
+
)}
|
|
77
|
+
{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>}
|
|
80
|
+
{canWithdraw && (
|
|
81
|
+
<form method="post" onsubmit={r!.kind === 'new' ? "return confirm('Withdraw this request?')" : "return confirm('Withdraw these changes?')"}>
|
|
82
|
+
<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
|
+
</form>
|
|
85
|
+
)}
|
|
86
|
+
</Layout>
|