@meith/web 0.36.1 → 0.37.0

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.
@@ -0,0 +1,304 @@
1
+ import { renderMarkdown } from '@meith/markdown'
2
+ import type { SlotModels, SlotName } from '@meith/theme-kit'
3
+
4
+ import { SLOT_FIXTURES } from './contract.fixture'
5
+
6
+ const base = SLOT_FIXTURES
7
+ const zero = { value: 0, label: '0' }
8
+ const guest = base.Shell.model.viewer
9
+ const thread = base.ThreadView.model.thread
10
+ const post = base.PostBit.model.post
11
+ const forum = base.ForumRow.model.forum
12
+ const selection = {
13
+ name: 'ids',
14
+ value: '4102',
15
+ formId: 'fixture-selection',
16
+ label: 'Select for moderation',
17
+ }
18
+
19
+ export const SLOT_VARIANTS: {
20
+ readonly [K in SlotName]?: Readonly<Record<string, Partial<SlotModels[K]>>>
21
+ } = {
22
+ Header: {
23
+ guest: { viewer: guest },
24
+ staff: {
25
+ viewer: { ...base.Header.model.viewer, canAccessAdminCp: true, canAccessModCp: true },
26
+ },
27
+ },
28
+ UserPanel: {
29
+ guest: {
30
+ viewer: guest,
31
+ links: [
32
+ { label: 'Log in', href: '/login' },
33
+ { label: 'Register', href: '/register' },
34
+ ],
35
+ unreadNotifications: zero,
36
+ unreadMessages: zero,
37
+ children: null,
38
+ },
39
+ staff: {
40
+ viewer: { ...base.UserPanel.model.viewer, canAccessAdminCp: true },
41
+ links: [
42
+ { label: 'Admin CP', href: '/admin' },
43
+ { label: 'Moderator CP', href: '/modcp' },
44
+ ],
45
+ unreadNotifications: { value: 124, label: '124' },
46
+ },
47
+ },
48
+ Notice: {
49
+ info: { kind: 'info', message: 'Welcome to the community.' },
50
+ success: { kind: 'success', message: 'Your changes were saved.' },
51
+ error: { kind: 'error', message: 'Your changes could not be saved.' },
52
+ },
53
+ BoardIndex: {
54
+ empty: {
55
+ markAllReadAction: null,
56
+ regions: { categories: null, stats: null, online: null, latest: null },
57
+ },
58
+ },
59
+ ForumRow: {
60
+ empty: {
61
+ forum: {
62
+ ...forum,
63
+ isUnread: false,
64
+ threadCount: zero,
65
+ postCount: zero,
66
+ lastPost: null,
67
+ subforums: [],
68
+ },
69
+ },
70
+ link: {
71
+ forum: {
72
+ ...forum,
73
+ type: 'link',
74
+ title: 'Community website',
75
+ href: 'https://meith.dev',
76
+ lastPost: null,
77
+ subforums: [],
78
+ },
79
+ },
80
+ long: {
81
+ forum: {
82
+ ...forum,
83
+ title:
84
+ 'Community projects, questions, resources and announcements for everyone working together',
85
+ description:
86
+ 'A longer description tests wrapping on narrow screens without hiding the activity counts or subforum navigation.',
87
+ },
88
+ },
89
+ },
90
+ ForumDisplay: {
91
+ empty: {
92
+ forum: { ...forum, threadCount: zero, postCount: zero, lastPost: null },
93
+ regions: { threads: null, pagination: null, subforums: null },
94
+ },
95
+ },
96
+ ThreadRow: {
97
+ read: {
98
+ thread: { ...thread, isSticky: false, isUnread: false, prefix: null, visibility: 'visible' },
99
+ },
100
+ locked: { thread: { ...thread, isLocked: true } },
101
+ moved: { thread: { ...thread, isMoved: true } },
102
+ unapproved: { thread: { ...thread, visibility: 'unapproved' }, select: selection },
103
+ deleted: { thread: { ...thread, visibility: 'deleted' }, select: selection },
104
+ long: {
105
+ thread: {
106
+ ...thread,
107
+ title:
108
+ 'How should we organise the next community gathering when members are travelling from several different time zones?',
109
+ },
110
+ },
111
+ },
112
+ ThreadView: {
113
+ guest: { replyHref: null, markReadAction: null, watch: null },
114
+ locked: { thread: { ...thread, isLocked: true }, replyHref: null, watch: null },
115
+ poll: {},
116
+ },
117
+ PostBit: {
118
+ unapproved: { post: { ...post, visibility: 'unapproved' }, select: selection },
119
+ deleted: { post: { ...post, visibility: 'deleted' }, select: selection },
120
+ ignored: {
121
+ post: {
122
+ ...post,
123
+ bodyHtml: '',
124
+ quoteSource: '',
125
+ attachments: [],
126
+ ignored: { authorUsername: post.author.username, revealHref: '/fixtures?slot=PostBit' },
127
+ },
128
+ },
129
+ guest: {
130
+ post: {
131
+ ...post,
132
+ author: {
133
+ ...post.author,
134
+ userId: null,
135
+ profileHref: null,
136
+ avatarUrl: null,
137
+ signatureHtml: null,
138
+ groups: [],
139
+ fields: [],
140
+ isOnline: false,
141
+ },
142
+ attachments: [],
143
+ editedNote: null,
144
+ },
145
+ },
146
+ rich: {
147
+ post: {
148
+ ...post,
149
+ bodyHtml: renderMarkdown(
150
+ '## Formatting and media\n\n**Bold**, *italic*, ~~removed~~ and [linked text](/).\n\n> A nested conversation.\n>\n> > The earlier reply.\n\n- One item\n- Another item\n\n1. First step\n2. Next step\n\n- [x] Checked task\n- [ ] Open task\n\n```ts\nconst greeting = "Hello, community";\n```\n\n| Day | Activity |\n| --- | --- |\n| Friday | Meetup |\n\n:::spoiler\nThe shed is green.\n:::\n\nLongUnbrokenContentToCheckWrapping0123456789LongUnbrokenContentToCheckWrapping0123456789LongUnbrokenContentToCheckWrapping0123456789',
151
+ ).html,
152
+ author: {
153
+ ...post.author,
154
+ reputation: { value: 1240, label: '1,240' },
155
+ badge: { src: '/placeholder-logo.svg', darkSrc: null, alt: 'Community helper' },
156
+ },
157
+ attachments: [
158
+ ...post.attachments,
159
+ {
160
+ id: 56,
161
+ filename: 'robots.txt',
162
+ size: '124 B',
163
+ isImage: false,
164
+ href: '/robots.txt',
165
+ thumbnailHref: null,
166
+ width: null,
167
+ height: null,
168
+ },
169
+ ],
170
+ },
171
+ },
172
+ },
173
+ PostActions: {
174
+ guest: {
175
+ actions: {
176
+ quoteHref: null,
177
+ editHref: null,
178
+ restoreHref: null,
179
+ reportHref: null,
180
+ warnHref: null,
181
+ moderateHref: null,
182
+ rateHref: null,
183
+ },
184
+ },
185
+ staff: {
186
+ actions: {
187
+ ...post.actions,
188
+ restoreHref: '/fixtures',
189
+ warnHref: '/fixtures',
190
+ moderateHref: '/fixtures',
191
+ rateHref: '/fixtures',
192
+ },
193
+ },
194
+ },
195
+ PostForm: {
196
+ thread: { mode: 'thread', heading: 'Post a new thread', errorMessage: null },
197
+ edit: { mode: 'edit', heading: 'Edit your post', errorMessage: null },
198
+ },
199
+ MemberProfile: {
200
+ minimal: {
201
+ avatarUrl: null,
202
+ title: null,
203
+ groups: [],
204
+ signatureHtml: null,
205
+ fields: [],
206
+ actions: [],
207
+ lastVisitAt: null,
208
+ regions: {},
209
+ },
210
+ },
211
+ SearchForm: {
212
+ blank: {
213
+ query: '',
214
+ errorMessage: null,
215
+ advanced: { ...base.SearchForm.model.advanced!, isOpen: false },
216
+ },
217
+ },
218
+ SearchResults: {
219
+ empty: {
220
+ hits: [],
221
+ nextHref: null,
222
+ refine: {
223
+ ...base.SearchResults.model.refine!,
224
+ summary: 'No matching posts.',
225
+ choices: [],
226
+ applied: [],
227
+ },
228
+ },
229
+ },
230
+ DiscoveryView: {
231
+ empty: { rows: [], nextHref: null },
232
+ refused: {
233
+ rows: [],
234
+ nextHref: null,
235
+ refusal: {
236
+ message: 'Sign in to see your unread threads.',
237
+ signInHref: '/login',
238
+ signInLabel: 'Log in',
239
+ },
240
+ },
241
+ },
242
+ WhoIsOnline: {
243
+ empty: {
244
+ guestCount: zero,
245
+ members: [],
246
+ memberCount: zero,
247
+ total: zero,
248
+ recordCount: zero,
249
+ recordAt: null,
250
+ },
251
+ },
252
+ LatestThreads: { empty: { threads: [] } },
253
+ LatestPosts: { empty: { posts: [] } },
254
+ BoardStats: {
255
+ empty: {
256
+ threadCount: zero,
257
+ postCount: zero,
258
+ memberCount: zero,
259
+ newestMember: null,
260
+ computedAt: null,
261
+ },
262
+ },
263
+ Pagination: {
264
+ single: {
265
+ page: 1,
266
+ pageCount: 1,
267
+ pages: [{ page: 1, href: '/fixtures?slot=Pagination', isCurrent: true }],
268
+ previousHref: null,
269
+ nextHref: null,
270
+ },
271
+ },
272
+ PanelShell: { modcp: { panel: 'modcp' }, admincp: { panel: 'admincp' } },
273
+ PanelNav: { modcp: { panel: 'modcp' }, admincp: { panel: 'admincp' } },
274
+ PanelPage: {
275
+ usercp: { panel: 'usercp' },
276
+ modcp: { panel: 'modcp' },
277
+ standalone: { panel: null, frame: 'standalone' },
278
+ },
279
+ AuthPage: {
280
+ login: { alert: null },
281
+ register: { title: 'Create an account', alert: null },
282
+ reset: { title: 'Reset your password', alert: null },
283
+ },
284
+ ErrorNotice: {
285
+ forbidden: {
286
+ status: 403,
287
+ title: 'Access denied',
288
+ message: 'You do not have permission to view this page.',
289
+ },
290
+ unavailable: {
291
+ status: 503,
292
+ title: 'Temporarily unavailable',
293
+ message: 'Please try again in a few minutes.',
294
+ },
295
+ },
296
+ }
297
+
298
+ export function fixtureModel<K extends SlotName>(name: K, variant = 'default'): SlotModels[K] {
299
+ return { ...SLOT_FIXTURES[name].model, ...SLOT_VARIANTS[name]?.[variant] }
300
+ }
301
+
302
+ export function fixtureVariants(name: SlotName): readonly string[] {
303
+ return ['default', ...Object.keys(SLOT_VARIANTS[name] ?? {})]
304
+ }
@@ -0,0 +1,33 @@
1
+ export interface RunDetailField {
2
+ readonly path: readonly string[]
3
+ readonly value: string | number | boolean | null
4
+ }
5
+
6
+ export function systemRunDetail(detail: string | null): readonly RunDetailField[] {
7
+ if (detail === null || detail.trim() === '') return []
8
+
9
+ let parsed: unknown
10
+ try {
11
+ parsed = JSON.parse(detail)
12
+ } catch {
13
+ return [{ path: [], value: detail }]
14
+ }
15
+
16
+ const fields: RunDetailField[] = []
17
+ const pending = [{ path: [] as string[], value: parsed }]
18
+ while (pending.length > 0) {
19
+ const { path, value } = pending.pop()!
20
+ if (value !== null && typeof value === 'object') {
21
+ const entries = Array.isArray(value)
22
+ ? value.map((item, index) => [String(index + 1), item] as const)
23
+ : Object.entries(value)
24
+ if (entries.length === 0) fields.push({ path, value: null })
25
+ for (const [key, item] of entries.reverse()) {
26
+ pending.push({ path: [...path, key], value: item })
27
+ }
28
+ } else {
29
+ fields.push({ path, value: value as RunDetailField['value'] })
30
+ }
31
+ }
32
+ return fields
33
+ }
@@ -1,27 +0,0 @@
1
- import type { NextRequest } from 'next/server'
2
-
3
- import { handleFakeStripe } from '@/server/demo-stripe'
4
-
5
- export const dynamic = 'force-dynamic'
6
-
7
- async function handle(
8
- request: NextRequest,
9
- params: Promise<{ path?: string[] }>,
10
- ): Promise<Response> {
11
- const { path } = await params
12
- return handleFakeStripe(request, (path ?? []).join('/'))
13
- }
14
-
15
- export async function GET(
16
- request: NextRequest,
17
- context: { params: Promise<{ path?: string[] }> },
18
- ): Promise<Response> {
19
- return handle(request, context.params)
20
- }
21
-
22
- export async function POST(
23
- request: NextRequest,
24
- context: { params: Promise<{ path?: string[] }> },
25
- ): Promise<Response> {
26
- return handle(request, context.params)
27
- }
@@ -1,41 +0,0 @@
1
- import { demoBannerModel } from '@/server/demo'
2
- import { getTranslator } from '@/server/i18n'
3
- import { splitAround } from '@/view/copy'
4
-
5
- export async function DemoBanner() {
6
- const banner = await demoBannerModel()
7
- if (banner === null) return null
8
-
9
- const t = await getTranslator()
10
- const [loginLead, loginTail] = splitAround(t, 'demo.loginAs', 'logins')
11
-
12
- return (
13
- <aside
14
- className="border-b border-border bg-muted px-4 py-2 text-sm text-muted-foreground"
15
- aria-label={t.t('demo.aria')}
16
- >
17
- <div className="mx-auto flex max-w-6xl flex-wrap items-baseline gap-x-4 gap-y-1">
18
- <strong className="font-semibold text-foreground">{t.t('demo.title')}</strong>
19
-
20
- <span>
21
- {loginLead}
22
- {banner.logins.map((login, index) => (
23
- <span key={login.username}>
24
- {index > 0 && ', '}
25
- <code className="rounded bg-background px-1 py-0.5 font-mono text-foreground">
26
- {login.username} / {login.password}
27
- </code>
28
- </span>
29
- ))}
30
- {loginTail}
31
- </span>
32
-
33
- <span>
34
- {banner.resetsIn === null
35
- ? t.t('demo.wiped')
36
- : t.t('demo.wipedIn', { when: banner.resetsIn })}
37
- </span>
38
- </div>
39
- </aside>
40
- )
41
- }
@@ -1,104 +0,0 @@
1
- import 'server-only'
2
-
3
- import { env, logger, readPluginEnv } from '@meith/core'
4
- import {
5
- FAKE_STRIPE_MOUNT,
6
- type FakeStripeRequest,
7
- type FakeStripeResponse,
8
- fakeStripe,
9
- } from '@meith/demo'
10
- import { signStripePayload } from '@meith/plugin-dues'
11
- import { resolvePluginSettings } from '@meith/plugin-kit'
12
-
13
- import { boardUrl } from './board-url'
14
- import { activeDefinitions } from './plugin-host'
15
- import { dispatchPluginRoute } from './plugin-routes'
16
- import { getSettingOverrides } from './settings'
17
-
18
- const DUES = 'dues'
19
-
20
- const SETTLED = new Set(['granted', 'already-settled', 'paid-but-grant-refused'])
21
-
22
- interface Global {
23
- __meithFakeStripe?: Map<string, ReturnType<typeof fakeStripe>>
24
- }
25
-
26
- function instance(publicBase: string): ReturnType<typeof fakeStripe> {
27
- const store = (globalThis as Global).__meithFakeStripe ?? new Map()
28
- ;(globalThis as Global).__meithFakeStripe = store
29
-
30
- const existing = store.get(publicBase)
31
- if (existing !== undefined) return existing
32
-
33
- const made = fakeStripe({ publicBase })
34
- store.set(publicBase, made)
35
- return made
36
- }
37
-
38
- export async function handleFakeStripe(request: Request, path: string): Promise<Response> {
39
- if (!env.DEMO_MODE) return new Response('Not found', { status: 404 })
40
-
41
- const url = new URL(request.url)
42
- const call: FakeStripeRequest = {
43
- method: request.method,
44
- path: path === '' ? '/' : `/${path}`,
45
- body: request.method === 'POST' ? await request.text() : '',
46
- query: url.searchParams,
47
- }
48
-
49
- const outcome = instance(`${await boardUrl()}${FAKE_STRIPE_MOUNT}`).handle(call)
50
- if (outcome.event !== null) await deliver(outcome.event)
51
- return respond(outcome.response)
52
- }
53
-
54
- function respond(response: FakeStripeResponse): Response {
55
- if (response.kind === 'redirect') {
56
- return new Response(null, { status: 303, headers: { location: response.to } })
57
- }
58
-
59
- const contentType =
60
- response.kind === 'json' ? 'application/json; charset=utf-8' : 'text/html; charset=utf-8'
61
- const body = response.kind === 'json' ? JSON.stringify(response.body) : response.body
62
-
63
- return new Response(body, {
64
- status: response.status,
65
- headers: { 'content-type': contentType, 'cache-control': 'no-store' },
66
- })
67
- }
68
-
69
- async function deliver(event: unknown): Promise<void> {
70
- const definition = activeDefinitions().find((candidate) => candidate.key === DUES)
71
- if (definition === undefined) return
72
-
73
- const settings = resolvePluginSettings(definition, await getSettingOverrides(), readPluginEnv)
74
- const secret = String(settings.stripe_webhook_secret ?? '')
75
- if (secret === '') {
76
- logger().warn('the demo Stripe has no webhook secret to sign with')
77
- return
78
- }
79
-
80
- const body = JSON.stringify(event)
81
- const signature = signStripePayload(body, secret, Math.floor(Date.now() / 1000))
82
- const board = await boardUrl()
83
-
84
- const response = await dispatchPluginRoute(
85
- new Request(`${board}/api/plugins/${DUES}/hook/stripe`, {
86
- method: 'POST',
87
- body,
88
- headers: { 'content-type': 'application/json', 'stripe-signature': signature },
89
- }),
90
- DUES,
91
- ['hook', 'stripe'],
92
- )
93
-
94
- const outcome = response.ok
95
- ? String(((await response.json()) as { outcome?: unknown }).outcome ?? 'unknown')
96
- : 'rejected'
97
-
98
- if (!SETTLED.has(outcome)) {
99
- logger().warn(
100
- { status: response.status, outcome },
101
- 'the demo Stripe delivered a webhook the board did not act on',
102
- )
103
- }
104
- }
@@ -1,17 +0,0 @@
1
- import 'server-only'
2
-
3
- import { rm } from 'node:fs/promises'
4
-
5
- import { env, logger } from '@meith/core'
6
-
7
- export async function clearUploadedFiles(): Promise<void> {
8
- if (env.FILESTORE_DRIVER !== 'local') {
9
- logger({ module: 'demo' }).warn(
10
- { driver: env.FILESTORE_DRIVER },
11
- 'demo reset left uploads in place: only the local file store is cleared',
12
- )
13
- return
14
- }
15
-
16
- await rm(env.UPLOADS_DIR, { recursive: true, force: true })
17
- }
@@ -1,59 +0,0 @@
1
- import 'server-only'
2
-
3
- import { env, logger } from '@meith/core'
4
- import { getDb } from '@meith/db'
5
- import {
6
- assertDemoAccountIsChangeable,
7
- type DemoBanner,
8
- demoBanner,
9
- type FrozenField,
10
- nextDemoResetAt,
11
- } from '@meith/demo'
12
-
13
- import { getContainer } from './container'
14
-
15
- export async function assertDemoAccountChangeable(
16
- userId: number,
17
- what: FrozenField,
18
- ): Promise<void> {
19
- if (!env.DEMO_MODE) return
20
-
21
- const account = await getContainer().accountStore.accounts.findById(userId)
22
- if (account === null) return
23
-
24
- assertDemoAccountIsChangeable(account.username, what)
25
- }
26
-
27
- export async function assertDemoIdentityUnchanged(
28
- userId: number,
29
- submitted: { readonly username: string; readonly email: string },
30
- ): Promise<void> {
31
- if (!env.DEMO_MODE) return
32
-
33
- const account = await getContainer().accountStore.accounts.findById(userId)
34
- if (account === null) return
35
-
36
- if (submitted.username.trim().toLowerCase() !== account.usernameLower) {
37
- assertDemoAccountIsChangeable(account.username, 'username')
38
- }
39
- if (submitted.email.trim().toLowerCase() !== account.emailLower) {
40
- assertDemoAccountIsChangeable(account.username, 'email')
41
- }
42
- }
43
-
44
- export async function demoBannerModel(): Promise<DemoBanner | null> {
45
- if (!env.DEMO_MODE) return null
46
-
47
- try {
48
- return demoBanner({
49
- nextResetAt: await nextDemoResetAt(getDb()),
50
- now: new Date(),
51
- })
52
- } catch (error) {
53
- logger({ module: 'demo' }).warn(
54
- { err: String(error) },
55
- 'could not read the next demo reset time',
56
- )
57
- return demoBanner({ nextResetAt: null, now: new Date() })
58
- }
59
- }