@meith/web 0.30.1 → 0.32.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.
Files changed (165) hide show
  1. package/app/(board)/[slug]/feed.xml/route.ts +5 -3
  2. package/app/(board)/[slug]/new/page.tsx +5 -0
  3. package/app/(board)/[slug]/page.tsx +7 -1
  4. package/app/(board)/discover/[view]/page.tsx +6 -1
  5. package/app/(board)/member/[id]/page.tsx +3 -5
  6. package/app/(board)/member/[id]/reputation/page.tsx +8 -15
  7. package/app/(board)/members/page.tsx +3 -8
  8. package/app/(board)/messages/[id]/page.tsx +3 -7
  9. package/app/(board)/messages/page.tsx +3 -5
  10. package/app/(board)/modcp/ip/page.tsx +3 -5
  11. package/app/(board)/modcp/plugins/[key]/[[...path]]/page.tsx +84 -0
  12. package/app/(board)/moderation/reports/page.tsx +53 -15
  13. package/app/(board)/moderation/warn/page.tsx +3 -6
  14. package/app/(board)/notifications/page.tsx +5 -10
  15. package/app/(board)/notifications/preferences/page.tsx +8 -0
  16. package/app/(board)/online/page.tsx +12 -6
  17. package/app/(board)/staff/page.tsx +4 -5
  18. package/app/(board)/stats/page.tsx +10 -3
  19. package/app/(board)/subscriptions/page.tsx +5 -10
  20. package/app/(board)/thread/[slug]/edit/page.tsx +21 -0
  21. package/app/(board)/thread/[slug]/feed.xml/route.ts +6 -3
  22. package/app/(board)/thread/[slug]/page.tsx +49 -11
  23. package/app/(board)/thread/[slug]/reply/page.tsx +5 -0
  24. package/app/(board)/unsubscribe/page.tsx +8 -11
  25. package/app/(board)/usercp/contacts/page.tsx +3 -5
  26. package/app/(board)/usercp/drafts/page.tsx +70 -0
  27. package/app/(board)/usercp/options/page.tsx +8 -1
  28. package/app/(board)/usercp/security/page.tsx +16 -0
  29. package/app/admin/antispam/page.tsx +4 -12
  30. package/app/admin/content/attachments/page.tsx +3 -6
  31. package/app/admin/content/page.tsx +3 -13
  32. package/app/admin/forums/[id]/permissions/page.tsx +9 -12
  33. package/app/admin/groups/page.tsx +7 -9
  34. package/app/admin/page.tsx +12 -6
  35. package/app/admin/plugins/[key]/[[...path]]/page.tsx +6 -7
  36. package/app/admin/plugins/page.tsx +3 -6
  37. package/app/admin/users/[id]/page.tsx +5 -11
  38. package/app/admin/users/prune/page.tsx +4 -12
  39. package/app/admin/webhooks/page.tsx +144 -0
  40. package/app/api/subscribe/thread/[id]/route.ts +53 -0
  41. package/app/api/unsubscribe/thread/[id]/route.ts +42 -0
  42. package/app/atom.xml/route.ts +2 -2
  43. package/app/auth/sso/[provider]/callback/route.ts +10 -0
  44. package/app/feed.xml/route.ts +2 -2
  45. package/app/offline/route.ts +98 -0
  46. package/next.config.mjs +1 -0
  47. package/package.json +49 -48
  48. package/public/sw.js +42 -2
  49. package/src/components/account/draft-forms.tsx +39 -0
  50. package/src/components/account/feed-token.tsx +95 -0
  51. package/src/components/account/notification-forms.tsx +37 -2
  52. package/src/components/account/relation-forms.tsx +3 -2
  53. package/src/components/account/subscription-forms.tsx +20 -6
  54. package/src/components/account/usercp-forms.tsx +44 -0
  55. package/src/components/admin/api-token-forms.tsx +11 -7
  56. package/src/components/admin/ban-filter-forms.tsx +15 -11
  57. package/src/components/admin/content-forms.tsx +31 -14
  58. package/src/components/admin/forum-forms.tsx +241 -129
  59. package/src/components/admin/forum-tree.tsx +5 -7
  60. package/src/components/admin/navigation-forms.tsx +43 -26
  61. package/src/components/admin/navigation-tree.tsx +8 -13
  62. package/src/components/admin/oklch-picker.tsx +2 -1
  63. package/src/components/admin/theme-changes.tsx +3 -2
  64. package/src/components/admin/theme-forms.tsx +3 -2
  65. package/src/components/admin/webhook-forms.tsx +137 -0
  66. package/src/components/auth/register-form.tsx +4 -7
  67. package/src/components/board/live-region.tsx +3 -1
  68. package/src/components/content/attachment-field.tsx +93 -22
  69. package/src/components/content/combobox-keys.ts +38 -0
  70. package/src/components/content/edit-post-form.tsx +75 -1
  71. package/src/components/content/markdown-editor.tsx +29 -86
  72. package/src/components/content/mention-combobox.tsx +137 -0
  73. package/src/components/content/multiquote-button.tsx +15 -12
  74. package/src/components/content/new-thread-form.tsx +95 -17
  75. package/src/components/content/poll-edit-form.tsx +61 -39
  76. package/src/components/content/poll-vote-form.tsx +91 -0
  77. package/src/components/content/poll.tsx +12 -48
  78. package/src/components/content/progressive-marker.tsx +13 -0
  79. package/src/components/content/recipient-segment.ts +39 -0
  80. package/src/components/content/reply-form.tsx +9 -1
  81. package/src/components/content/thanks-button.tsx +12 -7
  82. package/src/components/messages/message-forms.tsx +53 -43
  83. package/src/components/messages/recipient-field.tsx +88 -0
  84. package/src/components/moderation/inline-moderation-form.tsx +2 -0
  85. package/src/components/moderation/modcp-shell.tsx +17 -3
  86. package/src/components/moderation/queue-form.tsx +3 -6
  87. package/src/components/moderation/report-forms.tsx +24 -5
  88. package/src/components/moderation/thread-tools-form.tsx +2 -0
  89. package/src/components/shell/board-notice.tsx +22 -1
  90. package/src/components/shell/confirm-dialog.tsx +93 -0
  91. package/src/components/shell/nav-disclosure-enhancer.tsx +36 -0
  92. package/src/components/shell/notice-toast.tsx +40 -0
  93. package/src/components/shell/notification-menu.tsx +5 -3
  94. package/src/components/shell/page-shell.tsx +3 -0
  95. package/src/components/shell/scheme-toggle.tsx +72 -0
  96. package/src/components/shell/theme-switcher.tsx +16 -36
  97. package/src/server/admin-settings-actions.ts +5 -0
  98. package/src/server/api/search.ts +8 -2
  99. package/src/server/api-token-actions.ts +4 -0
  100. package/src/server/appearance-actions.ts +21 -0
  101. package/src/server/attachments.ts +35 -0
  102. package/src/server/auth-actions.ts +2 -0
  103. package/src/server/auth-form-state.ts +28 -0
  104. package/src/server/auto-watch.ts +31 -0
  105. package/src/server/ban-filter-admin-actions.ts +5 -0
  106. package/src/server/confirm.ts +19 -0
  107. package/src/server/content-actions.ts +129 -19
  108. package/src/server/content-admin-actions.ts +31 -0
  109. package/src/server/discovery.ts +11 -1
  110. package/src/server/feed-routes.ts +13 -4
  111. package/src/server/feed-token-actions.ts +61 -0
  112. package/src/server/feed-token.ts +78 -0
  113. package/src/server/fixture-post-repo.ts +18 -0
  114. package/src/server/forum-admin-actions.ts +23 -16
  115. package/src/server/forum-admin.ts +10 -1
  116. package/src/server/inline-moderation-actions.ts +6 -0
  117. package/src/server/message-actions.ts +9 -0
  118. package/src/server/navigation-admin-actions.ts +6 -0
  119. package/src/server/plugin-admin.ts +7 -2
  120. package/src/server/plugin-pages.ts +50 -0
  121. package/src/server/plugin-panel.ts +34 -1
  122. package/src/server/plugin-routes.ts +8 -0
  123. package/src/server/plugin-view.tsx +46 -7
  124. package/src/server/poll-actions.ts +13 -1
  125. package/src/server/presence.ts +23 -0
  126. package/src/server/reply-core.ts +8 -1
  127. package/src/server/report-actions.ts +13 -2
  128. package/src/server/reputation-actions.ts +12 -1
  129. package/src/server/search-page.ts +8 -2
  130. package/src/server/search.ts +18 -5
  131. package/src/server/subscription-actions.ts +9 -0
  132. package/src/server/thread-core.ts +10 -2
  133. package/src/server/thread-tool-actions.ts +6 -0
  134. package/src/server/unread-goto.ts +28 -0
  135. package/src/server/upgrade-notice.ts +1 -1
  136. package/src/server/usercp-actions.ts +31 -1
  137. package/src/server/viewer-preferences.ts +5 -0
  138. package/src/server/webhook-actions.ts +98 -0
  139. package/src/server/webhooks-admin.ts +122 -0
  140. package/src/theme/contract.fixture.ts +36 -5
  141. package/src/view/account-copy.ts +39 -0
  142. package/src/view/admin-copy.ts +4 -9
  143. package/src/view/admin-nav.ts +6 -0
  144. package/src/view/admin-panel-copy.ts +23 -0
  145. package/src/view/attachments.ts +33 -1
  146. package/src/view/board-index.ts +31 -2
  147. package/src/view/board-title.ts +1 -0
  148. package/src/view/content-copy.ts +22 -12
  149. package/src/view/copy.ts +17 -0
  150. package/src/view/discovery-view.ts +2 -1
  151. package/src/view/drafts.ts +45 -0
  152. package/src/view/editor-toolbar.ts +14 -0
  153. package/src/view/forum-display.ts +39 -7
  154. package/src/view/modcp-nav.ts +32 -0
  155. package/src/view/moderation-copy.ts +20 -0
  156. package/src/view/notifications.ts +1 -0
  157. package/src/view/plugin-panel.ts +13 -0
  158. package/src/view/poll-vote.ts +46 -0
  159. package/src/view/post-link.ts +8 -1
  160. package/src/view/progressive-enhancement.ts +5 -0
  161. package/src/view/shell.ts +2 -1
  162. package/src/view/subscriptions.ts +1 -0
  163. package/src/view/thread-view.ts +41 -2
  164. package/src/view/usercp-nav.ts +6 -0
  165. package/src/view/usercp.ts +14 -0
