@meith/web 0.36.2 → 0.37.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.
@@ -79,12 +79,16 @@ export async function boardNavigation(
79
79
  const live = await Promise.all(items.map((item) => pluginRowIsLive(item.key)))
80
80
  const shown = items.filter((_, index) => live[index] === true)
81
81
 
82
- return buildNavigation(shown, viewer, {
82
+ const t = await getTranslator()
83
+ const links = buildNavigation(shown, viewer, {
83
84
  searchEnabled: await searchEnabled(),
84
- t: await getTranslator(),
85
+ t,
85
86
  names: pluginNavigationNames(),
86
87
  admits: (item) => authorizer.inAnyGroup(actor, item.visibleToGroups),
87
88
  })
89
+ return env.DATA_SOURCE === 'fixture'
90
+ ? [...links, { label: t.t('nav.themeFixtures'), href: '/fixtures' }]
91
+ : links
88
92
  }
89
93
 
90
94
  export async function syncPluginNavigation(): Promise<void> {
@@ -3,7 +3,6 @@ import 'server-only'
3
3
 
4
4
  import { env, logger, resolveClientAddress } from '@meith/core'
5
5
  import { currentRequestId } from '@meith/core/logger'
6
- import { demoAddressToken, demoDiscardsAddresses } from '@meith/demo'
7
6
  import {
8
7
  createRouteRateLimiter,
9
8
  DEFAULT_ROUTE_BODY_BYTES,
@@ -169,7 +168,7 @@ function callerKey(request: Request, userId: number | null): string {
169
168
  )
170
169
  if (address === null) return 'a:unknown'
171
170
 
172
- return `a:${demoDiscardsAddresses() ? demoAddressToken(address) : address}`
171
+ return `a:${address}`
173
172
  }
174
173
 
175
174
  export type PluginRouteSurface = 'board' | 'admin'
@@ -3,7 +3,6 @@ import 'server-only'
3
3
  import { headers } from 'next/headers'
4
4
 
5
5
  import { env, logger, resolveClientAddress, truncateIp } from '@meith/core'
6
- import { demoAddressToken, demoDiscardsAddresses } from '@meith/demo'
7
6
 
8
7
  export interface RequestFingerprint {
9
8
  readonly ipPrefix: string | null
@@ -32,25 +31,13 @@ export async function remoteAddress(): Promise<string | null> {
32
31
  }
33
32
 
34
33
  export async function retainedIpPrefix(): Promise<string | null> {
35
- if (demoDiscardsAddresses()) return null
36
34
  return truncateIp(await remoteAddress()) ?? null
37
35
  }
38
36
 
39
- export async function countingPrefix(): Promise<string | null> {
40
- const address = await remoteAddress()
41
- if (address === null || address === '') return null
42
-
43
- const prefix = truncateIp(address)
44
- if (prefix === undefined) return null
45
-
46
- return demoDiscardsAddresses() ? demoAddressToken(prefix) : prefix
47
- }
37
+ export const countingPrefix = retainedIpPrefix
48
38
 
49
39
  export async function countingAddress(): Promise<string | null> {
50
- const address = await remoteAddress()
51
- if (address === null || address === '') return null
52
-
53
- return demoDiscardsAddresses() ? demoAddressToken(address) : address
40
+ return (await remoteAddress()) || null
54
41
  }
55
42
 
56
43
  export async function requestFingerprint(): Promise<RequestFingerprint> {
@@ -2,9 +2,11 @@ import type { MemberProfileRecord } from '@meith/accounts'
2
2
  import type { GroupDefaults, MemoryBoard } from '@meith/authorization'
3
3
  import { emptyPermissionSet, type ForumPermissions, type PermissionSet } from '@meith/core'
4
4
  import type { ForumListingRow } from '@meith/forums'
5
- import { BodyFormat } from '@meith/markdown'
5
+ import { BodyFormat, quoteBlock } from '@meith/markdown'
6
6
  import type { PostListingRow } from '@meith/posts'
7
- import type { ThreadListingRow } from '@meith/threads'
7
+ import { type ThreadListingRow, threadSlug } from '@meith/threads'
8
+
9
+ import content from './fixture-content.json'
8
10
 
9
11
  export const SEED_GROUP = {
10
12
  guest: 1,
@@ -16,7 +18,7 @@ export const SEED_GROUP = {
16
18
  banned: 7,
17
19
  } as const
18
20
 
19
- export const FIXTURE_DATA_VERSION = 4
21
+ export const FIXTURE_DATA_VERSION = 5
20
22
 
21
23
  export const SEED_FORUM = {
22
24
  main: 10,
@@ -88,29 +90,7 @@ const ANNOUNCEMENT_READONLY: Partial<ForumPermissions> = {
88
90
  canPostReplies: false,
89
91
  }
90
92
 
91
- export const SEED_BOARD: MemoryBoard = {
92
- groups: GROUPS,
93
- chains: {
94
- [SEED_FORUM.main]: [SEED_FORUM.main],
95
- [SEED_FORUM.announcements]: [SEED_FORUM.announcements, SEED_FORUM.main],
96
- [SEED_FORUM.general]: [SEED_FORUM.general, SEED_FORUM.main],
97
- [SEED_FORUM.generalOffTopic]: [SEED_FORUM.generalOffTopic, SEED_FORUM.general, SEED_FORUM.main],
98
- },
99
- overrides: [
100
- {
101
- forumId: SEED_FORUM.announcements,
102
- groupId: SEED_GROUP.guest,
103
- overrides: ANNOUNCEMENT_READONLY,
104
- },
105
- {
106
- forumId: SEED_FORUM.announcements,
107
- groupId: SEED_GROUP.registered,
108
- overrides: ANNOUNCEMENT_READONLY,
109
- },
110
- ],
111
- }
112
-
113
- export const SEED_FORUM_ROWS: readonly ForumListingRow[] = [
93
+ const FORUM_ROWS: ForumListingRow[] = [
114
94
  {
115
95
  id: SEED_FORUM.main,
116
96
  type: 'category',
@@ -198,7 +178,7 @@ export const SEED_FORUM_ROWS: readonly ForumListingRow[] = [
198
178
  },
199
179
  ]
200
180
 
201
- export const SEED_THREAD_ROWS: readonly ThreadListingRow[] = [
181
+ const THREAD_ROWS: ThreadListingRow[] = [
202
182
  {
203
183
  id: 4,
204
184
  forumId: SEED_FORUM.announcements,
@@ -273,7 +253,7 @@ export const SEED_THREAD_ROWS: readonly ThreadListingRow[] = [
273
253
  },
274
254
  ]
275
255
 
276
- export const SEED_POST_ROWS: readonly PostListingRow[] = [
256
+ const POST_ROWS: PostListingRow[] = [
277
257
  {
278
258
  id: 10,
279
259
  threadId: 4,
@@ -402,7 +382,7 @@ export const SEED_POST_ROWS: readonly PostListingRow[] = [
402
382
  },
403
383
  ]
404
384
 
405
- export const SEED_MEMBER_PROFILES: readonly MemberProfileRecord[] = [
385
+ const MEMBER_PROFILES: MemberProfileRecord[] = [
406
386
  {
407
387
  id: 1,
408
388
  username: 'admin',
@@ -415,3 +395,174 @@ export const SEED_MEMBER_PROFILES: readonly MemberProfileRecord[] = [
415
395
  bio: 'Runs this board. Fixture data — nothing here is durable.',
416
396
  },
417
397
  ]
398
+
399
+ const SNAPSHOT_AT = new Date('2026-07-30T12:00:00Z')
400
+ const forumIds = new Map(content.forums.map((forum, index) => [forum.key, 1000 + index]))
401
+ const memberIds = new Map(
402
+ content.members.map((member, index) => [member.key, member.key === 'admin' ? 1 : 100 + index]),
403
+ )
404
+
405
+ for (const member of content.members) {
406
+ const id = memberIds.get(member.key)!
407
+ if (id === 1) continue
408
+ MEMBER_PROFILES.push({
409
+ id,
410
+ username: member.username,
411
+ title: 'Members',
412
+ postCount: 0,
413
+ createdAt: new Date(SNAPSHOT_AT.getTime() - member.joinedDaysAgo * 86_400_000),
414
+ lastActiveAt: SNAPSHOT_AT,
415
+ location: member.location,
416
+ website: member.website,
417
+ bio: member.bio,
418
+ })
419
+ }
420
+
421
+ for (const [index, forum] of content.forums.entries()) {
422
+ const id = forumIds.get(forum.key)!
423
+ const parentId = forum.parent === null ? null : forumIds.get(forum.parent)!
424
+ FORUM_ROWS.push({
425
+ id,
426
+ type: forum.type as 'category' | 'forum',
427
+ allowThreads: forum.type === 'forum',
428
+ title: forum.title,
429
+ slug: forum.slug,
430
+ description: forum.description ?? null,
431
+ parentId,
432
+ path: parentId === null ? String(id) : `${parentId}.${id}`,
433
+ depth: parentId === null ? 0 : 1,
434
+ displayOrder: 10 + index,
435
+ linkUrl: null,
436
+ threadCount: 0,
437
+ postCount: 0,
438
+ lastPost: null,
439
+ })
440
+ }
441
+
442
+ for (const [index, thread] of content.threads.entries()) {
443
+ const id = 1000 + index
444
+ const forumId = forumIds.get(thread.forum)!
445
+ const createdAt = new Date(SNAPSHOT_AT.getTime() - thread.daysAgo * 86_400_000)
446
+ const slug = threadSlug(thread.title)
447
+ const posts: PostListingRow[] = []
448
+ for (const [number, reply] of [{ ...thread, hoursAfter: 0 }, ...thread.replies].entries()) {
449
+ const member = MEMBER_PROFILES.find((profile) => profile.id === memberIds.get(reply.author))!
450
+ const quoted = 'quotes' in reply && reply.quotes !== undefined ? posts[reply.quotes] : undefined
451
+ const message =
452
+ quoted === undefined
453
+ ? reply.message
454
+ : `${quoteBlock({
455
+ author: quoted.authorUsername,
456
+ markdown: quoted.message,
457
+ sourceHref: `/thread/${id}-${slug}?post=${quoted.id}`,
458
+ })}\n\n${reply.message}`
459
+ posts.push({
460
+ id: 1000 + POST_ROWS.length + number,
461
+ threadId: id,
462
+ forumId,
463
+ number: number + 1,
464
+ authorUserId: member.id,
465
+ authorUsername: member.username,
466
+ authorPostCount: 0,
467
+ authorJoinedAt: member.createdAt,
468
+ message,
469
+ messageHtml: null,
470
+ renderVersion: 0,
471
+ bodyFormat: BodyFormat.Markdown,
472
+ editedAt: null,
473
+ editedByUsername: null,
474
+ editReason: null,
475
+ isFirstPost: number === 0,
476
+ visibility: 'visible',
477
+ createdAt: new Date(createdAt.getTime() + reply.hoursAfter * 3_600_000),
478
+ })
479
+ }
480
+ POST_ROWS.push(...posts)
481
+ const first = posts[0]!
482
+ const last = posts.at(-1)!
483
+ const prefix =
484
+ 'prefix' in thread ? content.prefixes.find((prefix) => prefix.key === thread.prefix) : undefined
485
+ THREAD_ROWS.push({
486
+ id,
487
+ forumId,
488
+ title: thread.title,
489
+ slug,
490
+ prefix: prefix === undefined ? null : { label: prefix.label, token: prefix.token },
491
+ authorUserId: first.authorUserId,
492
+ authorUsername: first.authorUsername,
493
+ replyCount: posts.length - 1,
494
+ viewCount: posts.length * 37,
495
+ ratingTotal: 0,
496
+ ratingCount: 0,
497
+ visibility: 'visible',
498
+ isSticky: 'sticky' in thread && thread.sticky === true,
499
+ isLocked: 'locked' in thread && thread.locked === true,
500
+ isMoved: false,
501
+ lastPost: {
502
+ postId: last.id,
503
+ userId: last.authorUserId,
504
+ username: last.authorUsername,
505
+ at: last.createdAt,
506
+ },
507
+ lastPostAt: last.createdAt,
508
+ })
509
+ }
510
+
511
+ export const SEED_THREAD_ROWS: readonly ThreadListingRow[] = THREAD_ROWS
512
+ export const SEED_MEMBER_PROFILES: readonly MemberProfileRecord[] = MEMBER_PROFILES.map(
513
+ (member) => ({
514
+ ...member,
515
+ postCount: POST_ROWS.filter((post) => post.authorUserId === member.id).length,
516
+ }),
517
+ )
518
+ export const SEED_POST_ROWS: readonly PostListingRow[] = POST_ROWS.map((post) => ({
519
+ ...post,
520
+ authorPostCount:
521
+ SEED_MEMBER_PROFILES.find((member) => member.id === post.authorUserId)?.postCount ?? 0,
522
+ }))
523
+ export const SEED_FORUM_ROWS: readonly ForumListingRow[] = FORUM_ROWS.map((forum) => {
524
+ const beneath = new Set(
525
+ FORUM_ROWS.filter(
526
+ (row) => row.path === forum.path || row.path.startsWith(`${forum.path}.`),
527
+ ).map((row) => row.id),
528
+ )
529
+ const posts = POST_ROWS.filter((post) => beneath.has(post.forumId))
530
+ const last = [...posts].sort(
531
+ (a, b) => b.createdAt.getTime() - a.createdAt.getTime() || b.id - a.id,
532
+ )[0]
533
+ return {
534
+ ...forum,
535
+ threadCount: THREAD_ROWS.filter((thread) => beneath.has(thread.forumId)).length,
536
+ postCount: posts.length,
537
+ lastPost:
538
+ last === undefined
539
+ ? null
540
+ : {
541
+ postId: last.id,
542
+ threadId: last.threadId,
543
+ threadTitle: THREAD_ROWS.find((thread) => thread.id === last.threadId)!.title,
544
+ userId: last.authorUserId,
545
+ username: last.authorUsername,
546
+ at: last.createdAt,
547
+ },
548
+ }
549
+ })
550
+
551
+ export const SEED_BOARD: MemoryBoard = {
552
+ groups: GROUPS,
553
+ chains: Object.fromEntries(
554
+ SEED_FORUM_ROWS.map((forum) => [forum.id, forum.path.split('.').map(Number).reverse()]),
555
+ ),
556
+ overrides: [
557
+ {
558
+ forumId: SEED_FORUM.announcements,
559
+ groupId: SEED_GROUP.guest,
560
+ overrides: ANNOUNCEMENT_READONLY,
561
+ },
562
+ {
563
+ forumId: SEED_FORUM.announcements,
564
+ groupId: SEED_GROUP.registered,
565
+ overrides: ANNOUNCEMENT_READONLY,
566
+ },
567
+ ],
568
+ }
@@ -12,11 +12,14 @@ import {
12
12
  } from '@meith/db'
13
13
 
14
14
  import { getContainer } from './container'
15
+ import { FixtureActivityRepository } from './fixture-activity-repo'
15
16
 
16
17
  export const LEADERBOARD_SIZE = 10
17
18
 
18
- export function statsRepository(): PostgresStatsRepository | null {
19
- return getContainer().dataSource === 'postgres' ? new PostgresStatsRepository(getDb()) : null
19
+ export function statsRepository(): PostgresStatsRepository | FixtureActivityRepository {
20
+ return getContainer().dataSource === 'postgres'
21
+ ? new PostgresStatsRepository(getDb())
22
+ : new FixtureActivityRepository()
20
23
  }
21
24
 
22
25
  export async function statsScopeFor(actor: Actor): Promise<StatsScope> {
@@ -38,7 +41,6 @@ export interface StatsView {
38
41
 
39
42
  export async function buildStatsView(actor: Actor): Promise<StatsView | null> {
40
43
  const repo = statsRepository()
41
- if (repo === null) return null
42
44
 
43
45
  const scope = await statsScopeFor(actor)
44
46
  const [totals, topPosters, mostViewed, mostReplied] = await Promise.all([
@@ -53,7 +55,6 @@ export async function buildStatsView(actor: Actor): Promise<StatsView | null> {
53
55
 
54
56
  export async function readTotals(): Promise<BoardTotals | null> {
55
57
  const repo = statsRepository()
56
- if (repo === null) return null
57
58
 
58
59
  try {
59
60
  return await repo.readTotals()
@@ -1,6 +1,6 @@
1
1
  import 'server-only'
2
2
 
3
- import { env, PUBLIC_CONTENT } from '@meith/core'
3
+ import { PUBLIC_CONTENT } from '@meith/core'
4
4
  import { type FeedScope, getDb, PostgresFeedRepository } from '@meith/db'
5
5
 
6
6
  import type { SitemapUrl } from '@/view/feed'
@@ -41,8 +41,6 @@ export async function publicScope(): Promise<FeedScope> {
41
41
  }
42
42
 
43
43
  export async function isIndexable(): Promise<boolean> {
44
- if (env.DEMO_MODE) return false
45
-
46
44
  try {
47
45
  return (await getSettings()).get('board.offline') !== true
48
46
  } catch {
@@ -13,7 +13,6 @@ import type { FormState } from './auth-form-state'
13
13
  import { getContainer } from './container'
14
14
  import { getActor } from './context'
15
15
  import { requireFreshCredentialProof } from './credential-proof'
16
- import { assertDemoAccountChangeable } from './demo'
17
16
  import { formStateReporter } from './form-state-reporter'
18
17
  import { text } from './form-values'
19
18
  import { getSettingsUncached } from './settings'
@@ -33,8 +32,6 @@ async function ownAccount(): Promise<Owner> {
33
32
  const actor = await getActor()
34
33
  if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged'))
35
34
 
36
- await assertDemoAccountChangeable(actor.userId, 'sign-in method')
37
-
38
35
  const account = await getContainer().accountStore.accounts.findById(actor.userId)
39
36
  if (account === null) throw new ForbiddenError(msg('error.app.account-longer-exists'))
40
37
 
@@ -17,7 +17,7 @@ import { planUpgrade, type UpgradeState, upgradeNotice } from '@meith/upgrade'
17
17
 
18
18
  import { activeDefinitions } from './plugin-host'
19
19
 
20
- export const CODE_VERSION = '0.36.2'
20
+ export const CODE_VERSION = '0.37.1'
21
21
 
22
22
  export interface UpgradeApplied {
23
23
  readonly plugins: readonly string[]
@@ -12,7 +12,6 @@ import { claimAdminUndo, issueAdminUndo } from './admin-undo'
12
12
  import { recordStaffAuthEvent } from './auth-events'
13
13
  import type { FormState } from './auth-form-state'
14
14
  import { getContainer } from './container'
15
- import { assertDemoAccountChangeable, assertDemoIdentityUnchanged } from './demo'
16
15
  import { formStateReporter } from './form-state-reporter'
17
16
  import { trimmedText } from './form-values'
18
17
  import { getTranslator } from './i18n'
@@ -64,7 +63,6 @@ export async function saveMemberAccountAction(
64
63
 
65
64
  const username = trimmedText(form, 'username')
66
65
  const email = trimmedText(form, 'email')
67
- await assertDemoIdentityUnchanged(id, { username, email })
68
66
 
69
67
  const before = await getContainer().accountStore.accounts.findById(id)
70
68
 
@@ -102,8 +100,6 @@ export async function clearSecondFactorAction(
102
100
  await requireFreshAdmin()
103
101
  const id = userId(form)
104
102
 
105
- await assertDemoAccountChangeable(id, 'sign-in method')
106
-
107
103
  const cleared = await clearMemberSecondFactor(id)
108
104
  if (!cleared) return { notice: 'cleared' }
109
105
 
@@ -17,7 +17,6 @@ import type { FormState } from './auth-form-state'
17
17
  import { AVATAR_FIELD, canUploadAvatar, requireAvatarService } from './avatars'
18
18
  import { configuredSessions, getContainer } from './container'
19
19
  import { getActor } from './context'
20
- import { assertDemoAccountChangeable } from './demo'
21
20
  import { revokeFeedToken } from './feed-token'
22
21
  import { formStateReporter } from './form-state-reporter'
23
22
  import { text } from './form-values'
@@ -150,8 +149,6 @@ export async function changePasswordAction(_prev: FormState, form: FormData): Pr
150
149
  try {
151
150
  const { service, userId } = await requireOwnSettings()
152
151
 
153
- await assertDemoAccountChangeable(userId, 'password')
154
-
155
152
  const next = text(form, 'newPassword')
156
153
  if (next !== text(form, 'confirmPassword')) {
157
154
  return { error: await tr('notice.app.two-new-passwords-match') }
@@ -189,8 +186,6 @@ export async function requestEmailChangeAction(
189
186
  try {
190
187
  const { service, userId } = await requireOwnSettings()
191
188
 
192
- await assertDemoAccountChangeable(userId, 'email')
193
-
194
189
  pending = {
195
190
  userId,
196
191
  ...(await service.requestEmailChange({
@@ -10,7 +10,7 @@ const VIEWER = {
10
10
  userId: 7,
11
11
  username: 'Wren',
12
12
  profileHref: '/member/7-wren',
13
- avatarUrl: '/avatar/7.png',
13
+ avatarUrl: '/placeholder-user.jpg',
14
14
  canAccessAdminCp: false,
15
15
  canAccessModCp: true,
16
16
  }
@@ -64,7 +64,7 @@ const THREAD = {
64
64
 
65
65
  const POST_AUTHOR = {
66
66
  ...AUTHOR,
67
- avatarUrl: '/avatar/12.png',
67
+ avatarUrl: '/placeholder-user.jpg',
68
68
  title: 'Registered',
69
69
  groups: [
70
70
  { title: 'Registered', nameClass: null },
@@ -105,8 +105,8 @@ const POST = {
105
105
  filename: 'plan.png',
106
106
  size: '1.4 MB',
107
107
  isImage: true,
108
- href: '/attachment/55/plan.png',
109
- thumbnailHref: '/attachment/55/thumb.png',
108
+ href: '/placeholder.jpg',
109
+ thumbnailHref: '/placeholder.jpg',
110
110
  width: 800,
111
111
  height: 600,
112
112
  },
@@ -136,7 +136,7 @@ export interface SlotFixture<K extends SlotName> {
136
136
  readonly requires: readonly string[]
137
137
  }
138
138
 
139
- export const SLOT_FIXTURES: { readonly [K in SlotName]?: SlotFixture<K> } = {
139
+ export const SLOT_FIXTURES: { readonly [K in SlotName]: SlotFixture<K> } = {
140
140
  Shell: {
141
141
  model: { boardTitle: 'The Bike Shed', viewer: GUEST, children: region('body') },
142
142
  requires: [region('body')],
@@ -472,7 +472,7 @@ export const SLOT_FIXTURES: { readonly [K in SlotName]?: SlotFixture<K> } = {
472
472
  MemberProfile: {
473
473
  model: {
474
474
  user: AUTHOR,
475
- avatarUrl: '/avatar/12.png',
475
+ avatarUrl: '/placeholder-user.jpg',
476
476
  title: 'Registered',
477
477
  groups: [
478
478
  { title: 'Registered', nameClass: null },