@meith/web 0.28.1 → 0.29.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.
@@ -63,13 +63,18 @@ export default async function MemberProfilePage({ params }: { params: Promise<{
63
63
 
64
64
  const MemberProfile = requireSlot(await currentTheme(), 'MemberProfile')
65
65
  const translator = await getTranslator()
66
+ const identity = (await identitiesFor([id])).get(id)
66
67
 
67
68
  const profileModel = await filterView(
68
69
  'view.member-profile',
69
70
  {
70
71
  ...buildMemberProfileView(profile, new Date(), {
71
72
  avatarUrl: (await avatarsFor([id])).get(id) ?? null,
72
- nameClass: (await identitiesFor([id])).get(id)?.nameClass ?? null,
73
+ nameClass: identity?.nameClass ?? null,
74
+ groups:
75
+ identity === undefined
76
+ ? []
77
+ : identity.groups.map((group) => ({ title: group.title, nameClass: group.nameClass })),
73
78
  canWarn: warnings !== null && actor.userId !== id && authorizer.can(actor, 'user.warn'),
74
79
  canMessage:
75
80
  messages !== null &&
@@ -0,0 +1,156 @@
1
+ import type { Metadata } from 'next'
2
+ import { notFound } from 'next/navigation'
3
+
4
+ import { Card, CardRows, Empty, EmptyDescription, EmptyTitle } from '@meith/ui'
5
+
6
+ import { PanelPage } from '@/components/shell/panel-page'
7
+ import { PanelPagination } from '@/components/shell/panel-pagination'
8
+ import { getContainer } from '@/server/container'
9
+ import { getActor } from '@/server/context'
10
+ import { identitiesFor } from '@/server/group-identity'
11
+ import { getTranslator, tr } from '@/server/i18n'
12
+ import {
13
+ MEMBER_DIRECTORY_PAGE,
14
+ memberDirectoryRepository,
15
+ parseMemberDirectorySort,
16
+ } from '@/server/member-directory'
17
+ import { offsetOf, readPage } from '@/view/pager'
18
+ import { formatDate } from '@/view/time'
19
+
20
+ export async function generateMetadata(): Promise<Metadata> {
21
+ return { title: await tr('page.members') }
22
+ }
23
+
24
+ const INPUT =
25
+ 'rounded-md border border-border bg-background px-3 py-2 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
26
+
27
+ export default async function MembersPage({
28
+ searchParams,
29
+ }: {
30
+ searchParams: Promise<Record<string, string | string[] | undefined>>
31
+ }) {
32
+ const actor = await getActor()
33
+ const { authorizer } = getContainer()
34
+ if (!authorizer.can(actor, 'memberlist.view')) notFound()
35
+
36
+ const translator = await getTranslator()
37
+ const repository = memberDirectoryRepository()
38
+
39
+ if (repository === null) {
40
+ return (
41
+ <PanelPage frame="standalone" title={await tr('page.members')}>
42
+ <Card>
43
+ <Empty>
44
+ <EmptyTitle>{translator.t('board.memberlist.empty')}</EmptyTitle>
45
+ <EmptyDescription>{translator.t('board.memberlist.needsDatabase')}</EmptyDescription>
46
+ </Empty>
47
+ </Card>
48
+ </PanelPage>
49
+ )
50
+ }
51
+
52
+ const params = await searchParams
53
+ const one = (key: string): string => {
54
+ const raw = params[key]
55
+ const text = Array.isArray(raw) ? raw[0] : raw
56
+ return text ?? ''
57
+ }
58
+
59
+ const page = readPage(params)
60
+ const sort = parseMemberDirectorySort(one('sort'))
61
+ const name = one('name')
62
+
63
+ const result = await repository.page({
64
+ offset: offsetOf(page, MEMBER_DIRECTORY_PAGE),
65
+ limit: MEMBER_DIRECTORY_PAGE,
66
+ sort,
67
+ nameContains: name,
68
+ })
69
+ const identities = await identitiesFor(result.rows.map((row) => row.id))
70
+
71
+ return (
72
+ <PanelPage
73
+ frame="standalone"
74
+ title={await tr('page.members')}
75
+ lede={translator.t('board.memberlist.lede', { count: result.total })}
76
+ >
77
+ <form method="get" className="flex flex-wrap items-end gap-3">
78
+ <label className="flex flex-col gap-1 text-sm">
79
+ <span className="font-medium">{translator.t('board.memberlist.search')}</span>
80
+ <input name="name" defaultValue={name} className={INPUT} />
81
+ </label>
82
+
83
+ <label className="flex flex-col gap-1 text-sm">
84
+ <span className="font-medium">{translator.t('board.memberlist.sort')}</span>
85
+ <select name="sort" defaultValue={sort} className={INPUT}>
86
+ <option value="name">{translator.t('board.memberlist.sortName')}</option>
87
+ <option value="posts">{translator.t('board.memberlist.sortPosts')}</option>
88
+ <option value="joined">{translator.t('board.memberlist.sortJoined')}</option>
89
+ </select>
90
+ </label>
91
+
92
+ <button
93
+ type="submit"
94
+ className="inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:opacity-90"
95
+ >
96
+ {translator.t('board.memberlist.apply')}
97
+ </button>
98
+ </form>
99
+
100
+ <Card>
101
+ {result.rows.length === 0 ? (
102
+ <Empty>
103
+ <EmptyTitle>{translator.t('board.memberlist.empty')}</EmptyTitle>
104
+ </Empty>
105
+ ) : (
106
+ <CardRows>
107
+ {result.rows.map((row) => {
108
+ const identity = identities.get(row.id)
109
+ return (
110
+ <li
111
+ key={row.id}
112
+ className="flex flex-col gap-1 px-4 py-3 sm:flex-row sm:items-baseline sm:justify-between sm:gap-4"
113
+ >
114
+ <span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5 text-sm">
115
+ <a
116
+ href={`/member/${row.id}`}
117
+ className={`font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground ${
118
+ identity?.nameClass ?? ''
119
+ }`}
120
+ >
121
+ {row.username}
122
+ </a>
123
+ {identity?.groups.map((group) => (
124
+ <span
125
+ key={group.groupId}
126
+ className={`text-xs text-muted-foreground ${group.nameClass ?? ''}`}
127
+ >
128
+ {group.title}
129
+ </span>
130
+ ))}
131
+ </span>
132
+
133
+ <span className="shrink-0 text-xs text-muted-foreground">
134
+ {translator.t('board.memberlist.posts', { count: row.postCount })}
135
+ {' · '}
136
+ {translator.t('board.memberlist.joined', {
137
+ time: formatDate(row.createdAt, translator).label,
138
+ })}
139
+ </span>
140
+ </li>
141
+ )
142
+ })}
143
+ </CardRows>
144
+ )}
145
+ </Card>
146
+
147
+ <PanelPagination
148
+ path="/members"
149
+ params={params}
150
+ page={page}
151
+ pageSize={MEMBER_DIRECTORY_PAGE}
152
+ total={result.total}
153
+ />
154
+ </PanelPage>
155
+ )
156
+ }
@@ -0,0 +1,80 @@
1
+ import type { Metadata } from 'next'
2
+
3
+ import {
4
+ Card,
5
+ CardHeader,
6
+ CardRows,
7
+ CardTitle,
8
+ Empty,
9
+ EmptyDescription,
10
+ EmptyTitle,
11
+ } from '@meith/ui'
12
+
13
+ import { PanelPage } from '@/components/shell/panel-page'
14
+ import { identitiesFor } from '@/server/group-identity'
15
+ import { getTranslator, tr } from '@/server/i18n'
16
+ import { memberDirectoryRepository } from '@/server/member-directory'
17
+
18
+ export async function generateMetadata(): Promise<Metadata> {
19
+ return { title: await tr('page.staff') }
20
+ }
21
+
22
+ export default async function StaffPage() {
23
+ const translator = await getTranslator()
24
+ const repository = memberDirectoryRepository()
25
+ const staff = repository === null ? [] : await repository.staff()
26
+
27
+ if (staff.length === 0) {
28
+ return (
29
+ <PanelPage frame="standalone" title={await tr('page.staff')}>
30
+ <Card>
31
+ <Empty>
32
+ <EmptyTitle>{translator.t('board.staff.empty')}</EmptyTitle>
33
+ <EmptyDescription>
34
+ {translator.t(
35
+ repository === null ? 'board.memberlist.needsDatabase' : 'board.staff.emptyHint',
36
+ )}
37
+ </EmptyDescription>
38
+ </Empty>
39
+ </Card>
40
+ </PanelPage>
41
+ )
42
+ }
43
+
44
+ const identities = await identitiesFor(
45
+ staff.flatMap((group) => group.members.map((member) => member.id)),
46
+ )
47
+
48
+ return (
49
+ <PanelPage
50
+ frame="standalone"
51
+ title={await tr('page.staff')}
52
+ lede={translator.t('board.staff.lede')}
53
+ >
54
+ {staff.map((group) => (
55
+ <Card key={group.groupId} aria-label={group.title}>
56
+ <CardHeader>
57
+ <CardTitle className="text-sm">{group.title}</CardTitle>
58
+ {group.description !== null && (
59
+ <p className="text-xs text-muted-foreground">{group.description}</p>
60
+ )}
61
+ </CardHeader>
62
+ <CardRows>
63
+ {group.members.map((member) => (
64
+ <li key={member.id} className="px-4 py-3 text-sm">
65
+ <a
66
+ href={`/member/${member.id}`}
67
+ className={`font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground ${
68
+ identities.get(member.id)?.nameClass ?? ''
69
+ }`}
70
+ >
71
+ {member.username}
72
+ </a>
73
+ </li>
74
+ ))}
75
+ </CardRows>
76
+ </Card>
77
+ ))}
78
+ </PanelPage>
79
+ )
80
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/web",
3
- "version": "0.28.1",
3
+ "version": "0.29.0",
4
4
  "description": "The board itself: the Next.js app, and the forum-web bin that materializes it into an external board workspace.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -43,53 +43,53 @@
43
43
  "react-dom": "19.2.8",
44
44
  "tailwindcss": "^4.3.3",
45
45
  "typescript": "7.0.2",
46
- "@meith/accounts": "0.28.1",
47
- "@meith/admin": "0.28.1",
48
- "@meith/antispam": "0.28.1",
49
- "@meith/api": "0.28.1",
50
- "@meith/attachments": "0.28.1",
51
- "@meith/authorization": "0.28.1",
52
- "@meith/avatars": "0.28.1",
53
- "@meith/core": "0.28.1",
54
- "@meith/db": "0.28.1",
55
- "@meith/demo": "0.28.1",
56
- "@meith/drafts": "0.28.1",
57
- "@meith/drivers": "0.28.1",
58
- "@meith/events": "0.28.1",
59
- "@meith/forums": "0.28.1",
60
- "@meith/groups": "0.28.1",
61
- "@meith/i18n": "0.28.1",
62
- "@meith/import": "0.28.1",
63
- "@meith/install": "0.28.1",
64
- "@meith/mail": "0.28.1",
65
- "@meith/markdown": "0.28.1",
66
- "@meith/marketplace": "0.28.1",
67
- "@meith/messages": "0.28.1",
68
- "@meith/moderation": "0.28.1",
69
- "@meith/notifications": "0.28.1",
70
- "@meith/plugin-calendar": "0.28.1",
71
- "@meith/plugin-dues": "0.28.1",
72
- "@meith/plugin-kit": "0.28.1",
73
- "@meith/polls": "0.28.1",
74
- "@meith/posts": "0.28.1",
75
- "@meith/profile-fields": "0.28.1",
76
- "@meith/relations": "0.28.1",
77
- "@meith/reputation": "0.28.1",
78
- "@meith/runtime": "0.28.1",
79
- "@meith/search": "0.28.1",
80
- "@meith/settings": "0.28.1",
81
- "@meith/signatures": "0.28.1",
82
- "@meith/subscriptions": "0.28.1",
83
- "@meith/tasks": "0.28.1",
84
- "@meith/theme-clubhouse": "0.28.1",
85
- "@meith/theme-default": "0.28.1",
86
- "@meith/theme-kit": "0.28.1",
87
- "@meith/theme-midnight": "0.28.1",
88
- "@meith/theme-phasebook": "0.28.1",
89
- "@meith/theme-raidframe": "0.28.1",
90
- "@meith/threads": "0.28.1",
91
- "@meith/ui": "0.28.1",
92
- "@meith/upgrade": "0.28.1"
46
+ "@meith/accounts": "0.29.0",
47
+ "@meith/admin": "0.29.0",
48
+ "@meith/antispam": "0.29.0",
49
+ "@meith/api": "0.29.0",
50
+ "@meith/attachments": "0.29.0",
51
+ "@meith/authorization": "0.29.0",
52
+ "@meith/avatars": "0.29.0",
53
+ "@meith/core": "0.29.0",
54
+ "@meith/db": "0.29.0",
55
+ "@meith/demo": "0.29.0",
56
+ "@meith/drafts": "0.29.0",
57
+ "@meith/drivers": "0.29.0",
58
+ "@meith/events": "0.29.0",
59
+ "@meith/forums": "0.29.0",
60
+ "@meith/groups": "0.29.0",
61
+ "@meith/i18n": "0.29.0",
62
+ "@meith/import": "0.29.0",
63
+ "@meith/install": "0.29.0",
64
+ "@meith/mail": "0.29.0",
65
+ "@meith/markdown": "0.29.0",
66
+ "@meith/marketplace": "0.29.0",
67
+ "@meith/messages": "0.29.0",
68
+ "@meith/moderation": "0.29.0",
69
+ "@meith/notifications": "0.29.0",
70
+ "@meith/plugin-calendar": "0.29.0",
71
+ "@meith/plugin-dues": "0.29.0",
72
+ "@meith/plugin-kit": "0.29.0",
73
+ "@meith/polls": "0.29.0",
74
+ "@meith/posts": "0.29.0",
75
+ "@meith/profile-fields": "0.29.0",
76
+ "@meith/relations": "0.29.0",
77
+ "@meith/reputation": "0.29.0",
78
+ "@meith/runtime": "0.29.0",
79
+ "@meith/search": "0.29.0",
80
+ "@meith/settings": "0.29.0",
81
+ "@meith/signatures": "0.29.0",
82
+ "@meith/subscriptions": "0.29.0",
83
+ "@meith/tasks": "0.29.0",
84
+ "@meith/theme-clubhouse": "0.29.0",
85
+ "@meith/theme-default": "0.29.0",
86
+ "@meith/theme-kit": "0.29.0",
87
+ "@meith/theme-midnight": "0.29.0",
88
+ "@meith/theme-phasebook": "0.29.0",
89
+ "@meith/theme-raidframe": "0.29.0",
90
+ "@meith/threads": "0.29.0",
91
+ "@meith/ui": "0.29.0",
92
+ "@meith/upgrade": "0.29.0"
93
93
  },
94
94
  "scripts": {
95
95
  "dev": "next dev",
@@ -15,6 +15,7 @@ import type { MemberIdentity } from '@/view/member-identity'
15
15
 
16
16
  import { getContainer } from './container'
17
17
  import { badgeSrc } from './group-badge'
18
+ import { getSettings } from './settings'
18
19
  import { currentColourScheme } from './theme'
19
20
  import { groupNameClass, renderGroupNameStyle } from './theme-style'
20
21
 
@@ -78,13 +79,24 @@ const load = cache(async (key: string): Promise<ReadonlyMap<number, MemberStandi
78
79
  return repo.forUsers(key.split(',').map(Number)).catch(() => new Map<number, MemberStanding>())
79
80
  })
80
81
 
82
+ function nameClassFor(group: GroupIdentity): string | null {
83
+ return group.nameColorLight === null && group.nameColorDark === null
84
+ ? null
85
+ : groupNameClass(group.groupId)
86
+ }
87
+
81
88
  export async function identitiesFor(
82
89
  userIds: readonly number[],
83
90
  ): Promise<ReadonlyMap<number, MemberIdentity>> {
84
91
  const ids = [...new Set(userIds)].sort((a, b) => a - b)
85
92
  if (ids.length === 0) return new Map()
86
93
 
87
- const [rows, scheme] = await Promise.all([load(ids.join(',')), currentColourScheme()])
94
+ const [rows, scheme, settings] = await Promise.all([
95
+ load(ids.join(',')),
96
+ currentColourScheme(),
97
+ getSettings(),
98
+ ])
99
+ const maxShown = settings.get('display.max_displayed_groups')
88
100
 
89
101
  return new Map(
90
102
  [...rows].map(([userId, group]) => [
@@ -92,12 +104,14 @@ export async function identitiesFor(
92
104
  {
93
105
  groupId: group.groupId,
94
106
  title: group.title,
95
- nameClass:
96
- group.nameColorLight === null && group.nameColorDark === null
97
- ? null
98
- : groupNameClass(group.groupId),
107
+ nameClass: nameClassFor(group),
99
108
  badge: resolveBadge(group, scheme),
100
109
  reputation: group.reputation,
110
+ groups: group.groups.slice(0, maxShown).map((held) => ({
111
+ groupId: held.groupId,
112
+ title: held.title,
113
+ nameClass: nameClassFor(held),
114
+ })),
101
115
  },
102
116
  ]),
103
117
  )
@@ -0,0 +1,16 @@
1
+ import 'server-only'
2
+
3
+ import { env } from '@meith/core'
4
+ import { getDb, type MemberDirectorySort, PostgresMemberDirectoryRepository } from '@meith/db'
5
+
6
+ export const MEMBER_DIRECTORY_PAGE = 50
7
+
8
+ export function memberDirectoryRepository(): PostgresMemberDirectoryRepository | null {
9
+ return env.DATA_SOURCE === 'postgres' ? new PostgresMemberDirectoryRepository(getDb()) : null
10
+ }
11
+
12
+ const SORTS: readonly MemberDirectorySort[] = ['name', 'posts', 'joined']
13
+
14
+ export function parseMemberDirectorySort(value: string | undefined): MemberDirectorySort {
15
+ return SORTS.find((sort) => sort === value) ?? 'name'
16
+ }
@@ -16,7 +16,7 @@ import { planUpgrade, type UpgradeState, upgradeNotice } from '@meith/upgrade'
16
16
 
17
17
  import { activeDefinitions } from './plugin-host'
18
18
 
19
- export const CODE_VERSION = '0.28.1'
19
+ export const CODE_VERSION = '0.29.0'
20
20
 
21
21
  export interface UpgradeApplied {
22
22
  readonly plugins: readonly string[]
@@ -66,6 +66,10 @@ const POST_AUTHOR = {
66
66
  ...AUTHOR,
67
67
  avatarUrl: '/avatar/12.png',
68
68
  title: 'Registered',
69
+ groups: [
70
+ { title: 'Registered', nameClass: null },
71
+ { title: 'Supporters', nameClass: 'gname-9' },
72
+ ],
69
73
  postCount: COUNT(318),
70
74
  joinedAt: OLDER,
71
75
  signatureHtml: '<p>Sent from a rotary telephone</p>',
@@ -368,6 +372,8 @@ export const SLOT_FIXTURES: { readonly [K in SlotName]?: SlotFixture<K> } = {
368
372
  requires: [
369
373
  'The shed should be teak.',
370
374
  'Marlow',
375
+ 'Registered',
376
+ 'Supporters',
371
377
  '#post-12',
372
378
  region('post-actions'),
373
379
  pluginRegion('postbit.badges'),
@@ -436,6 +442,10 @@ export const SLOT_FIXTURES: { readonly [K in SlotName]?: SlotFixture<K> } = {
436
442
  user: AUTHOR,
437
443
  avatarUrl: '/avatar/12.png',
438
444
  title: 'Registered',
445
+ groups: [
446
+ { title: 'Registered', nameClass: null },
447
+ { title: 'Supporters', nameClass: 'gname-9' },
448
+ ],
439
449
  joinedAt: OLDER,
440
450
  lastVisitAt: TIME,
441
451
  postCount: COUNT(318),
@@ -444,7 +454,14 @@ export const SLOT_FIXTURES: { readonly [K in SlotName]?: SlotFixture<K> } = {
444
454
  actions: [{ label: 'Send a message', href: '/messages/new?to=12' }],
445
455
  regions: { plugins: pluginRegion('profile.panel') },
446
456
  },
447
- requires: ['Marlow', '318', 'Bristol', pluginRegion('profile.panel')],
457
+ requires: [
458
+ 'Marlow',
459
+ '318',
460
+ 'Bristol',
461
+ 'Registered',
462
+ 'Supporters',
463
+ pluginRegion('profile.panel'),
464
+ ],
448
465
  },
449
466
 
450
467
  SearchForm: {
@@ -1,3 +1,9 @@
1
+ export interface MemberGroupTag {
2
+ readonly groupId: number
3
+ readonly title: string
4
+ readonly nameClass: string | null
5
+ }
6
+
1
7
  export interface MemberIdentity {
2
8
  readonly groupId: number
3
9
  readonly title: string
@@ -8,6 +14,7 @@ export interface MemberIdentity {
8
14
  readonly alt: string
9
15
  } | null
10
16
  readonly reputation: number
17
+ readonly groups: readonly MemberGroupTag[]
11
18
  }
12
19
 
13
20
  export function nameClassOf(
@@ -1,6 +1,6 @@
1
1
  import type { MemberProfileRecord } from '@meith/accounts'
2
2
  import type { Translator } from '@meith/i18n'
3
- import type { MemberProfileModel } from '@meith/theme-kit'
3
+ import type { GroupTagModel, MemberProfileModel } from '@meith/theme-kit'
4
4
 
5
5
  import { count } from './count'
6
6
  import { formatDate, formatTime, untranslated } from './time'
@@ -19,6 +19,7 @@ export function buildMemberProfileView(
19
19
  readonly canMessage?: boolean
20
20
  readonly avatarUrl?: string | null
21
21
  readonly nameClass?: string | null
22
+ readonly groups?: readonly GroupTagModel[]
22
23
  } = {},
23
24
  ): MemberProfileModel {
24
25
  const {
@@ -28,6 +29,7 @@ export function buildMemberProfileView(
28
29
  customFields = [],
29
30
  avatarUrl = null,
30
31
  nameClass = null,
32
+ groups = [],
31
33
  } = options
32
34
  return {
33
35
  user: {
@@ -38,6 +40,7 @@ export function buildMemberProfileView(
38
40
  },
39
41
  avatarUrl,
40
42
  title: profile.title,
43
+ groups,
41
44
  joinedAt: formatDate(profile.createdAt, t),
42
45
  lastVisitAt: profile.lastActiveAt === null ? null : formatTime(profile.lastActiveAt, now, t),
43
46
  postCount: count(profile.postCount, t),
@@ -41,6 +41,8 @@ export const BUILT_IN_NAVIGATION: readonly BuiltInNavigationItem[] = [
41
41
  },
42
42
  { key: 'search', messageKey: 'nav.search', href: '/search', audience: 'all' },
43
43
  { key: 'online', messageKey: 'nav.online', href: '/online', audience: 'all' },
44
+ { key: 'members', messageKey: 'nav.members', href: '/members', audience: 'all' },
45
+ { key: 'staff', messageKey: 'nav.staff', href: '/staff', audience: 'all' },
44
46
  ]
45
47
 
46
48
  const SEARCH_KEY = 'search'
@@ -112,6 +112,10 @@ function post(post: PostListingRow, thread: ThreadListingRow, context: PostConte
112
112
  ? null
113
113
  : (context.avatars.get(post.authorUserId) ?? null),
114
114
  title: identity?.title ?? null,
115
+ groups:
116
+ identity === undefined
117
+ ? []
118
+ : identity.groups.map((group) => ({ title: group.title, nameClass: group.nameClass })),
115
119
  nameClass: identity?.nameClass ?? null,
116
120
  badge: identity?.badge ?? null,
117
121
  reputation: identity?.reputation === undefined ? null : count(identity.reputation, t),