@@ -4,13 +4,14 @@ import { getContainer } from '@/server/container'
4
4
  import { activeWordFilter } from '@/server/content-admin'
5
5
  import { feedFor } from '@/server/feed-builder'
6
6
  import { feedResponse, noFeed, offlineFeed } from '@/server/feed-routes'
7
- import { FEED_LIMIT, feedRepository, origin, publicScope } from '@/server/syndication'
7
+ import { feedScopeForRequest } from '@/server/feed-token'
8
+ import { FEED_LIMIT, feedRepository, origin } from '@/server/syndication'
8
9
  import { leadingId } from '@/view/slug-id'
9
10
 
10
11
  export const dynamic = 'force-dynamic'
11
12
 
12
13
  export async function GET(
13
- _request: Request,
14
+ request: Request,
14
15
  { params }: { params: Promise<{ slug: string }> },
15
16
  ): Promise<Response> {
16
17
  const offline = await offlineFeed()
@@ -23,7 +24,7 @@ export async function GET(
23
24
  const id = leadingId(slug)
24
25
  if (id === null) return noFeed()
25
26
 
26
- const scope = await publicScope()
27
+ const { scope, tokened } = await feedScopeForRequest(request)
27
28
  if (!scope.forumIds.includes(id)) return noFeed()
28
29
 
29
30
  const forum = await getContainer().forums.findById(id)
@@ -44,5 +45,6 @@ export async function GET(
44
45
  }),
45
46
  'rss',
46
47
  'forum',
48
+ { private: tokened },
47
49
  )
