@meith/web 0.28.0 → 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.
- package/app/(board)/member/[id]/page.tsx +6 -1
- package/app/(board)/members/page.tsx +156 -0
- package/app/(board)/staff/page.tsx +80 -0
- package/package.json +48 -48
- package/src/components/admin/system-forms.tsx +1 -6
- package/src/server/group-identity.ts +19 -5
- package/src/server/member-directory.ts +16 -0
- package/src/server/system-admin-actions.ts +2 -5
- package/src/server/upgrade-notice.ts +2 -6
- package/src/theme/contract.fixture.ts +18 -1
- package/src/view/admin-panel-copy.ts +1 -1
- package/src/view/member-identity.ts +7 -0
- package/src/view/member-profile.ts +4 -1
- package/src/view/navigation.ts +2 -0
- package/src/view/thread-view.ts +4 -0
|
@@ -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:
|
|
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.
|
|
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.
|
|
47
|
-
"@meith/admin": "0.
|
|
48
|
-
"@meith/antispam": "0.
|
|
49
|
-
"@meith/api": "0.
|
|
50
|
-
"@meith/attachments": "0.
|
|
51
|
-
"@meith/authorization": "0.
|
|
52
|
-
"@meith/avatars": "0.
|
|
53
|
-
"@meith/core": "0.
|
|
54
|
-
"@meith/db": "0.
|
|
55
|
-
"@meith/demo": "0.
|
|
56
|
-
"@meith/drafts": "0.
|
|
57
|
-
"@meith/drivers": "0.
|
|
58
|
-
"@meith/events": "0.
|
|
59
|
-
"@meith/forums": "0.
|
|
60
|
-
"@meith/
|
|
61
|
-
"@meith/
|
|
62
|
-
"@meith/import": "0.
|
|
63
|
-
"@meith/install": "0.
|
|
64
|
-
"@meith/mail": "0.
|
|
65
|
-
"@meith/markdown": "0.
|
|
66
|
-
"@meith/marketplace": "0.
|
|
67
|
-
"@meith/messages": "0.
|
|
68
|
-
"@meith/moderation": "0.
|
|
69
|
-
"@meith/notifications": "0.
|
|
70
|
-
"@meith/plugin-calendar": "0.
|
|
71
|
-
"@meith/plugin-dues": "0.
|
|
72
|
-
"@meith/plugin-kit": "0.
|
|
73
|
-
"@meith/polls": "0.
|
|
74
|
-
"@meith/posts": "0.
|
|
75
|
-
"@meith/profile-fields": "0.
|
|
76
|
-
"@meith/relations": "0.
|
|
77
|
-
"@meith/reputation": "0.
|
|
78
|
-
"@meith/runtime": "0.
|
|
79
|
-
"@meith/search": "0.
|
|
80
|
-
"@meith/settings": "0.
|
|
81
|
-
"@meith/signatures": "0.
|
|
82
|
-
"@meith/subscriptions": "0.
|
|
83
|
-
"@meith/tasks": "0.
|
|
84
|
-
"@meith/theme-clubhouse": "0.
|
|
85
|
-
"@meith/theme-default": "0.
|
|
86
|
-
"@meith/theme-kit": "0.
|
|
87
|
-
"@meith/theme-midnight": "0.
|
|
88
|
-
"@meith/theme-phasebook": "0.
|
|
89
|
-
"@meith/theme-raidframe": "0.
|
|
90
|
-
"@meith/threads": "0.
|
|
91
|
-
"@meith/ui": "0.
|
|
92
|
-
"@meith/upgrade": "0.
|
|
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",
|
|
@@ -50,12 +50,7 @@ export function ApplyMigrationsForm({ copy }: { copy: Copy }) {
|
|
|
50
50
|
<form action={action} className="flex flex-col gap-2">
|
|
51
51
|
<FormError message={state.error} />
|
|
52
52
|
{state.notice === 'upgraded' && (
|
|
53
|
-
<Result>
|
|
54
|
-
{formatFromCopy(copy, 'adminPanel.system.upgradeApplied', {
|
|
55
|
-
core: state.values?.core ?? '0',
|
|
56
|
-
plugins: state.values?.plugins === '' ? '—' : (state.values?.plugins ?? '—'),
|
|
57
|
-
})}
|
|
58
|
-
</Result>
|
|
53
|
+
<Result>{fromCopy(copy, 'adminPanel.system.upgradeApplied')}</Result>
|
|
59
54
|
)}
|
|
60
55
|
<div>
|
|
61
56
|
<SubmitButton>{fromCopy(copy, 'adminPanel.system.applyMigrations')}</SubmitButton>
|
|
@@ -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([
|
|
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
|
+
}
|
|
@@ -100,13 +100,10 @@ export async function applyUpgradeAction(): Promise<FormState> {
|
|
|
100
100
|
revalidatePath('/admin')
|
|
101
101
|
await recordAdminAction({
|
|
102
102
|
action: 'system.upgrade_applied',
|
|
103
|
-
detail: {
|
|
103
|
+
detail: { plugins: result.plugins },
|
|
104
104
|
})
|
|
105
105
|
|
|
106
|
-
return {
|
|
107
|
-
notice: 'upgraded',
|
|
108
|
-
values: { core: String(result.coreMigrations), plugins: result.plugins.join(', ') },
|
|
109
|
-
}
|
|
106
|
+
return { notice: 'upgraded' }
|
|
110
107
|
} catch (err) {
|
|
111
108
|
return toFormState(err)
|
|
112
109
|
}
|
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
PostgresNavigationRepository,
|
|
9
9
|
readVersion,
|
|
10
10
|
recordVersion,
|
|
11
|
-
runMigrations,
|
|
12
11
|
} from '@meith/db'
|
|
13
12
|
import { msg } from '@meith/i18n'
|
|
14
13
|
import { pluginNavigationPlacements } from '@meith/plugin-kit'
|
|
@@ -17,10 +16,9 @@ import { planUpgrade, type UpgradeState, upgradeNotice } from '@meith/upgrade'
|
|
|
17
16
|
|
|
18
17
|
import { activeDefinitions } from './plugin-host'
|
|
19
18
|
|
|
20
|
-
export const CODE_VERSION = '0.
|
|
19
|
+
export const CODE_VERSION = '0.29.0'
|
|
21
20
|
|
|
22
21
|
export interface UpgradeApplied {
|
|
23
|
-
readonly coreMigrations: number
|
|
24
22
|
readonly plugins: readonly string[]
|
|
25
23
|
}
|
|
26
24
|
|
|
@@ -59,8 +57,6 @@ export async function applyPendingUpgrade(): Promise<UpgradeApplied> {
|
|
|
59
57
|
if ((await readVersion(db, `plugin:${plugin.key}`)) === null) fresh.add(plugin.key)
|
|
60
58
|
}
|
|
61
59
|
|
|
62
|
-
const coreMigrations = await runMigrations()
|
|
63
|
-
|
|
64
60
|
const touched: string[] = []
|
|
65
61
|
for (const definition of definitions) {
|
|
66
62
|
let changed = false
|
|
@@ -87,7 +83,7 @@ export async function applyPendingUpgrade(): Promise<UpgradeApplied> {
|
|
|
87
83
|
|
|
88
84
|
await recordVersion(db, 'core', CODE_VERSION)
|
|
89
85
|
|
|
90
|
-
return {
|
|
86
|
+
return { plugins: touched }
|
|
91
87
|
}
|
|
92
88
|
|
|
93
89
|
export async function pendingUpgradeNotice(): Promise<string | null> {
|
|
@@ -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: [
|
|
457
|
+
requires: [
|
|
458
|
+
'Marlow',
|
|
459
|
+
'318',
|
|
460
|
+
'Bristol',
|
|
461
|
+
'Registered',
|
|
462
|
+
'Supporters',
|
|
463
|
+
pluginRegion('profile.panel'),
|
|
464
|
+
],
|
|
448
465
|
},
|
|
449
466
|
|
|
450
467
|
SearchForm: {
|
|
@@ -61,6 +61,7 @@ export function systemFormsCopy(t: Translator = untranslated()): Readonly<Record
|
|
|
61
61
|
'adminPanel.system.retry',
|
|
62
62
|
'adminPanel.system.nothingToIndex',
|
|
63
63
|
'adminPanel.system.applyMigrations',
|
|
64
|
+
'adminPanel.system.upgradeApplied',
|
|
64
65
|
],
|
|
65
66
|
t,
|
|
66
67
|
),
|
|
@@ -74,7 +75,6 @@ export function systemFormsCopy(t: Translator = untranslated()): Readonly<Record
|
|
|
74
75
|
'adminPanel.system.indexFinished',
|
|
75
76
|
'adminPanel.system.indexMore',
|
|
76
77
|
'adminPanel.system.indexNext',
|
|
77
|
-
'adminPanel.system.upgradeApplied',
|
|
78
78
|
],
|
|
79
79
|
t,
|
|
80
80
|
),
|
|
@@ -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),
|
package/src/view/navigation.ts
CHANGED
|
@@ -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'
|
package/src/view/thread-view.ts
CHANGED
|
@@ -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),
|