@meith/plugin-calendar 0.29.1 → 0.30.1
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/package.json +2 -2
- package/src/access.ts +1 -2
- package/src/definition.tsx +17 -1
- package/src/handlers.ts +61 -3
- package/src/messages/en.json +5 -0
- package/src/store.ts +19 -0
- package/src/ui/page.tsx +138 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meith/plugin-calendar",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.1",
|
|
4
4
|
"description": "A shared calendar: events the community can see, linked to the threads that discuss them.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"access": "public"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@meith/plugin-kit": "^0.
|
|
23
|
+
"@meith/plugin-kit": "^0.30.1"
|
|
24
24
|
},
|
|
25
25
|
"peerDependencies": {
|
|
26
26
|
"react": "^19.2.0"
|
package/src/access.ts
CHANGED
|
@@ -20,10 +20,9 @@ export function mayAdd(input: {
|
|
|
20
20
|
return input.organisers.includes(input.userId) ? 'allowed' : 'not-an-organiser'
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
export function
|
|
23
|
+
export function mayManage(input: {
|
|
24
24
|
readonly userId: number | null
|
|
25
25
|
readonly createdByUserId: number | null
|
|
26
|
-
readonly config: CalendarConfig
|
|
27
26
|
readonly organisers: readonly number[]
|
|
28
27
|
}): boolean {
|
|
29
28
|
if (input.userId === null) return false
|
package/src/definition.tsx
CHANGED
|
@@ -3,8 +3,10 @@ import { definePlugin } from '@meith/plugin-kit'
|
|
|
3
3
|
import {
|
|
4
4
|
handleAddOrganiser,
|
|
5
5
|
handleCreateEvent,
|
|
6
|
+
handleDeleteEvent,
|
|
6
7
|
handleEventIcs,
|
|
7
8
|
handleRemoveOrganiser,
|
|
9
|
+
handleUpdateEvent,
|
|
8
10
|
} from './handlers'
|
|
9
11
|
import en from './messages/en.json'
|
|
10
12
|
import { CALENDAR_MIGRATIONS } from './schema'
|
|
@@ -18,7 +20,7 @@ export const calendarPlugin = definePlugin({
|
|
|
18
20
|
key: 'calendar',
|
|
19
21
|
name: en['calendar.definition.name'],
|
|
20
22
|
nameKey: 'calendar.definition.name',
|
|
21
|
-
version: '0.
|
|
23
|
+
version: '0.30.1',
|
|
22
24
|
description: en['calendar.definition.description'],
|
|
23
25
|
descriptionKey: 'calendar.definition.description',
|
|
24
26
|
apiVersion: '0',
|
|
@@ -74,6 +76,20 @@ export const calendarPlugin = definePlugin({
|
|
|
74
76
|
rateLimit: ADD_RATE_LIMIT,
|
|
75
77
|
handler: handleCreateEvent,
|
|
76
78
|
},
|
|
79
|
+
{
|
|
80
|
+
path: 'events/update',
|
|
81
|
+
method: 'POST',
|
|
82
|
+
access: 'member',
|
|
83
|
+
rateLimit: ADD_RATE_LIMIT,
|
|
84
|
+
handler: handleUpdateEvent,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
path: 'events/delete',
|
|
88
|
+
method: 'POST',
|
|
89
|
+
access: 'member',
|
|
90
|
+
rateLimit: ADD_RATE_LIMIT,
|
|
91
|
+
handler: handleDeleteEvent,
|
|
92
|
+
},
|
|
77
93
|
{
|
|
78
94
|
path: 'events/ics',
|
|
79
95
|
method: 'GET',
|
package/src/handlers.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import type { PluginRequest, PluginResponse, PluginRuntimeContext } from '@meith/plugin-kit'
|
|
2
2
|
|
|
3
|
-
import { mayAdd, resolveCalendarConfig } from './access'
|
|
4
|
-
import { readDraft } from './events'
|
|
3
|
+
import { mayAdd, mayManage, resolveCalendarConfig } from './access'
|
|
4
|
+
import { type CalendarEvent, readDraft } from './events'
|
|
5
5
|
import { ICS_CONTENT_TYPE, toIcs } from './ics'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
addOrganiser,
|
|
8
|
+
createEvent,
|
|
9
|
+
deleteEvent,
|
|
10
|
+
eventById,
|
|
11
|
+
organiserIds,
|
|
12
|
+
removeOrganiser,
|
|
13
|
+
updateEvent,
|
|
14
|
+
} from './store'
|
|
7
15
|
|
|
8
16
|
export const CALENDAR_PATH = '/plugins/calendar'
|
|
9
17
|
|
|
@@ -38,6 +46,56 @@ export async function handleCreateEvent(
|
|
|
38
46
|
return seeCalendar()
|
|
39
47
|
}
|
|
40
48
|
|
|
49
|
+
async function manageableEvent(
|
|
50
|
+
request: PluginRequest,
|
|
51
|
+
context: PluginRuntimeContext,
|
|
52
|
+
): Promise<{ event: CalendarEvent } | { refusal: PluginResponse }> {
|
|
53
|
+
const id = request.form?.id?.trim() ?? ''
|
|
54
|
+
if (!/^\d+$/.test(id)) return { refusal: refused(400, 'id-required') }
|
|
55
|
+
|
|
56
|
+
const event = await eventById(context.data, id)
|
|
57
|
+
if (event === null) return { refusal: refused(404, 'no-such-event') }
|
|
58
|
+
|
|
59
|
+
const allowed = mayManage({
|
|
60
|
+
userId: request.viewer.userId,
|
|
61
|
+
createdByUserId: event.createdByUserId,
|
|
62
|
+
organisers: await organiserIds(context.data),
|
|
63
|
+
})
|
|
64
|
+
if (!allowed) return { refusal: refused(403, 'not-yours') }
|
|
65
|
+
|
|
66
|
+
return { event }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function handleUpdateEvent(
|
|
70
|
+
request: PluginRequest,
|
|
71
|
+
context: PluginRuntimeContext,
|
|
72
|
+
): Promise<PluginResponse> {
|
|
73
|
+
const form = request.form
|
|
74
|
+
if (form === null) return refused(400, 'form-required')
|
|
75
|
+
|
|
76
|
+
const found = await manageableEvent(request, context)
|
|
77
|
+
if ('refusal' in found) return found.refusal
|
|
78
|
+
|
|
79
|
+
const { draft, problems } = readDraft(form)
|
|
80
|
+
if (draft === null) return refused(400, problems.join(','))
|
|
81
|
+
|
|
82
|
+
await updateEvent(context.data, found.event.id, draft)
|
|
83
|
+
return seeCalendar()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function handleDeleteEvent(
|
|
87
|
+
request: PluginRequest,
|
|
88
|
+
context: PluginRuntimeContext,
|
|
89
|
+
): Promise<PluginResponse> {
|
|
90
|
+
if (request.form === null) return refused(400, 'form-required')
|
|
91
|
+
|
|
92
|
+
const found = await manageableEvent(request, context)
|
|
93
|
+
if ('refusal' in found) return found.refusal
|
|
94
|
+
|
|
95
|
+
await deleteEvent(context.data, found.event.id)
|
|
96
|
+
return seeCalendar()
|
|
97
|
+
}
|
|
98
|
+
|
|
41
99
|
export async function handleEventIcs(
|
|
42
100
|
request: PluginRequest,
|
|
43
101
|
context: PluginRuntimeContext,
|
package/src/messages/en.json
CHANGED
|
@@ -8,14 +8,19 @@
|
|
|
8
8
|
"calendar.definition.name": "Calendar",
|
|
9
9
|
"calendar.error.notAnOrganiser": "Only an organiser may add an event to this calendar.",
|
|
10
10
|
"calendar.event.add": "Add event",
|
|
11
|
+
"calendar.event.cancel": "Cancel",
|
|
12
|
+
"calendar.event.delete": "Delete",
|
|
11
13
|
"calendar.event.discuss": "Discussion",
|
|
12
14
|
"calendar.event.download": "Add to your calendar",
|
|
15
|
+
"calendar.event.edit": "Edit",
|
|
16
|
+
"calendar.event.editTitle": "Edit event",
|
|
13
17
|
"calendar.event.link": "Link",
|
|
14
18
|
"calendar.event.linkFallback": "Open link",
|
|
15
19
|
"calendar.event.linkHint": "An https:// address people should open — a video call, a ticket page.",
|
|
16
20
|
"calendar.event.linkText": "Link text",
|
|
17
21
|
"calendar.event.linkTextHint": "What the link should say — “Join online”, “Get tickets”.",
|
|
18
22
|
"calendar.event.location": "Location",
|
|
23
|
+
"calendar.event.save": "Save changes",
|
|
19
24
|
"calendar.event.starts": "Starts",
|
|
20
25
|
"calendar.event.thread": "Thread link or id",
|
|
21
26
|
"calendar.event.title": "Title",
|
package/src/store.ts
CHANGED
|
@@ -53,6 +53,25 @@ export async function createEvent(
|
|
|
53
53
|
)
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
export async function updateEvent(data: PluginData, id: string, draft: EventDraft): Promise<void> {
|
|
57
|
+
await data.query(
|
|
58
|
+
`update plugin_calendar_event
|
|
59
|
+
set title = $2, starts_at = $3, ends_at = $4, location = $5, thread_id = $6,
|
|
60
|
+
link_url = $7, link_label = $8
|
|
61
|
+
where id = $1`,
|
|
62
|
+
[
|
|
63
|
+
id,
|
|
64
|
+
draft.title,
|
|
65
|
+
draft.startsAt,
|
|
66
|
+
draft.endsAt,
|
|
67
|
+
draft.location,
|
|
68
|
+
draft.threadId,
|
|
69
|
+
draft.linkUrl,
|
|
70
|
+
draft.linkLabel,
|
|
71
|
+
],
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
56
75
|
export async function upcomingEvents(
|
|
57
76
|
data: PluginData,
|
|
58
77
|
limit: number,
|
package/src/ui/page.tsx
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
pluginTabClass,
|
|
7
7
|
} from '@meith/plugin-kit'
|
|
8
8
|
|
|
9
|
-
import { mayAdd, resolveCalendarConfig } from '../access'
|
|
9
|
+
import { mayAdd, mayManage, resolveCalendarConfig } from '../access'
|
|
10
10
|
import {
|
|
11
11
|
type CalendarEvent,
|
|
12
12
|
dayParts,
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
relativeHint,
|
|
17
17
|
} from '../events'
|
|
18
18
|
import en from '../messages/en.json'
|
|
19
|
-
import { organiserIds, pastEvents, upcomingEvents } from '../store'
|
|
19
|
+
import { eventById, organiserIds, pastEvents, upcomingEvents } from '../store'
|
|
20
20
|
import { EventLink } from './event-link'
|
|
21
21
|
|
|
22
22
|
export const UPCOMING_LIMIT = 50
|
|
@@ -45,11 +45,13 @@ function EventRow({
|
|
|
45
45
|
locale,
|
|
46
46
|
now,
|
|
47
47
|
context,
|
|
48
|
+
manageable,
|
|
48
49
|
}: {
|
|
49
50
|
event: CalendarEvent
|
|
50
51
|
locale: string
|
|
51
52
|
now: Date
|
|
52
53
|
context: PluginPageContext
|
|
54
|
+
manageable: boolean
|
|
53
55
|
}) {
|
|
54
56
|
const href = eventHref(event)
|
|
55
57
|
|
|
@@ -67,7 +69,7 @@ function EventRow({
|
|
|
67
69
|
{event.location !== '' && <span> · {event.location}</span>}
|
|
68
70
|
</p>
|
|
69
71
|
|
|
70
|
-
<
|
|
72
|
+
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
|
71
73
|
<span className="text-muted-foreground">{relativeHint(event.startsAt, now, locale)}</span>
|
|
72
74
|
{href !== null && (
|
|
73
75
|
<a className="underline underline-offset-2" href={href}>
|
|
@@ -80,7 +82,23 @@ function EventRow({
|
|
|
80
82
|
>
|
|
81
83
|
{translated(context, 'calendar.event.download')}
|
|
82
84
|
</a>
|
|
83
|
-
|
|
85
|
+
{manageable && (
|
|
86
|
+
<>
|
|
87
|
+
<a
|
|
88
|
+
className="underline underline-offset-2"
|
|
89
|
+
href={`/plugins/calendar?edit=${event.id}`}
|
|
90
|
+
>
|
|
91
|
+
{translated(context, 'calendar.event.edit')}
|
|
92
|
+
</a>
|
|
93
|
+
<form method="post" action="/api/plugins/calendar/events/delete">
|
|
94
|
+
<input type="hidden" name="id" value={event.id} />
|
|
95
|
+
<button type="submit" className="underline underline-offset-2">
|
|
96
|
+
{translated(context, 'calendar.event.delete')}
|
|
97
|
+
</button>
|
|
98
|
+
</form>
|
|
99
|
+
</>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
84
102
|
|
|
85
103
|
<EventLink event={event} label={translated(context, 'calendar.event.linkFallback')} />
|
|
86
104
|
</div>
|
|
@@ -93,11 +111,13 @@ function Agenda({
|
|
|
93
111
|
locale,
|
|
94
112
|
now,
|
|
95
113
|
context,
|
|
114
|
+
organisers,
|
|
96
115
|
}: {
|
|
97
116
|
events: readonly CalendarEvent[]
|
|
98
117
|
locale: string
|
|
99
118
|
now: Date
|
|
100
119
|
context: PluginPageContext
|
|
120
|
+
organisers: readonly number[]
|
|
101
121
|
}) {
|
|
102
122
|
return (
|
|
103
123
|
<div className="flex flex-col gap-6">
|
|
@@ -108,7 +128,18 @@ function Agenda({
|
|
|
108
128
|
</h2>
|
|
109
129
|
<ul className="divide-border divide-y">
|
|
110
130
|
{month.events.map((event) => (
|
|
111
|
-
<EventRow
|
|
131
|
+
<EventRow
|
|
132
|
+
key={event.id}
|
|
133
|
+
event={event}
|
|
134
|
+
locale={locale}
|
|
135
|
+
now={now}
|
|
136
|
+
context={context}
|
|
137
|
+
manageable={mayManage({
|
|
138
|
+
userId: context.viewer.userId,
|
|
139
|
+
createdByUserId: event.createdByUserId,
|
|
140
|
+
organisers,
|
|
141
|
+
})}
|
|
142
|
+
/>
|
|
112
143
|
))}
|
|
113
144
|
</ul>
|
|
114
145
|
</section>
|
|
@@ -117,31 +148,76 @@ function Agenda({
|
|
|
117
148
|
)
|
|
118
149
|
}
|
|
119
150
|
|
|
120
|
-
function
|
|
151
|
+
function toDateTimeInput(date: Date | null): string {
|
|
152
|
+
return date === null ? '' : date.toISOString().slice(0, 16)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function EventForm({
|
|
156
|
+
context,
|
|
157
|
+
event,
|
|
158
|
+
}: {
|
|
159
|
+
context: PluginPageContext
|
|
160
|
+
event: CalendarEvent | null
|
|
161
|
+
}) {
|
|
162
|
+
const action =
|
|
163
|
+
event === null ? '/api/plugins/calendar/events' : '/api/plugins/calendar/events/update'
|
|
164
|
+
const heading = translated(
|
|
165
|
+
context,
|
|
166
|
+
event === null ? 'calendar.event.add' : 'calendar.event.editTitle',
|
|
167
|
+
)
|
|
168
|
+
const submit = translated(context, event === null ? 'calendar.event.add' : 'calendar.event.save')
|
|
169
|
+
|
|
121
170
|
return (
|
|
122
|
-
<form method="post" action=
|
|
123
|
-
<h2 className="font-semibold">{
|
|
171
|
+
<form method="post" action={action} className={PLUGIN_CARD}>
|
|
172
|
+
<h2 className="font-semibold">{heading}</h2>
|
|
173
|
+
{event !== null && <input type="hidden" name="id" value={event.id} />}
|
|
124
174
|
|
|
125
175
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
126
176
|
<label className="flex flex-col gap-1 text-sm sm:col-span-2">
|
|
127
177
|
{translated(context, 'calendar.event.title')}
|
|
128
|
-
<input
|
|
178
|
+
<input
|
|
179
|
+
name="title"
|
|
180
|
+
required
|
|
181
|
+
maxLength={120}
|
|
182
|
+
defaultValue={event?.title ?? ''}
|
|
183
|
+
className="rounded border p-1.5"
|
|
184
|
+
/>
|
|
129
185
|
</label>
|
|
130
186
|
<label className="flex flex-col gap-1 text-sm">
|
|
131
187
|
{translated(context, 'calendar.event.starts')}
|
|
132
|
-
<input
|
|
188
|
+
<input
|
|
189
|
+
name="starts_at"
|
|
190
|
+
type="datetime-local"
|
|
191
|
+
required
|
|
192
|
+
defaultValue={toDateTimeInput(event?.startsAt ?? null)}
|
|
193
|
+
className="rounded border p-1.5"
|
|
194
|
+
/>
|
|
133
195
|
</label>
|
|
134
196
|
<label className="flex flex-col gap-1 text-sm">
|
|
135
197
|
{translated(context, 'calendar.event.until')}
|
|
136
|
-
<input
|
|
198
|
+
<input
|
|
199
|
+
name="ends_at"
|
|
200
|
+
type="datetime-local"
|
|
201
|
+
defaultValue={toDateTimeInput(event?.endsAt ?? null)}
|
|
202
|
+
className="rounded border p-1.5"
|
|
203
|
+
/>
|
|
137
204
|
</label>
|
|
138
205
|
<label className="flex flex-col gap-1 text-sm">
|
|
139
206
|
{translated(context, 'calendar.event.location')}
|
|
140
|
-
<input
|
|
207
|
+
<input
|
|
208
|
+
name="location"
|
|
209
|
+
maxLength={120}
|
|
210
|
+
defaultValue={event?.location ?? ''}
|
|
211
|
+
className="rounded border p-1.5"
|
|
212
|
+
/>
|
|
141
213
|
</label>
|
|
142
214
|
<label className="flex flex-col gap-1 text-sm">
|
|
143
215
|
{translated(context, 'calendar.event.thread')}
|
|
144
|
-
<input
|
|
216
|
+
<input
|
|
217
|
+
name="thread"
|
|
218
|
+
defaultValue={event?.threadId == null ? '' : String(event.threadId)}
|
|
219
|
+
className="rounded border p-1.5"
|
|
220
|
+
/>
|
|
145
221
|
</label>
|
|
146
222
|
<label className="flex flex-col gap-1 text-sm">
|
|
147
223
|
{translated(context, 'calendar.event.link')}
|
|
@@ -150,6 +226,7 @@ function AddForm({ context }: { context: PluginPageContext }) {
|
|
|
150
226
|
type="url"
|
|
151
227
|
maxLength={500}
|
|
152
228
|
placeholder="https://"
|
|
229
|
+
defaultValue={event?.linkUrl ?? ''}
|
|
153
230
|
className="rounded border p-1.5"
|
|
154
231
|
/>
|
|
155
232
|
<span className="text-muted-foreground text-xs">
|
|
@@ -158,16 +235,28 @@ function AddForm({ context }: { context: PluginPageContext }) {
|
|
|
158
235
|
</label>
|
|
159
236
|
<label className="flex flex-col gap-1 text-sm">
|
|
160
237
|
{translated(context, 'calendar.event.linkText')}
|
|
161
|
-
<input
|
|
238
|
+
<input
|
|
239
|
+
name="link_text"
|
|
240
|
+
maxLength={40}
|
|
241
|
+
defaultValue={event?.linkLabel ?? ''}
|
|
242
|
+
className="rounded border p-1.5"
|
|
243
|
+
/>
|
|
162
244
|
<span className="text-muted-foreground text-xs">
|
|
163
245
|
{translated(context, 'calendar.event.linkTextHint')}
|
|
164
246
|
</span>
|
|
165
247
|
</label>
|
|
166
248
|
</div>
|
|
167
249
|
|
|
168
|
-
<
|
|
169
|
-
|
|
170
|
-
|
|
250
|
+
<div className="flex items-center gap-3">
|
|
251
|
+
<button type="submit" className="bg-muted rounded border px-3 py-1.5 text-sm">
|
|
252
|
+
{submit}
|
|
253
|
+
</button>
|
|
254
|
+
{event !== null && (
|
|
255
|
+
<a className="text-sm underline underline-offset-2" href="/plugins/calendar">
|
|
256
|
+
{translated(context, 'calendar.event.cancel')}
|
|
257
|
+
</a>
|
|
258
|
+
)}
|
|
259
|
+
</div>
|
|
171
260
|
</form>
|
|
172
261
|
)
|
|
173
262
|
}
|
|
@@ -187,6 +276,22 @@ export async function CalendarPage(context: PluginPageContext) {
|
|
|
187
276
|
|
|
188
277
|
const verdict = mayAdd({ userId: context.viewer.userId, config, organisers })
|
|
189
278
|
|
|
279
|
+
const editId = context.query.edit?.trim() ?? ''
|
|
280
|
+
let editing: CalendarEvent | null = null
|
|
281
|
+
if (/^\d+$/.test(editId)) {
|
|
282
|
+
const found = await eventById(context.data, editId).catch(() => null)
|
|
283
|
+
if (
|
|
284
|
+
found !== null &&
|
|
285
|
+
mayManage({
|
|
286
|
+
userId: context.viewer.userId,
|
|
287
|
+
createdByUserId: found.createdByUserId,
|
|
288
|
+
organisers,
|
|
289
|
+
})
|
|
290
|
+
) {
|
|
291
|
+
editing = found
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
190
295
|
return (
|
|
191
296
|
<div className="flex flex-col gap-6">
|
|
192
297
|
<nav aria-label={translated(context, 'calendar.page.views')}>
|
|
@@ -217,12 +322,24 @@ export async function CalendarPage(context: PluginPageContext) {
|
|
|
217
322
|
{translated(context, showingPast ? 'calendar.page.emptyPast' : 'calendar.page.empty')}
|
|
218
323
|
</p>
|
|
219
324
|
) : (
|
|
220
|
-
<Agenda
|
|
325
|
+
<Agenda
|
|
326
|
+
events={events}
|
|
327
|
+
locale={context.locale}
|
|
328
|
+
now={now}
|
|
329
|
+
context={context}
|
|
330
|
+
organisers={organisers}
|
|
331
|
+
/>
|
|
221
332
|
)}
|
|
222
333
|
|
|
223
|
-
{
|
|
224
|
-
|
|
225
|
-
|
|
334
|
+
{editing !== null ? (
|
|
335
|
+
<EventForm context={context} event={editing} />
|
|
336
|
+
) : (
|
|
337
|
+
<>
|
|
338
|
+
{!showingPast && verdict === 'allowed' && <EventForm context={context} event={null} />}
|
|
339
|
+
{!showingPast && verdict === 'not-an-organiser' && (
|
|
340
|
+
<p className={PLUGIN_NOTE}>{translated(context, 'calendar.error.notAnOrganiser')}</p>
|
|
341
|
+
)}
|
|
342
|
+
</>
|
|
226
343
|
)}
|
|
227
344
|
</div>
|
|
228
345
|
)
|