48
50
  }
@@ -7,6 +7,7 @@ import { requireSlot, slotCopy } from '@meith/theme-kit'
7
7
  import { NewThreadForm } from '@/components/content/new-thread-form'
8
8
  import { OnboardingBanner } from '@/components/shell/onboarding-banner'
9
9
  import { attachmentLimits, canAttach } from '@/server/attachments'
10
+ import { autoWatchChecksByDefault, autoWatchPreference } from '@/server/auto-watch'
10
11
  import { getContainer } from '@/server/container'
11
12
  import { getActor } from '@/server/context'
12
13
  import { getTranslator, tr } from '@/server/i18n'
@@ -46,6 +47,9 @@ export default async function NewThreadPage({ params }: { params: Promise<{ slug
46
47
  const prefixes = await threadWrites.listPrefixes(id)
47
48
 
48
49
  const attachTarget = { ...target, allowsAttachments: rules.allowAttachments }
50
+ const subscribeDefault = autoWatchChecksByDefault(
51
+ await autoWatchPreference(actor.userId, 'create'),
52
+ )
49
53
 
50
54
  const view = buildNewThreadView({
51
55
  t: await getTranslator(),
@@ -87,6 +91,7 @@ export default async function NewThreadPage({ params }: { params: Promise<{ slug
87
91
  prefixes={prefixes.map((p) => ({ id: p.id, label: p.label }))}
88
92
  requiresPrefix={rules.requiresPrefix}
89
93
  canSubscribe={authorizer.can(actor, 'forum.subscribe', target)}
94
+ subscribeDefault={subscribeDefault}
90
95
  canPostPoll={authorizer.can(actor, 'poll.post', target)}
91
96
  attachmentLimits={attachable ? attachmentLimits(attachTarget) : null}
92
97
  draft={
@@ -20,7 +20,7 @@ import { getTranslator } from '@/server/i18n'
20
20
  import { legacyDestination } from '@/server/legacy-redirect'
21
21
  import { moderatorTargetFor } from '@/server/modcp'
22
22
  import { pageMetadata } from '@/server/page-metadata'
23
- import { filterView, viewerRef } from '@/server/plugin-view'
23
+ import { filterView, threadRowBadges, viewerRef } from '@/server/plugin-view'
24
24
  import { getSettings } from '@/server/settings'
25
25
  import { currentTheme } from '@/server/theme'
26
26
  import { getViewerPreferences } from '@/server/viewer-preferences'
@@ -279,6 +279,11 @@ export default async function ForumPage({
279
279
  ),
280
280
  )
281
281
 
282
+ const badgesByThread = await threadRowBadges(
283
+ actor,
284
+ view.threads.map((thread) => ({ threadId: thread.id, authorId: thread.author.userId })),
285
+ )
286
+
282
287
  const threadRows = await Promise.all(
283
288
  view.threads.map((thread) =>
284
289
  filterView(
@@ -286,6 +291,7 @@ export default async function ForumPage({
286
291
  {
287
292
  thread,
288
293
  select: selectionFor('thread', thread.id, `“${thread.title}”`, inlineOffered),
294
+ regions: { pluginBadges: badgesByThread.get(thread.id) },
289
295
  },
290
296
  pluginContext,
291
297
  ),
@@ -59,9 +59,14 @@ export default async function DiscoverPage({
59
59
  const themed = async (model: DiscoveryViewModel) =>
60
60
  filterView('view.discovery-view', model, viewerRef(actor))
61
61
 
62
+ const views =
63
+ actor.userId === null
64
+ ? DISCOVERY_VIEWS.filter((candidate) => candidate !== 'unread')
65
+ : DISCOVERY_VIEWS
66
+
62
67
  const common = {
63
68
  view,
64
- views: DISCOVERY_VIEWS,
69
+ views,
65
70
  pageSize: DISCOVER_PAGE,
66
71
  isFirstPage: after === null,
67
72
  now,
@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
2
2
  import { notFound } from 'next/navigation'
3
3
 
4
4
  import { requireSlot, slotCopy } from '@meith/theme-kit'
5
+ import { TextLink } from '@meith/ui'
5
6
 
6
7
  import { RemoveRelationForm, SetRelationForm } from '@/components/account/relation-forms'
7
8
  import { AvatarLockForm, SignatureLockForm } from '@/components/moderation/signature-lock-form'
@@ -114,12 +115,9 @@ export default async function MemberProfilePage({ params }: { params: Promise<{
114
115
  {translator.t('board.member.reputation')}
115
116
  </span>
116
117
  <span className="font-medium">{reputationLabel(repSummary, translator)}</span>
117
- <a
118
- href={`/member/${id}/reputation`}
119
- className="ms-auto font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
120
- >
118
+ <TextLink href={`/member/${id}/reputation`} className="ms-auto">
121
119
  {await tr('page.see-all-ratings')}
122
- </a>
120
+ </TextLink>
123
121
  </div>
124
122
  )}
125
123
 
@@ -1,6 +1,8 @@
1
1
  import type { Metadata } from 'next'
2
2
  import { notFound } from 'next/navigation'
3
3
 
4
+ import { TextLink } from '@meith/ui'
5
+
4
6
  import { RateMemberForm, WithdrawRatingForm } from '@/components/account/reputation-forms'
5
7
  import { BoardNotice } from '@/components/shell/board-notice'
6
8
  import { getContainer } from '@/server/container'
@@ -96,12 +98,9 @@ export default async function ReputationPage({
96
98
  <p className="text-sm text-muted-foreground">
97
99
  {reputationLabel(view.summary, translator)}
98
100
  </p>
99
- <a
100
- href={`/member/${id}`}
101
- className="text-sm font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
102
- >
101
+ <TextLink href={`/member/${id}`} size="sm">
103
102
  {await tr('page.back-their-profile')}
104
- </a>
103
+ </TextLink>
105
104
  </div>
106
105
 
107
106
  {mayRate && (
@@ -152,12 +151,9 @@ export default async function ReputationPage({
152
151
  {row.at.label}
153
152
  </time>
154
153
  {row.postHref !== null && (
155
- <a
156
- href={row.postHref}
157
- className="text-xs font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
158
- >
154
+ <TextLink href={row.postHref} size="xs">
159
155
  on a post
160
- </a>
156
+ </TextLink>
161
157
  )}
162
158
  {row.isMine && (
163
159
  <span className="ml-auto">
@@ -182,12 +178,9 @@ export default async function ReputationPage({
182
178
  )}
183
179
 
184
180
  {view.nextHref !== null && (
185
- <a
186
- href={view.nextHref}
187
- className="text-sm font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
188
- >
181
+ <TextLink href={view.nextHref} size="sm">
189
182
  {await tr('page.older-ratings')}
190
- </a>
183
+ </TextLink>
191
184
  )}
192
185
  </div>
193
186
  </main>
@@ -1,7 +1,7 @@
1
1
  import type { Metadata } from 'next'
2
2
  import { notFound } from 'next/navigation'
3
3
 
4
- import { Card, CardRows, Empty, EmptyDescription, EmptyTitle } from '@meith/ui'
4
+ import { Card, CardRows, Empty, EmptyDescription, EmptyTitle, TextLink } from '@meith/ui'
5
5
 
6
6
  import { PanelPage } from '@/components/shell/panel-page'
7
7
  import { PanelPagination } from '@/components/shell/panel-pagination'
@@ -112,14 +112,9 @@ export default async function MembersPage({
112
112
  className="flex flex-col gap-1 px-4 py-3 sm:flex-row sm:items-baseline sm:justify-between sm:gap-4"
113
113
  >
114
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
- >
115
+ <TextLink href={`/member/${row.id}`} className={identity?.nameClass ?? ''}>
121
116
  {row.username}
122
- </a>
117
+ </TextLink>
123
118
  {identity?.groups.map((group) => (
124
119
  <span
125
120
  key={group.groupId}
@@ -2,7 +2,7 @@ import type { Metadata } from 'next'
2
2
  import { notFound } from 'next/navigation'
3
3
 
4
4
  import { postBodyHtml } from '@meith/markdown'
5
- import { Card, CardContent, CardFooter } from '@meith/ui'
5
+ import { Card, CardContent, CardFooter, TextLink } from '@meith/ui'
6
6
 
7
7
  import { PanelPage } from '@/components/shell/panel-page'
8
8
  import { getContainer } from '@/server/container'
@@ -74,13 +74,9 @@ export default async function MessagePage({ params }: { params: Promise<{ id: st
74
74
 
75
75
  <CardFooter className="gap-4">
76
76
  {view.actions.map((action) => (
77
- <a
78
- key={action.href}
79
- href={action.href}
80
- className="text-sm font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
81
- >
77
+ <TextLink key={action.href} href={action.href} size="sm">
82
78
  {action.label}
83
- </a>
79
+ </TextLink>
84
80
  ))}
85
81
  {view.reportHref !== null && (
86
82
  <a
@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
2
2
  import { notFound } from 'next/navigation'
3
3
 
4
4
  import { MESSAGES_PAGE_SIZE, parseFolder } from '@meith/messages'
5
+ import { TextLink } from '@meith/ui'
5
6
 
6
7
  import { MessageActionBar } from '@/components/messages/message-forms'
7
8
  import { BoardNotice } from '@/components/shell/board-notice'
@@ -72,12 +73,9 @@ export default async function MessagesPage({
72
73
  <PanelPage
73
74
  title={await tr('page.private-messages')}
74
75
  actions={
75
- <a
76
- href={view.composeHref}
77
- className="text-sm font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
78
- >
76
+ <TextLink href={view.composeHref} size="sm">
79
77
  {await tr('page.write-message')}
80
- </a>
78
+ </TextLink>
81
79
  }
82
80
  >
83
81
  {notice !== null && (
@@ -14,6 +14,7 @@ import {
14
14
  EmptyTitle,
15
15
  Field,
16
16
  Input,
17
+ TextLink,
17
18
  } from '@meith/ui'
18
19
 
19
20
  import { PanelPage, PanelSection } from '@/components/shell/panel-page'
@@ -95,12 +96,9 @@ export default async function IpLookupPage({
95
96
  title={translator.t('board.ip.matches', { username: subject.username })}
96
97
  description={
97
98
  <>
98
- <a
99
- href={memberHref(subject.id)}
100
- className="underline decoration-border underline-offset-2 hover:decoration-foreground"
101
- >
99
+ <TextLink href={memberHref(subject.id)} tone="inherit" weight="normal">
102
100
  {subject.username}
103
- </a>
101
+ </TextLink>
104
102
  . {translator.t('board.ip.ranges')} {translator.t('board.ip.registration')}{' '}
105
103
  <code className="font-mono">
106
104
  {result.prefixes.registration ?? translator.t('board.ip.none')}
@@ -0,0 +1,84 @@
1
+ import type { Metadata } from 'next'
2
+ import { notFound, redirect } from 'next/navigation'
3
+
4
+ import { PANEL_CARD, PANEL_NOTE } from '@/components/shell/panel-list'
5
+ import { PanelPage } from '@/components/shell/panel-page'
6
+ import { ViewTabs } from '@/components/shell/view-tabs'
7
+ import { boardUrl } from '@/server/board-url'
8
+ import { getActor } from '@/server/context'
9
+ import { getTranslator, tr } from '@/server/i18n'
10
+ import { resolveModCpAccess } from '@/server/modcp'
11
+ import { pluginBoardPageTitle, renderPluginStaffPage } from '@/server/plugin-pages'
12
+ import { pluginStaffPanelSection } from '@/server/plugin-panel'
13
+ import { viewerRef } from '@/server/plugin-view'
14
+ import { pluginStaffPanelTabs } from '@/view/plugin-panel'
15
+
16
+ interface RouteParams {
17
+ readonly key: string
18
+ readonly path?: string[]
19
+ }
20
+
21
+ export async function generateMetadata({
22
+ params,
23
+ }: {
24
+ params: Promise<RouteParams>
25
+ }): Promise<Metadata> {
26
+ const { key, path } = await params
27
+ const title = pluginBoardPageTitle(key, (path ?? []).join('/'))
28
+ return title === null ? {} : { title }
29
+ }
30
+
31
+ export default async function PluginStaffPage({
32
+ params,
33
+ searchParams,
34
+ }: {
35
+ params: Promise<RouteParams>
36
+ searchParams: Promise<Record<string, string | string[] | undefined>>
37
+ }) {
38
+ const { key, path } = await params
39
+ const segments = path ?? []
40
+ if (segments.length > 1) notFound()
41
+ const pagePath = segments.join('/')
42
+
43
+ const query: Record<string, string> = {}
44
+ for (const [name, value] of Object.entries(await searchParams)) {
45
+ if (typeof value === 'string') query[name] = value
46
+ }
47
+
48
+ const actor = await getActor()
49
+ const result = await renderPluginStaffPage(key, pagePath, {
50
+ viewer: viewerRef(actor),
51
+ query,
52
+ boardUrl: await boardUrl(),
53
+ })
54
+
55
+ if (result.outcome === 'forbidden') notFound()
56
+
57
+ const t = await getTranslator()
58
+ const section = await pluginStaffPanelSection(key, t)
59
+
60
+ if (result.outcome === 'missing') {
61
+ if (pagePath === '' && section !== null) {
62
+ const access = await resolveModCpAccess()
63
+ if (access?.hasGroupAccess === true) redirect(section.pages[0]!.href)
64
+ }
65
+ notFound()
66
+ }
67
+ const tabs =
68
+ section === null ? [] : pluginStaffPanelTabs({ pages: section.pages, current: pagePath })
69
+
70
+ return (
71
+ <PanelPage
72
+ back={{ href: '/modcp', label: await tr('page.moderator-control-panel') }}
73
+ title={result.title}
74
+ >
75
+ {tabs.length > 0 && <ViewTabs label={section?.name ?? result.title} tabs={tabs} />}
76
+
77
+ {result.node === null ? (
78
+ <p className={PANEL_NOTE}>{t.t('board.plugin.failed')}</p>
79
+ ) : (
80
+ <section className={PANEL_CARD}>{result.node}</section>
81
+ )}
82
+ </PanelPage>
83
+ )
84
+ }
@@ -1,8 +1,13 @@
1
1
  import type { Metadata } from 'next'
2
2
  import { notFound } from 'next/navigation'
3
3
 
4
- import { REPORTS_PAGE_SIZE, ReportService } from '@meith/moderation'
5
- import { Card, Empty, EmptyDescription, EmptyTitle } from '@meith/ui'
4
+ import {
5
+ parseReportCategory,
6
+ REPORT_CATEGORIES,
7
+ REPORTS_PAGE_SIZE,
8
+ ReportService,
9
+ } from '@meith/moderation'
10
+ import { Badge, Card, Empty, EmptyDescription, EmptyTitle, NativeSelect, TextLink } from '@meith/ui'
6
11
 
7
12
  import { AssignReportForm, CloseReportForm } from '@/components/moderation/report-forms'
8
13
  import { BoardNotice } from '@/components/shell/board-notice'
@@ -13,7 +18,7 @@ import { getActor } from '@/server/context'
13
18
  import { getTranslator, tr } from '@/server/i18n'
14
19
  import { messageService } from '@/server/messages'
15
20
  import { hasReportScope, resolveReportScope } from '@/server/report-scope'
16
- import { moderationFormsCopy } from '@/view/moderation-copy'
21
+ import { moderationFormsCopy, REPORT_CATEGORY_LABEL_KEYS } from '@/view/moderation-copy'
17
22
  import { offsetOf, readPage } from '@/view/pager'
18
23
  import { postLink } from '@/view/post-link'
19
24
  import { formatTime } from '@/view/time'
@@ -25,7 +30,7 @@ export async function generateMetadata(): Promise<Metadata> {
25
30
  export default async function ReportsPage({
26
31
  searchParams,
27
32
  }: {
28
- searchParams: Promise<{ page?: string; closed?: string }>
33
+ searchParams: Promise<{ page?: string; closed?: string; category?: string }>
29
34
  }) {
30
35
  const query = await searchParams
31
36
  const actor = await getActor()
@@ -35,11 +40,15 @@ export default async function ReportsPage({
35
40
  const scope = await resolveReportScope()
36
41
  if (!hasReportScope(scope)) notFound()
37
42
 
43
+ const activeCategory = parseReportCategory(query.category)
38
44
  const service = new ReportService({ reports })
39
45
  const pageNumber = readPage(query)
40
46
  const [page, open] = await Promise.all([
41
- service.listOpen(scope, { offset: offsetOf(pageNumber, REPORTS_PAGE_SIZE) }),
42
- service.countOpen(scope),
47
+ service.listOpen(scope, {
48
+ offset: offsetOf(pageNumber, REPORTS_PAGE_SIZE),
49
+ ...(activeCategory === null ? {} : { category: activeCategory }),
50
+ }),
51
+ service.countOpen(scope, activeCategory ?? undefined),
43
52
  ])
44
53
 
45
54
  const reportedMessages = await reportedPrivateMessages(page.rows)
@@ -62,6 +71,35 @@ export default async function ReportsPage({
62
71
  <BoardNotice kind="info" message={notice} dismissHref="/moderation/reports" />
63
72
  )}
64
73
 
74
+ {(open > 0 || activeCategory !== null) && (
75
+ <form method="get" action="/moderation/reports" className="flex flex-wrap items-end gap-2">
76
+ <div className="flex flex-col gap-1 text-xs">
77
+ <label htmlFor="report-category-filter" className="font-medium text-muted-foreground">
78
+ {translator.t('board.reports.filterCategory')}
79
+ </label>
80
+ <NativeSelect
81
+ id="report-category-filter"
82
+ name="category"
83
+ defaultValue={activeCategory ?? ''}
84
+ className="w-48"
85
+ >
86
+ <option value="">{translator.t('board.reports.filterAll')}</option>
87
+ {REPORT_CATEGORIES.map((category) => (
88
+ <option key={category} value={category}>
89
+ {translator.t(REPORT_CATEGORY_LABEL_KEYS[category])}
90
+ </option>
91
+ ))}
92
+ </NativeSelect>
93
+ </div>
94
+ <button
95
+ type="submit"
96
+ className="inline-flex h-9 items-center rounded-md border border-border px-3 text-sm font-medium hover:bg-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
97
+ >
98
+ {translator.t('board.reports.filterApply')}
99
+ </button>
100
+ </form>
101
+ )}
102
+
65
103
  {page.rows.length === 0 && (
66
104
  <Card>
67
105
  <Empty className="py-8">
@@ -96,15 +134,13 @@ export default async function ReportsPage({
96
134
  ? translator.t('board.reports.privateMessage')
97
135
  : report.kind}
98
136
  </span>
137
+ <Badge tone="neutral">
138
+ {translator.t(REPORT_CATEGORY_LABEL_KEYS[report.category])}
139
+ </Badge>
99
140
  {href === null ? (
100
141
  <span className="font-medium">{report.targetLabel}</span>
101
142
  ) : (
102
- <a
103
- href={href}
104
- className="font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
105
- >
106
- {report.targetLabel}
107
- </a>
143
+ <TextLink href={href}>{report.targetLabel}</TextLink>
108
144
  )}
109
145
  <span className="text-xs text-muted-foreground">
110
146
  {translator.t('board.reports.reportedBy', {
@@ -115,9 +151,11 @@ export default async function ReportsPage({
115
151
  </span>
116
152
  </div>
117
153
 
118
- <p className="mt-2 whitespace-pre-wrap break-words text-sm leading-relaxed">
119
- {report.reason}
120
- </p>
154
+ {report.reason !== '' && (
155
+ <p className="mt-2 whitespace-pre-wrap break-words text-sm leading-relaxed">
156
+ {report.reason}
157
+ </p>
158
+ )}
121
159
 
122
160
  {report.kind === 'private_message' && (
123
161
  <div className="mt-2 rounded-md border border-border bg-muted/50 p-3">
@@ -2,7 +2,7 @@ import type { Metadata } from 'next'
2
2
  import { notFound } from 'next/navigation'
3
3
 
4
4
  import { WarningService } from '@meith/moderation'
5
- import { Card, Empty, EmptyDescription, EmptyTitle } from '@meith/ui'
5
+ import { Card, Empty, EmptyDescription, EmptyTitle, TextLink } from '@meith/ui'
6
6
 
7
7
  import { IssueWarningForm, RevokeWarningForm } from '@/components/moderation/warning-forms'
8
8
  import { BoardNotice } from '@/components/shell/board-notice'
@@ -145,12 +145,9 @@ export default async function WarnPage({
145
145
  )}
146
146
 
147
147
  {view.nextHref !== null && (
148
- <a
149
- href={view.nextHref}
150
- className="text-sm font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
151
- >
148
+ <TextLink href={view.nextHref} size="sm">
152
149
  {await tr('page.older-warnings')}
153
- </a>
150
+ </TextLink>
154
151
  )}
155
152
  </PanelSection>
156
153
  </PanelPage>
@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
2
2
  import { notFound } from 'next/navigation'
3
3
 
4
4
  import { NOTIFICATIONS_PAGE_SIZE } from '@meith/notifications'
5
+ import { TextLink } from '@meith/ui'
5
6
 
6
7
  import {
7
8
  MarkAllNotificationsReadForm,
@@ -60,12 +61,9 @@ export default async function NotificationsPage({
60
61
  lede={
61
62
  <>
62
63
  {translator.t('board.notifications.unread', { count: view.unread })}{' '}
63
- <a
64
- href={view.preferencesHref}
65
- className="font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
66
- >
64
+ <TextLink href={view.preferencesHref}>
67
65
  {await tr('page.choose-which-these-receive-by')}
68
- </a>
66
+ </TextLink>
69
67
  .
70
68
  </>
71
69
  }
@@ -115,12 +113,9 @@ export default async function NotificationsPage({
115
113
 
116
114
  <div className="mt-3 flex items-center gap-4">
117
115
  {row.href !== null && (
118
- <a
119
- href={row.href}
120
- className="text-sm font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
121
- >
116
+ <TextLink href={row.href} size="sm">
122
117
  {translator.t('board.notifications.view')}
123
- </a>
118
+ </TextLink>
124
119
  )}
125
120
  {!row.isRead && (
126
121
  <MarkNotificationReadForm
@@ -3,6 +3,7 @@ import { notFound } from 'next/navigation'
3
3
 
4
4
  import {
5
5
  AnnouncementsOptInForm,
6
+ BoardDigestCadenceForm,
6
7
  NotificationPreferencesForm,
7
8
  } from '@/components/account/notification-forms'
8
9
  import { PushDeviceForm } from '@/components/account/push-device-form'
@@ -68,6 +69,13 @@ export default async function NotificationPreferencesPage({
68
69
  copy={notificationFormsCopy(await getTranslator())}
69
70
  />
70
71
 
72
+ {settings !== null && (
73
+ <BoardDigestCadenceForm
74
+ cadence={settings.boardDigestCadence}
75
+ copy={notificationFormsCopy(await getTranslator())}
76
+ />
77
+ )}
78
+
71
79
  {settings !== null && (
72
80
  <AnnouncementsOptInForm
73
81
  optedIn={settings.massMailOptInAt !== null}
@@ -1,6 +1,14 @@
1
1
  import type { Metadata } from 'next'
2
2
 
3
- import { Card, CardFooter, CardRows, Empty, EmptyDescription, EmptyTitle } from '@meith/ui'
3
+ import {
4
+ Card,
5
+ CardFooter,
6
+ CardRows,
7
+ Empty,
8
+ EmptyDescription,
9
+ EmptyTitle,
10
+ TextLink,
11
+ } from '@meith/ui'
4
12
 
5
13
  import { PanelPage } from '@/components/shell/panel-page'
6
14
  import { activeWordFilter } from '@/server/content-admin'
@@ -72,14 +80,12 @@ export default async function OnlinePage() {
72
80
  className="flex flex-col gap-1 px-4 py-3 sm:flex-row sm:items-baseline sm:justify-between sm:gap-4"
73
81
  >
74
82
  <span className="text-sm font-medium">
75
- <a
83
+ <TextLink
76
84
  href={`/member/${member.userId}`}
77
- className={`font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground ${
78
- identities.get(member.userId)?.nameClass ?? ''
79
- }`}
85
+ className={identities.get(member.userId)?.nameClass ?? ''}
80
86
  >
81
87
  {member.username}
82
- </a>
88
+ </TextLink>
83
89
  {member.invisible && (
84
90
  <span className="ml-1 text-xs text-muted-foreground">
85
91
  {translator.t('board.online.invisible')}
@@ -8,6 +8,7 @@ import {
8
8
  Empty,
9
9
  EmptyDescription,
10
10
  EmptyTitle,
11
+ TextLink,
11
12
  } from '@meith/ui'
12
13
 
13
14
  import { PanelPage } from '@/components/shell/panel-page'
@@ -62,14 +63,12 @@ export default async function StaffPage() {
62
63
  <CardRows>
63
64
  {group.members.map((member) => (
64
65
  <li key={member.id} className="px-4 py-3 text-sm">
65
- <a
66
+ <TextLink
66
67
  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
- }`}
68
+ className={identities.get(member.id)?.nameClass ?? ''}
70
69
  >
71
70
  {member.username}
72
- </a>
71
+ </TextLink>
73
72
  </li>
74
73
  ))}
75
74
  </CardRows>
@@ -1,6 +1,14 @@
1
1
  import type { Metadata } from 'next'
2
2
 
3
- import { Card, CardContent, CardRows, Empty, EmptyDescription, EmptyTitle } from '@meith/ui'
3
+ import {
4
+ Card,
5
+ CardContent,
6
+ CardRows,
7
+ Empty,
8
+ EmptyDescription,
9
+ EmptyTitle,
10
+ textLinkVariants,
11
+ } from '@meith/ui'
4
12
 
5
13
  import { PanelPage, PanelSection } from '@/components/shell/panel-page'
6
14
  import { getActor } from '@/server/context'
@@ -13,8 +21,7 @@ export async function generateMetadata(): Promise<Metadata> {
13
21
  return { title: await tr('page.board-statistics') }
14
22
  }
15
23
 
16
- const LINK =
17
- 'font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground'
24
+ const LINK = textLinkVariants()
18
25
 
19
26
  const ROW = 'flex items-baseline justify-between gap-4 px-4 py-2.5 text-sm'
20
27