@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
@@ -25,6 +25,7 @@ import {
25
25
  } from '@/server/content-admin-actions'
26
26
 
27
27
  import { FormError, PendingButton, SubmitButton } from '../auth/form-controls'
28
+ import { ConfirmDialog } from '../shell/confirm-dialog'
28
29
  import { type Copy, fromCopy } from '../shell/copy'
29
30
  import { INPUT, Saved } from './form-bits'
30
31
 
@@ -91,6 +92,8 @@ export function WordFilterRowForm({ filter, copy }: { filter: WordFilterValues;
91
92
  {fromCopy(copy, 'adminContent.filter.removeThis')}
92
93
  </PendingButton>
93
94
  </form>
95
+
96
+ <ConfirmDialog confirm={removeState.confirm} action={removeAction} />
94
97
  </div>
95
98
  )
96
99
  }
@@ -140,13 +143,16 @@ export function DeletePrefixForm({ prefix, copy }: { prefix: PrefixValues; copy:
140
143
  const [state, action] = useActionState(deletePrefixAction, EMPTY_STATE)
141
144
 
142
145
  return (
143
- <form action={action} className="flex items-center gap-2">
144
- <FormError message={state.error} />
145
- <input type="hidden" name="id" value={prefix.id} />
146
- <PendingButton showWorking className="text-xs text-muted-foreground hover:underline">
147
- {fromCopy(copy, 'admin.remove')}
148
- </PendingButton>
149
- </form>
146
+ <>
147
+ <form action={action} className="flex items-center gap-2">
148
+ <FormError message={state.error} />
149
+ <input type="hidden" name="id" value={prefix.id} />
150
+ <PendingButton showWorking className="text-xs text-muted-foreground hover:underline">
151
+ {fromCopy(copy, 'admin.remove')}
152
+ </PendingButton>
153
+ </form>
154
+ <ConfirmDialog confirm={state.confirm} action={action} />
155
+ </>
150
156
  )
151
157
  }
152
158
 
@@ -256,6 +262,8 @@ export function SmileyRowForm({ smiley, copy }: { smiley: SmileyValues; copy: Co
256
262
  {fromCopy(copy, 'admin.remove')}
257
263
  </PendingButton>
258
264
  </form>
265
+
266
+ <ConfirmDialog confirm={removeState.confirm} action={removeAction} />
259
267
  </div>
260
268
  )
261
269
  }
@@ -366,6 +374,8 @@ export function DirectiveRowForm({ directive, copy }: { directive: DirectiveValu
366
374
  {fromCopy(copy, 'admin.remove')}
367
375
  </PendingButton>
368
376
  </form>
377
+
378
+ <ConfirmDialog confirm={removeState.confirm} action={removeAction} />
369
379
  </div>
370
380
  )
371
381
  }
@@ -419,13 +429,16 @@ export function DeleteAttachmentForm({ attachmentId, copy }: { attachmentId: num
419
429
  const [state, action] = useActionState(deleteAttachmentAction, EMPTY_STATE)
420
430
 
421
431
  return (
422
- <form action={action} className="shrink-0">
423
- <FormError message={state.error} />
424
- <input type="hidden" name="id" value={attachmentId} />
425
- <PendingButton showWorking className="text-xs text-destructive hover:underline">
426
- {fromCopy(copy, 'adminContent.attachment.delete')}
427
- </PendingButton>
428
- </form>
432
+ <div className="shrink-0">
433
+ <form action={action}>
434
+ <FormError message={state.error} />
435
+ <input type="hidden" name="id" value={attachmentId} />
436
+ <PendingButton showWorking className="text-xs text-destructive hover:underline">
437
+ {fromCopy(copy, 'adminContent.attachment.delete')}
438
+ </PendingButton>
439
+ </form>
440
+ <ConfirmDialog confirm={state.confirm} action={action} />
441
+ </div>
429
442
  )
430
443
  }
431
444
 
@@ -572,6 +585,8 @@ export function AnnouncementRowForm({
572
585
  {fromCopy(copy, 'admin.remove')}
573
586
  </PendingButton>
574
587
  </form>
588
+
589
+ <ConfirmDialog confirm={removeState.confirm} action={removeAction} />
575
590
  </div>
576
591
  )
577
592
  }
@@ -674,6 +689,8 @@ export function CaptchaQuestionRowForm({
674
689
  {fromCopy(copy, 'admin.remove')}
675
690
  </PendingButton>
676
691
  </form>
692
+
693
+ <ConfirmDialog confirm={removeState.confirm} action={removeAction} />
677
694
  </div>
678
695
  )
679
696
  }
@@ -2,8 +2,9 @@
2
2
 
3
3
  import { useActionState, useEffect, useState } from 'react'
4
4
 
5
- import type { MatrixCell, MatrixRow } from '@meith/authorization'
6
- import { cn, Disclosure } from '@meith/ui'
5
+ import type { FieldMatrixCell, FieldMatrixRow, MatrixColumn } from '@meith/authorization'
6
+ import type { PermissionField } from '@meith/core'
7
+ import { cn } from '@meith/ui'
7
8
 
8
9
  import { PANEL_CARD, PANEL_LIST, PANEL_NOTE, PANEL_ROW } from '@/components/shell/panel-list'
9
10
  import { EMPTY_STATE } from '@/server/auth-form-state'
@@ -14,7 +15,7 @@ import {
14
15
  moveForumAction,
15
16
  removeModeratorAction,
16
17
  saveForumOptionsAction,
17
- saveForumPermissionsAction,
18
+ saveForumPermissionMatrixAction,
18
19
  } from '@/server/forum-admin-actions'
19
20
 
20
21
  import { FormError, PendingButton, SubmitButton } from '../auth/form-controls'
@@ -122,26 +123,34 @@ export function ForumOptionsForm({ forum, copy }: { forum: ForumOptionsValues; c
122
123
  )
123
124
  }
124
125
 
125
- function effectiveValue(cell: MatrixCell, copy: Copy): string {
126
- if (cell.kind === 'boolean')
127
- return cell.effective
126
+ function firstSentence(text: string): string {
127
+ const at = text.indexOf('. ')
128
+ return at === -1 ? text : text.slice(0, at + 1)
129
+ }
130
+
131
+ function effectiveValue(
132
+ kind: PermissionField['kind'],
133
+ effective: boolean | number,
134
+ copy: Copy,
135
+ ): string {
136
+ if (kind === 'boolean')
137
+ return effective
128
138
  ? fromCopy(copy, 'adminForum.perm.allowed')
129
139
  : fromCopy(copy, 'adminForum.perm.denied')
130
- if (cell.kind === 'negative')
131
- return cell.effective
140
+ if (kind === 'negative')
141
+ return effective
132
142
  ? fromCopy(copy, 'adminForum.perm.required')
133
143
  : fromCopy(copy, 'adminForum.perm.notRequired')
134
- return String(cell.effective)
144
+ return String(effective)
135
145
  }
136
146
 
137
- function effectiveLabel(
138
- cell: MatrixCell,
147
+ function inheritedTitle(
148
+ row: FieldMatrixRow,
149
+ cell: FieldMatrixCell,
139
150
  forumTitles: ReadonlyMap<number, string>,
140
151
  copy: Copy,
141
152
  ): string {
142
- const value = effectiveValue(cell, copy)
143
-
144
- if (cell.stored !== null) return formatFromCopy(copy, 'adminForum.perm.setHere', { value })
153
+ const value = effectiveValue(row.kind, cell.effective, copy)
145
154
  if (cell.inheritedFrom === null)
146
155
  return formatFromCopy(copy, 'adminForum.perm.inheritedDefault', { value })
147
156
  return formatFromCopy(copy, 'adminForum.perm.inheritedFrom', {
@@ -150,7 +159,10 @@ function effectiveLabel(
150
159
  })
151
160
  }
152
161
 
153
- const PERM_GROUPS: readonly { readonly labelKey: string; readonly fields: readonly string[] }[] = [
162
+ const BOOLEAN_PERM_GROUPS: readonly {
163
+ readonly labelKey: string
164
+ readonly fields: readonly string[]
165
+ }[] = [
154
166
  {
155
167
  labelKey: 'adminForum.permGroup.viewing',
156
168
  fields: ['canView', 'canViewThreads', 'canViewOthersThreads', 'canSearch'],
@@ -168,7 +180,7 @@ const PERM_GROUPS: readonly { readonly labelKey: string; readonly fields: readon
168
180
  },
169
181
  {
170
182
  labelKey: 'adminForum.permGroup.ownContent',
171
- fields: ['canEditOwnPosts', 'canDeleteOwnPosts', 'canDeleteOwnThreads', 'editTimeLimitMinutes'],
183
+ fields: ['canEditOwnPosts', 'canDeleteOwnPosts', 'canDeleteOwnThreads'],
172
184
  },
173
185
  {
174
186
  labelKey: 'adminForum.permGroup.moderation',
@@ -182,12 +194,7 @@ const PERM_GROUPS: readonly { readonly labelKey: string; readonly fields: readon
182
194
  },
183
195
  {
184
196
  labelKey: 'adminForum.permGroup.attachments',
185
- fields: [
186
- 'canUploadAttachments',
187
- 'canDownloadAttachments',
188
- 'maxAttachmentsPerPost',
189
- 'maxAttachmentSizeKb',
190
- ],
197
+ fields: ['canUploadAttachments', 'canDownloadAttachments'],
191
198
  },
192
199
  {
193
200
  labelKey: 'adminForum.permGroup.approvals',
@@ -195,37 +202,47 @@ const PERM_GROUPS: readonly { readonly labelKey: string; readonly fields: readon
195
202
  },
196
203
  ]
197
204
 
198
- function firstSentence(text: string): string {
199
- const at = text.indexOf('. ')
200
- return at === -1 ? text : text.slice(0, at + 1)
201
- }
202
-
203
- function groupCells(
204
- cells: readonly MatrixCell[],
205
- ): readonly { readonly labelKey: string; readonly cells: readonly MatrixCell[] }[] {
206
- const byKey = new Map(cells.map((cell) => [cell.key, cell]))
205
+ function groupFieldRows(
206
+ rows: readonly FieldMatrixRow[],
207
+ groups: readonly { readonly labelKey: string; readonly fields: readonly string[] }[],
208
+ ): readonly { readonly labelKey: string; readonly rows: readonly FieldMatrixRow[] }[] {
209
+ const byKey = new Map(rows.map((row) => [row.key, row]))
207
210
  const taken = new Set<string>()
208
- const sections = PERM_GROUPS.map((group) => {
209
- const found = group.fields.flatMap((key) => {
210
- const cell = byKey.get(key)
211
- if (cell === undefined) return []
212
- taken.add(key)
213
- return [cell]
211
+ const sections = groups
212
+ .map((group) => {
213
+ const found = group.fields.flatMap((key) => {
214
+ const row = byKey.get(key)
215
+ if (row === undefined) return []
216
+ taken.add(key)
217
+ return [row]
218
+ })
219
+ return { labelKey: group.labelKey, rows: found }
214
220
  })
215
- return { labelKey: group.labelKey, cells: found }
216
- }).filter((section) => section.cells.length > 0)
221
+ .filter((section) => section.rows.length > 0)
217
222
 
218
- const leftover = cells.filter((cell) => !taken.has(cell.key))
223
+ const leftover = rows.filter((row) => !taken.has(row.key))
219
224
  return leftover.length === 0
220
225
  ? sections
221
- : [...sections, { labelKey: 'adminForum.permGroup.other', cells: leftover }]
226
+ : [...sections, { labelKey: 'adminForum.permGroup.other', rows: leftover }]
222
227
  }
223
228
 
224
229
  const SEGMENT =
225
- 'flex-1 cursor-pointer px-2.5 py-1 text-center text-xs font-medium text-muted-foreground transition-colors hover:text-foreground has-[:checked]:font-semibold has-[:focus-visible]:outline-2 has-[:focus-visible]:-outline-offset-2 has-[:focus-visible]:outline-ring'
230
+ 'flex-1 cursor-pointer px-2 py-1 text-center text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground has-[:checked]:font-semibold has-[:focus-visible]:outline-2 has-[:focus-visible]:-outline-offset-2 has-[:focus-visible]:outline-ring'
226
231
 
227
- function TriState({ cell, copy }: { cell: MatrixCell; copy: Copy }) {
228
- const negative = cell.kind === 'negative'
232
+ function MatrixTriState({
233
+ row,
234
+ cell,
235
+ groupTitle,
236
+ forumTitles,
237
+ copy,
238
+ }: {
239
+ row: FieldMatrixRow
240
+ cell: FieldMatrixCell
241
+ groupTitle: string
242
+ forumTitles: ReadonlyMap<number, string>
243
+ copy: Copy
244
+ }) {
245
+ const negative = row.kind === 'negative'
229
246
  const options = [
230
247
  {
231
248
  value: 'inherit',
@@ -248,118 +265,213 @@ function TriState({ cell, copy }: { cell: MatrixCell; copy: Copy }) {
248
265
  },
249
266
  ]
250
267
 
268
+ const legend =
269
+ cell.stored === null
270
+ ? `${firstSentence(row.description)} — ${groupTitle}. ${inheritedTitle(row, cell, forumTitles, copy)}`
271
+ : `${firstSentence(row.description)} — ${groupTitle}`
272
+
251
273
  return (
252
- <fieldset className="flex w-full shrink-0 divide-x divide-border overflow-hidden rounded-md border border-border sm:w-56">
253
- <legend className="sr-only">{firstSentence(cell.description)}</legend>
254
- {options.map((option) => (
255
- <label key={option.value} className={cn(SEGMENT, option.on)}>
256
- <input
257
- type="radio"
258
- name={cell.key}
259
- value={option.value}
260
- defaultChecked={cell.control === option.value}
261
- className="sr-only"
262
- />
263
- {option.label}
264
- </label>
265
- ))}
266
- </fieldset>
274
+ <div className="flex flex-col gap-1">
275
+ <fieldset className="flex w-28 shrink-0 divide-x divide-border overflow-hidden rounded-md border border-border">
276
+ <legend className="sr-only">{legend}</legend>
277
+ {options.map((option) => (
278
+ <label key={option.value} className={cn(SEGMENT, option.on)}>
279
+ <input
280
+ type="radio"
281
+ name={cell.name}
282
+ value={option.value}
283
+ defaultChecked={cell.control === option.value}
284
+ className="sr-only"
285
+ />
286
+ {option.label}
287
+ </label>
288
+ ))}
289
+ </fieldset>
290
+ {cell.stored === null && (
291
+ <span className="text-[10px] text-muted-foreground">
292
+ {effectiveValue(row.kind, cell.effective, copy)}
293
+ </span>
294
+ )}
295
+ </div>
267
296
  )
268
297
  }
269
298
 
270
- function CellControl({ cell, copy }: { cell: MatrixCell; copy: Copy }) {
271
- if (cell.kind !== 'numeric') return <TriState cell={cell} copy={copy} />
272
-
299
+ function MatrixNumericInput({
300
+ row,
301
+ cell,
302
+ groupTitle,
303
+ copy,
304
+ }: {
305
+ row: FieldMatrixRow
306
+ cell: FieldMatrixCell
307
+ groupTitle: string
308
+ copy: Copy
309
+ }) {
273
310
  return (
274
- <input
275
- type="number"
276
- name={cell.key}
277
- min={0}
278
- placeholder={fromCopy(copy, 'adminForum.perm.inherit')}
279
- defaultValue={cell.control}
280
- className={cn(INPUT, 'w-full shrink-0 sm:w-56')}
281
- />
311
+ <div className="flex flex-col gap-1">
312
+ <input
313
+ type="number"
314
+ name={cell.name}
315
+ min={0}
316
+ placeholder={fromCopy(copy, 'adminForum.perm.inherit')}
317
+ defaultValue={cell.control}
318
+ aria-label={`${firstSentence(row.description)} — ${groupTitle}`}
319
+ className={cn(INPUT, 'w-20')}
320
+ />
321
+ {cell.stored === null && (
322
+ <span className="text-[10px] text-muted-foreground">
323
+ {effectiveValue(row.kind, cell.effective, copy)}
324
+ </span>
325
+ )}
326
+ </div>
282
327
  )
283
328
  }
284
329
 
285
- function overridesAside(row: MatrixRow, copy: Copy): string {
286
- const count = row.cells.filter((cell) => cell.stored !== null).length
287
- return count === 0
288
- ? fromCopy(copy, 'adminForum.inheritsAll')
289
- : formatFromCopy(copy, 'adminForum.overridesSet', { count })
330
+ function MatrixSection({
331
+ title,
332
+ columns,
333
+ rows,
334
+ forumTitles,
335
+ copy,
336
+ }: {
337
+ title: string
338
+ columns: readonly MatrixColumn[]
339
+ rows: readonly FieldMatrixRow[]
340
+ forumTitles: ReadonlyMap<number, string>
341
+ copy: Copy
342
+ }) {
343
+ return (
344
+ <section className="flex flex-col gap-2">
345
+ <h3 className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
346
+ {title}
347
+ </h3>
348
+ <div className="overflow-x-auto rounded-lg border border-border">
349
+ <table className="w-full border-collapse text-sm">
350
+ <thead>
351
+ <tr>
352
+ <th
353
+ scope="col"
354
+ className="sticky top-0 left-0 z-20 min-w-48 border-b border-border bg-card px-3 py-2 text-left font-medium"
355
+ >
356
+ {fromCopy(copy, 'adminForum.permission')}
357
+ </th>
358
+ {columns.map((column) => (
359
+ <th
360
+ key={column.groupId}
361
+ scope="col"
362
+ className="sticky top-0 z-10 border-b border-border bg-card px-3 py-2 text-left font-medium whitespace-nowrap"
363
+ >
364
+ {column.groupTitle}
365
+ </th>
366
+ ))}
367
+ </tr>
368
+ </thead>
369
+ <tbody>
370
+ {rows.map((row) => (
371
+ <tr key={row.key} className="border-b border-border/60 last:border-b-0">
372
+ <th
373
+ scope="row"
374
+ title={row.description}
375
+ className="sticky left-0 z-10 border-r border-border/60 bg-card px-3 py-2 text-left align-top font-normal"
376
+ >
377
+ {firstSentence(row.description)}
378
+ </th>
379
+ {row.cells.map((cell, index) => (
380
+ <td key={cell.groupId} className="px-2 py-2 align-top">
381
+ {row.kind === 'numeric' ? (
382
+ <MatrixNumericInput
383
+ row={row}
384
+ cell={cell}
385
+ groupTitle={columns[index]?.groupTitle ?? ''}
386
+ copy={copy}
387
+ />
388
+ ) : (
389
+ <MatrixTriState
390
+ row={row}
391
+ cell={cell}
392
+ groupTitle={columns[index]?.groupTitle ?? ''}
393
+ forumTitles={forumTitles}
394
+ copy={copy}
395
+ />
396
+ )}
397
+ </td>
398
+ ))}
399
+ </tr>
400
+ ))}
401
+ </tbody>
402
+ </table>
403
+ </div>
404
+ </section>
405
+ )
290
406
  }
291
407
 
292
- export function ForumPermissionRowForm({
408
+ export function ForumPermissionMatrixForm({
293
409
  forumId,
294
- row,
410
+ columns,
411
+ fields,
295
412
  forumTitles,
296
413
  copy,
297
414
  }: {
298
415
  forumId: number
299
- row: MatrixRow
416
+ columns: readonly MatrixColumn[]
417
+ fields: readonly FieldMatrixRow[]
300
418
  forumTitles: ReadonlyMap<number, string>
301
419
  copy: Copy
302
420
  }) {
303
- const [state, action] = useActionState(saveForumPermissionsAction, EMPTY_STATE)
421
+ const [state, action] = useActionState(saveForumPermissionMatrixAction, EMPTY_STATE)
304
422
  const [dirty, setDirty] = useState(false)
305
423
 
306
424
  useEffect(() => {
307
425
  if (state.notice === 'saved') setDirty(false)
308
426
  }, [state])
309
427
 
428
+ const booleanSections = groupFieldRows(
429
+ fields.filter((field) => field.kind !== 'numeric'),
430
+ BOOLEAN_PERM_GROUPS,
431
+ )
432
+ const numericFields = fields.filter((field) => field.kind === 'numeric')
433
+
310
434
  return (
311
- <Disclosure
312
- summary={row.groupTitle}
313
- aside={overridesAside(row, copy)}
314
- className="overflow-visible"
315
- >
316
- <form action={action} className="flex flex-col gap-5" onChange={() => setDirty(true)}>
317
- <FormError message={state.error} />
318
- <input type="hidden" name="forumId" value={forumId} />
319
- <input type="hidden" name="groupId" value={row.groupId} />
320
-
321
- {groupCells(row.cells).map((section) => (
322
- <section key={section.labelKey} className="flex flex-col gap-2.5">
323
- <h4 className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
324
- {fromCopy(copy, section.labelKey)}
325
- </h4>
326
- {section.cells.map((cell) => (
327
- <div
328
- key={cell.key}
329
- className="flex flex-col gap-1.5 border-b border-border/60 pb-2.5 last:border-b-0 last:pb-0 sm:flex-row sm:items-start sm:justify-between sm:gap-4"
330
- >
331
- <div className="min-w-0">
332
- <p className="text-sm font-medium" title={cell.description}>
333
- {firstSentence(cell.description)}
334
- </p>
335
- <p className="mt-0.5 text-xs text-muted-foreground">
336
- {effectiveLabel(cell, forumTitles, copy)}
337
- </p>
338
- </div>
339
- <CellControl cell={cell} copy={copy} />
340
- </div>
341
- ))}
342
- </section>
343
- ))}
435
+ <form action={action} className="flex flex-col gap-6" onChange={() => setDirty(true)}>
436
+ <FormError message={state.error} />
437
+ <input type="hidden" name="forumId" value={forumId} />
438
+
439
+ {booleanSections.map((section) => (
440
+ <MatrixSection
441
+ key={section.labelKey}
442
+ title={fromCopy(copy, section.labelKey)}
443
+ columns={columns}
444
+ rows={section.rows}
445
+ forumTitles={forumTitles}
446
+ copy={copy}
447
+ />
448
+ ))}
344
449
 
345
- <div className="sticky bottom-0 -mx-4 -mb-4 flex items-center gap-3 rounded-b-lg border-t border-border bg-card/95 px-4 py-3 supports-[backdrop-filter]:bg-card/80 supports-[backdrop-filter]:backdrop-blur">
346
- <SubmitButton className="w-auto">
347
- {formatFromCopy(copy, 'adminForum.saveGroup', { group: row.groupTitle })}
348
- </SubmitButton>
349
- {dirty ? (
350
- <span className="text-xs font-medium text-muted-foreground">
351
- {fromCopy(copy, 'adminForum.unsavedChanges')}
450
+ {numericFields.length > 0 && (
451
+ <MatrixSection
452
+ title={fromCopy(copy, 'adminForum.permGroup.numeric')}
453
+ columns={columns}
454
+ rows={numericFields}
455
+ forumTitles={forumTitles}
456
+ copy={copy}
457
+ />
458
+ )}
459
+
460
+ <div className="sticky bottom-0 z-30 flex items-center gap-3 rounded-b-lg border-t border-border bg-card/95 px-4 py-3 supports-[backdrop-filter]:bg-card/80 supports-[backdrop-filter]:backdrop-blur">
461
+ <SubmitButton className="w-auto">{fromCopy(copy, 'adminForum.saveMatrix')}</SubmitButton>
462
+ {dirty ? (
463
+ <span className="text-xs font-medium text-muted-foreground">
464
+ {fromCopy(copy, 'adminForum.unsavedChanges')}
465
+ </span>
466
+ ) : (
467
+ state.notice === 'saved' && (
468
+ <span className="text-xs font-medium text-moderation-approved">
469
+ {fromCopy(copy, 'admin.saved')}
352
470
  </span>
353
- ) : (
354
- state.notice === 'saved' && (
355
- <span className="text-xs font-medium text-moderation-approved">
356
- {fromCopy(copy, 'admin.saved')}
357
- </span>
358
- )
359
- )}
360
- </div>
361
- </form>
362
- </Disclosure>
471
+ )
472
+ )}
473
+ </div>
474
+ </form>
363
475
  )
364
476
  }
365
477
 
@@ -15,7 +15,7 @@ import {
15
15
  subtreeOfOutline,
16
16
  withoutSubtree,
17
17
  } from '@meith/forums/arrange'
18
- import { buttonVariants, cn } from '@meith/ui'
18
+ import { buttonVariants, cn, TextLink } from '@meith/ui'
19
19
 
20
20
  import { PANEL_LIST } from '@/components/shell/panel-list'
21
21
  import { EMPTY_STATE } from '@/server/auth-form-state'
@@ -412,24 +412,22 @@ export function ForumTree({ rows, copy }: { rows: readonly ForumOutlineRow[]; co
412
412
  </form>
413
413
 
414
414
  <span className="ml-auto flex shrink-0 items-center gap-3 text-xs sm:ml-0 sm:gap-2 sm:pl-1">
415
- <a
415
+ <TextLink
416
416
  href={`/admin/forums/${row.id}`}
417
417
  aria-label={formatFromCopy(copy, 'adminForum.tree.optionsFor', {
418
418
  title: row.title,
419
419
  })}
420
- className="font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
421
420
  >
422
421
  {fromCopy(copy, 'adminForum.options')}
423
- </a>
424
- <a
422
+ </TextLink>
423
+ <TextLink
425
424
  href={`/admin/forums/${row.id}/permissions`}
426
425
  aria-label={formatFromCopy(copy, 'adminForum.tree.permissionsFor', {
427
426
  title: row.title,
428
427
  })}
429
- className="font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
430
428
  >
431
429
  {fromCopy(copy, 'adminForum.tree.permissions')}
432
- </a>
430
+ </TextLink>
433
431
  </span>
434
432
  </li>
435
433
  )