@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,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
+ }