@meith/web 0.36.2 → 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.
- package/app/fixtures/page.tsx +30 -0
- package/app/layout.tsx +0 -2
- package/next.config.mjs +1 -1
- package/package.json +50 -50
- package/src/server/auth-config.ts +2 -13
- package/src/server/board-latest.tsx +5 -3
- package/src/server/container.ts +16 -38
- package/src/server/federation-actions.ts +0 -3
- package/src/server/fixture-activity-repo.ts +127 -0
- package/src/server/fixture-content.json +3045 -0
- package/src/server/navigation.ts +6 -2
- package/src/server/plugin-routes.ts +1 -2
- package/src/server/request-fingerprint.ts +2 -15
- package/src/server/seed-board.ts +180 -29
- package/src/server/stats.ts +5 -4
- package/src/server/syndication.ts +1 -3
- package/src/server/two-factor-actions.ts +0 -3
- package/src/server/upgrade-notice.ts +1 -1
- package/src/server/user-admin-actions.ts +0 -4
- package/src/server/usercp-actions.ts +0 -5
- package/src/theme/contract.fixture.ts +6 -6
- package/src/theme/gallery.fixture.tsx +412 -0
- package/src/theme/preview-boundary.tsx +23 -0
- package/src/theme/preview.fixture.ts +304 -0
- package/app/demo/stripe/[[...path]]/route.ts +0 -27
- package/src/components/shell/demo-banner.tsx +0 -41
- package/src/server/demo-stripe.ts +0 -104
- package/src/server/demo-uploads.ts +0 -17
- package/src/server/demo.ts +0 -59
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { type ComponentType, createElement, type ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { Translator } from '@meith/i18n'
|
|
4
|
+
import {
|
|
5
|
+
type ResolvedTheme,
|
|
6
|
+
requireSlot,
|
|
7
|
+
SLOT_NAMES,
|
|
8
|
+
type SlotModels,
|
|
9
|
+
type SlotName,
|
|
10
|
+
slotCopy,
|
|
11
|
+
} from '@meith/theme-kit'
|
|
12
|
+
import { buttonVariants, Card, CardContent, Input, Textarea } from '@meith/ui'
|
|
13
|
+
|
|
14
|
+
import { PollForm } from '@/components/content/poll'
|
|
15
|
+
import { ThreadRatingForm } from '@/components/content/thread-rating'
|
|
16
|
+
import { HeaderPeekEnhancer } from '@/components/shell/header-peek-enhancer'
|
|
17
|
+
import { NavDisclosureEnhancer } from '@/components/shell/nav-disclosure-enhancer'
|
|
18
|
+
import { ThemeSwitcher } from '@/components/shell/theme-switcher'
|
|
19
|
+
import { getTranslator } from '@/server/i18n'
|
|
20
|
+
import { currentTheme } from '@/server/theme'
|
|
21
|
+
import { threadRatingCopy } from '@/view/content-copy'
|
|
22
|
+
import { buildEditorToolbarModel } from '@/view/editor-toolbar'
|
|
23
|
+
|
|
24
|
+
import { fixtureModel, fixtureVariants } from './preview.fixture'
|
|
25
|
+
import { PreviewBoundary } from './preview-boundary'
|
|
26
|
+
|
|
27
|
+
const READ_ONLY = 'Fixture preview: controls can be edited, but submissions are not saved.'
|
|
28
|
+
const SAMPLE =
|
|
29
|
+
'A reply with **bold text**, a [link](/), and a short list.\n\n- First point\n- Second point'
|
|
30
|
+
|
|
31
|
+
function previewActions(value: unknown): unknown {
|
|
32
|
+
if (Array.isArray(value)) return value.map(previewActions)
|
|
33
|
+
if (value === null || typeof value !== 'object') return value
|
|
34
|
+
return Object.fromEntries(
|
|
35
|
+
Object.entries(value).map(([key, entry]) => [
|
|
36
|
+
key,
|
|
37
|
+
(key === 'action' || key.endsWith('Action')) && typeof entry === 'string'
|
|
38
|
+
? '/fixtures'
|
|
39
|
+
: previewActions(entry),
|
|
40
|
+
]),
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function renderFixture(
|
|
45
|
+
theme: ResolvedTheme,
|
|
46
|
+
t: Translator,
|
|
47
|
+
selected: SlotName,
|
|
48
|
+
variant: string,
|
|
49
|
+
): ReactNode {
|
|
50
|
+
function model<K extends SlotName>(name: K): SlotModels[K] {
|
|
51
|
+
return previewActions(
|
|
52
|
+
fixtureModel(name, name === selected ? variant : 'default'),
|
|
53
|
+
) as SlotModels[K]
|
|
54
|
+
}
|
|
55
|
+
function slot<K extends SlotName>(name: K, overrides: Partial<SlotModels[K]> = {}): ReactNode {
|
|
56
|
+
return createElement(requireSlot(theme, name) as ComponentType<object>, {
|
|
57
|
+
...model(name),
|
|
58
|
+
...overrides,
|
|
59
|
+
copy: slotCopy(theme, name, t),
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
const badge = (
|
|
63
|
+
<span className="inline-flex rounded border border-border bg-muted px-2 py-1 text-xs">
|
|
64
|
+
Community helper
|
|
65
|
+
</span>
|
|
66
|
+
)
|
|
67
|
+
const form = (mode: 'thread' | 'reply' | 'edit' = 'reply') => (
|
|
68
|
+
<form action="/fixtures" method="post" className="space-y-4">
|
|
69
|
+
{mode === 'thread' && (
|
|
70
|
+
<label className="block space-y-2">
|
|
71
|
+
<span>Subject</span>
|
|
72
|
+
<Input name="subject" defaultValue="Ideas for our next meetup" />
|
|
73
|
+
</label>
|
|
74
|
+
)}
|
|
75
|
+
<label htmlFor="fixture-message" className="block text-sm font-medium">
|
|
76
|
+
Message
|
|
77
|
+
</label>
|
|
78
|
+
<div className="overflow-hidden rounded-md border border-border">
|
|
79
|
+
{slot(
|
|
80
|
+
'EditorToolbar',
|
|
81
|
+
buildEditorToolbarModel({ textareaId: 'fixture-message', attachments: true, t }),
|
|
82
|
+
)}
|
|
83
|
+
<Textarea
|
|
84
|
+
id="fixture-message"
|
|
85
|
+
name="message"
|
|
86
|
+
rows={8}
|
|
87
|
+
defaultValue={SAMPLE}
|
|
88
|
+
className="rounded-none border-0"
|
|
89
|
+
/>
|
|
90
|
+
</div>
|
|
91
|
+
<label className="block space-y-2 text-sm">
|
|
92
|
+
<span>Attachments</span>
|
|
93
|
+
<Input type="file" id="fixture-message-attachment" multiple />
|
|
94
|
+
</label>
|
|
95
|
+
{mode === 'edit' && (
|
|
96
|
+
<label className="block space-y-2">
|
|
97
|
+
<span>Reason for editing</span>
|
|
98
|
+
<Input name="reason" defaultValue="Corrected the meeting time" />
|
|
99
|
+
</label>
|
|
100
|
+
)}
|
|
101
|
+
<label className="flex items-center gap-2 text-sm">
|
|
102
|
+
<input type="checkbox" defaultChecked />
|
|
103
|
+
Notify me of replies
|
|
104
|
+
</label>
|
|
105
|
+
<button type="submit" className={buttonVariants({ variant: 'primary' })}>
|
|
106
|
+
Preview submission
|
|
107
|
+
</button>
|
|
108
|
+
</form>
|
|
109
|
+
)
|
|
110
|
+
const actions = () => slot('PostActions')
|
|
111
|
+
const post = () =>
|
|
112
|
+
slot('PostBit', {
|
|
113
|
+
regions: {
|
|
114
|
+
actions: actions(),
|
|
115
|
+
pluginBadges: badge,
|
|
116
|
+
pluginFooter: (
|
|
117
|
+
<p className="text-sm text-muted-foreground">3 members found this helpful.</p>
|
|
118
|
+
),
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
const pagination = () => slot('Pagination')
|
|
122
|
+
const quickReply = () => slot('QuickReply', { children: form() })
|
|
123
|
+
const category = () => slot('CategoryBlock', { children: slot('ForumRow') })
|
|
124
|
+
const viewer = selected === 'Header' ? model('Header').viewer : model('UserPanel').viewer
|
|
125
|
+
const userPanel = () =>
|
|
126
|
+
slot('UserPanel', {
|
|
127
|
+
viewer,
|
|
128
|
+
...(viewer.isGuest
|
|
129
|
+
? {
|
|
130
|
+
links: [
|
|
131
|
+
{ label: 'Log in', href: '/login' },
|
|
132
|
+
{ label: 'Register', href: '/register' },
|
|
133
|
+
],
|
|
134
|
+
unreadNotifications: { value: 0, label: '0' },
|
|
135
|
+
unreadMessages: { value: 0, label: '0' },
|
|
136
|
+
}
|
|
137
|
+
: {}),
|
|
138
|
+
children: viewer.isGuest ? null : (
|
|
139
|
+
<button type="button" className={buttonVariants({ variant: 'ghost' })}>
|
|
140
|
+
Log out
|
|
141
|
+
</button>
|
|
142
|
+
),
|
|
143
|
+
})
|
|
144
|
+
const header = () => slot('Header', { children: userPanel() })
|
|
145
|
+
const footer = () => slot('Footer', { regions: { controls: slot('ForumJump') } })
|
|
146
|
+
const board = () =>
|
|
147
|
+
slot('BoardIndex', {
|
|
148
|
+
regions:
|
|
149
|
+
variant === 'empty' && selected === 'BoardIndex'
|
|
150
|
+
? model('BoardIndex').regions
|
|
151
|
+
: {
|
|
152
|
+
categories: category(),
|
|
153
|
+
stats: slot('BoardStats'),
|
|
154
|
+
online: slot('WhoIsOnline'),
|
|
155
|
+
latest: (
|
|
156
|
+
<>
|
|
157
|
+
{slot('LatestThreads')}
|
|
158
|
+
{slot('LatestPosts')}
|
|
159
|
+
</>
|
|
160
|
+
),
|
|
161
|
+
announcements: slot('Announcement'),
|
|
162
|
+
plugins: badge,
|
|
163
|
+
},
|
|
164
|
+
})
|
|
165
|
+
const listing = () =>
|
|
166
|
+
slot('ForumDisplay', {
|
|
167
|
+
regions:
|
|
168
|
+
variant === 'empty' && selected === 'ForumDisplay'
|
|
169
|
+
? model('ForumDisplay').regions
|
|
170
|
+
: {
|
|
171
|
+
subforums: slot('SubforumList'),
|
|
172
|
+
threads: slot('ThreadRow', { regions: { pluginBadges: badge } }),
|
|
173
|
+
pagination: pagination(),
|
|
174
|
+
announcements: slot('Announcement'),
|
|
175
|
+
},
|
|
176
|
+
})
|
|
177
|
+
const rating = { average: 4.2, count: 12, mine: null }
|
|
178
|
+
const thread = () =>
|
|
179
|
+
slot('ThreadView', {
|
|
180
|
+
regions: {
|
|
181
|
+
posts: post(),
|
|
182
|
+
pagination: pagination(),
|
|
183
|
+
quickReply: ['guest', 'locked'].includes(variant) ? null : quickReply(),
|
|
184
|
+
tools:
|
|
185
|
+
variant === 'poll' ? (
|
|
186
|
+
<PollForm
|
|
187
|
+
threadId={91}
|
|
188
|
+
canVote={false}
|
|
189
|
+
poll={{
|
|
190
|
+
id: 1,
|
|
191
|
+
threadId: 91,
|
|
192
|
+
question: 'When should we meet?',
|
|
193
|
+
createdAt: new Date('2026-03-12T09:14:00Z'),
|
|
194
|
+
closesAt: null,
|
|
195
|
+
maxOptions: 1,
|
|
196
|
+
publicVotes: true,
|
|
197
|
+
allowRevote: true,
|
|
198
|
+
votedOptionIds: [1],
|
|
199
|
+
options: [
|
|
200
|
+
{
|
|
201
|
+
id: 1,
|
|
202
|
+
label: 'Friday evening',
|
|
203
|
+
votes: 8,
|
|
204
|
+
voters: [{ userId: 12, username: 'Marlow', votedAt: null }],
|
|
205
|
+
},
|
|
206
|
+
{ id: 2, label: 'Saturday morning', votes: 4, voters: [] },
|
|
207
|
+
{ id: 3, label: 'Sunday afternoon', votes: 0, voters: [] },
|
|
208
|
+
],
|
|
209
|
+
}}
|
|
210
|
+
/>
|
|
211
|
+
) : null,
|
|
212
|
+
afterContent: (
|
|
213
|
+
<ThreadRatingForm
|
|
214
|
+
threadId={91}
|
|
215
|
+
rating={rating}
|
|
216
|
+
canRate={false}
|
|
217
|
+
copy={threadRatingCopy(rating, t)}
|
|
218
|
+
/>
|
|
219
|
+
),
|
|
220
|
+
},
|
|
221
|
+
})
|
|
222
|
+
const panelBody = () => (
|
|
223
|
+
<Card>
|
|
224
|
+
<CardContent className="space-y-4 pt-6">
|
|
225
|
+
<label className="block space-y-2">
|
|
226
|
+
<span>Display name</span>
|
|
227
|
+
<Input defaultValue="Wren" />
|
|
228
|
+
</label>
|
|
229
|
+
<label htmlFor="fixture-bio" className="block space-y-2">
|
|
230
|
+
<span>Biography</span>
|
|
231
|
+
<Textarea
|
|
232
|
+
id="fixture-bio"
|
|
233
|
+
defaultValue="Building a friendly community, one conversation at a time."
|
|
234
|
+
/>
|
|
235
|
+
</label>
|
|
236
|
+
<label className="flex items-center gap-2">
|
|
237
|
+
<input type="checkbox" defaultChecked />
|
|
238
|
+
Show my online status
|
|
239
|
+
</label>
|
|
240
|
+
<button type="button" className={buttonVariants({ variant: 'primary' })}>
|
|
241
|
+
Save changes
|
|
242
|
+
</button>
|
|
243
|
+
<p className="text-sm text-muted-foreground">No changes have been saved.</p>
|
|
244
|
+
</CardContent>
|
|
245
|
+
</Card>
|
|
246
|
+
)
|
|
247
|
+
const section = () =>
|
|
248
|
+
slot('PanelSection', {
|
|
249
|
+
regions: { description: 'Manage the details shown beside your posts.', actions: badge },
|
|
250
|
+
children: panelBody(),
|
|
251
|
+
})
|
|
252
|
+
const panelKind =
|
|
253
|
+
selected === 'PanelPage'
|
|
254
|
+
? model('PanelPage').panel
|
|
255
|
+
: selected === 'PanelNav'
|
|
256
|
+
? model('PanelNav').panel
|
|
257
|
+
: model('PanelShell').panel
|
|
258
|
+
const panelPage = () =>
|
|
259
|
+
slot('PanelPage', {
|
|
260
|
+
panel: panelKind,
|
|
261
|
+
title: 'Profile settings',
|
|
262
|
+
regions: {
|
|
263
|
+
lede: 'Personal details and preferences.',
|
|
264
|
+
meta: 'Last updated 12 Mar 2026',
|
|
265
|
+
actions: null,
|
|
266
|
+
},
|
|
267
|
+
children: section(),
|
|
268
|
+
})
|
|
269
|
+
const panel = () =>
|
|
270
|
+
slot('PanelShell', {
|
|
271
|
+
panel: panelKind ?? 'usercp',
|
|
272
|
+
regions: { nav: slot('PanelNav', { panel: panelKind ?? 'usercp' }) },
|
|
273
|
+
children: panelPage(),
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
switch (selected) {
|
|
277
|
+
case 'Shell':
|
|
278
|
+
return slot('Shell', {
|
|
279
|
+
children: (
|
|
280
|
+
<>
|
|
281
|
+
{header()}
|
|
282
|
+
{board()}
|
|
283
|
+
{footer()}
|
|
284
|
+
</>
|
|
285
|
+
),
|
|
286
|
+
})
|
|
287
|
+
case 'Header':
|
|
288
|
+
return header()
|
|
289
|
+
case 'UserPanel':
|
|
290
|
+
return userPanel()
|
|
291
|
+
case 'Footer':
|
|
292
|
+
return footer()
|
|
293
|
+
case 'BoardIndex':
|
|
294
|
+
return board()
|
|
295
|
+
case 'CategoryBlock':
|
|
296
|
+
case 'ForumRow':
|
|
297
|
+
return category()
|
|
298
|
+
case 'ForumDisplay':
|
|
299
|
+
case 'ThreadRow':
|
|
300
|
+
return listing()
|
|
301
|
+
case 'ThreadView':
|
|
302
|
+
return thread()
|
|
303
|
+
case 'PostBit':
|
|
304
|
+
return post()
|
|
305
|
+
case 'QuickReply':
|
|
306
|
+
return quickReply()
|
|
307
|
+
case 'PostForm':
|
|
308
|
+
return slot('PostForm', { regions: { form: form(model('PostForm').mode), toolbar: null } })
|
|
309
|
+
case 'EditorToolbar':
|
|
310
|
+
return form('thread')
|
|
311
|
+
case 'MemberProfile':
|
|
312
|
+
return slot('MemberProfile', { regions: variant === 'minimal' ? {} : { plugins: badge } })
|
|
313
|
+
case 'AuthPage':
|
|
314
|
+
return slot('AuthPage', {
|
|
315
|
+
regions: {
|
|
316
|
+
lede: 'Join the conversation.',
|
|
317
|
+
form: (
|
|
318
|
+
<form action="/fixtures" method="post" className="space-y-4">
|
|
319
|
+
<label className="block space-y-2">
|
|
320
|
+
<span>Email address</span>
|
|
321
|
+
<Input type="email" defaultValue="wren@example.test" autoComplete="off" />
|
|
322
|
+
</label>
|
|
323
|
+
{variant !== 'reset' && (
|
|
324
|
+
<label className="block space-y-2">
|
|
325
|
+
<span>Password</span>
|
|
326
|
+
<Input type="password" autoComplete="off" />
|
|
327
|
+
</label>
|
|
328
|
+
)}
|
|
329
|
+
<button type="submit" className={buttonVariants({ variant: 'primary' })}>
|
|
330
|
+
Continue
|
|
331
|
+
</button>
|
|
332
|
+
</form>
|
|
333
|
+
),
|
|
334
|
+
note: READ_ONLY,
|
|
335
|
+
},
|
|
336
|
+
})
|
|
337
|
+
case 'PanelShell':
|
|
338
|
+
case 'PanelNav':
|
|
339
|
+
return panel()
|
|
340
|
+
case 'PanelPage':
|
|
341
|
+
return variant === 'standalone' ? panelPage() : panel()
|
|
342
|
+
case 'PanelSection':
|
|
343
|
+
return section()
|
|
344
|
+
default:
|
|
345
|
+
return slot(selected)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export async function FixtureGallery({ name, variant }: { name: SlotName; variant: string }) {
|
|
350
|
+
const [theme, t] = await Promise.all([currentTheme(), getTranslator()])
|
|
351
|
+
const Shell = requireSlot(theme, 'Shell')
|
|
352
|
+
const preview = renderFixture(theme, t, name, variant)
|
|
353
|
+
return (
|
|
354
|
+
<div className="text-foreground">
|
|
355
|
+
<header className="space-y-4 border-b border-border bg-card px-4 py-5 sm:px-8">
|
|
356
|
+
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
357
|
+
<h1 className="text-xl font-semibold">Theme fixtures</h1>
|
|
358
|
+
<a href="/" className="text-sm underline">
|
|
359
|
+
Back to the board
|
|
360
|
+
</a>
|
|
361
|
+
</div>
|
|
362
|
+
<p className="max-w-3xl text-sm text-muted-foreground">
|
|
363
|
+
Every theme slot, including signed-in and staff presentation states. These are sample
|
|
364
|
+
models, not a login. {READ_ONLY}
|
|
365
|
+
</p>
|
|
366
|
+
<form action="/fixtures" className="flex flex-wrap items-end gap-3">
|
|
367
|
+
<label className="space-y-1 text-sm">
|
|
368
|
+
<span className="block">Slot</span>
|
|
369
|
+
<select
|
|
370
|
+
name="slot"
|
|
371
|
+
defaultValue={name}
|
|
372
|
+
className="rounded border border-border bg-background p-2"
|
|
373
|
+
>
|
|
374
|
+
{SLOT_NAMES.map((slot) => (
|
|
375
|
+
<option key={slot}>{slot}</option>
|
|
376
|
+
))}
|
|
377
|
+
</select>
|
|
378
|
+
</label>
|
|
379
|
+
<button type="submit" className={buttonVariants({ variant: 'secondary' })}>
|
|
380
|
+
Show slot
|
|
381
|
+
</button>
|
|
382
|
+
</form>
|
|
383
|
+
<nav aria-label="Fixture states" className="flex flex-wrap gap-2">
|
|
384
|
+
{fixtureVariants(name).map((state) => (
|
|
385
|
+
<a
|
|
386
|
+
key={state}
|
|
387
|
+
href={`/fixtures?slot=${name}&variant=${state}`}
|
|
388
|
+
aria-current={state === variant ? 'page' : undefined}
|
|
389
|
+
className="rounded border border-border px-3 py-1 text-sm aria-[current=page]:bg-primary aria-[current=page]:text-primary-foreground"
|
|
390
|
+
>
|
|
391
|
+
{state}
|
|
392
|
+
</a>
|
|
393
|
+
))}
|
|
394
|
+
</nav>
|
|
395
|
+
<ThemeSwitcher />
|
|
396
|
+
</header>
|
|
397
|
+
<NavDisclosureEnhancer />
|
|
398
|
+
<HeaderPeekEnhancer />
|
|
399
|
+
<PreviewBoundary message={READ_ONLY}>
|
|
400
|
+
<div data-fixture-slot={name} data-fixture-variant={variant} className="min-w-0 p-4 sm:p-8">
|
|
401
|
+
{name === 'Shell' ? (
|
|
402
|
+
preview
|
|
403
|
+
) : (
|
|
404
|
+
<Shell {...fixtureModel('Shell')} copy={slotCopy(theme, 'Shell', t)}>
|
|
405
|
+
{preview}
|
|
406
|
+
</Shell>
|
|
407
|
+
)}
|
|
408
|
+
</div>
|
|
409
|
+
</PreviewBoundary>
|
|
410
|
+
</div>
|
|
411
|
+
)
|
|
412
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { type ReactNode, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
export function PreviewBoundary({ children, message }: { children: ReactNode; message: string }) {
|
|
6
|
+
const [submitted, setSubmitted] = useState(false)
|
|
7
|
+
return (
|
|
8
|
+
<div
|
|
9
|
+
onSubmitCapture={(event) => {
|
|
10
|
+
event.preventDefault()
|
|
11
|
+
event.stopPropagation()
|
|
12
|
+
setSubmitted(true)
|
|
13
|
+
}}
|
|
14
|
+
>
|
|
15
|
+
{submitted && (
|
|
16
|
+
<p role="status" className="border-b border-border bg-muted p-3 text-sm">
|
|
17
|
+
{message}
|
|
18
|
+
</p>
|
|
19
|
+
)}
|
|
20
|
+
{children}
|
|
21
|
+
</div>
|
|
22
|
+
)
|
|
23
|
+
}
|