@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.
- package/app/(board)/[slug]/feed.xml/route.ts +5 -3
- package/app/(board)/[slug]/new/page.tsx +5 -0
- package/app/(board)/[slug]/page.tsx +7 -1
- package/app/(board)/discover/[view]/page.tsx +6 -1
- package/app/(board)/member/[id]/page.tsx +3 -5
- package/app/(board)/member/[id]/reputation/page.tsx +8 -15
- package/app/(board)/members/page.tsx +3 -8
- package/app/(board)/messages/[id]/page.tsx +3 -7
- package/app/(board)/messages/page.tsx +3 -5
- package/app/(board)/modcp/ip/page.tsx +3 -5
- package/app/(board)/modcp/plugins/[key]/[[...path]]/page.tsx +84 -0
- package/app/(board)/moderation/reports/page.tsx +53 -15
- package/app/(board)/moderation/warn/page.tsx +3 -6
- package/app/(board)/notifications/page.tsx +5 -10
- package/app/(board)/notifications/preferences/page.tsx +8 -0
- package/app/(board)/online/page.tsx +12 -6
- package/app/(board)/staff/page.tsx +4 -5
- package/app/(board)/stats/page.tsx +10 -3
- package/app/(board)/subscriptions/page.tsx +5 -10
- package/app/(board)/thread/[slug]/edit/page.tsx +21 -0
- package/app/(board)/thread/[slug]/feed.xml/route.ts +6 -3
- package/app/(board)/thread/[slug]/page.tsx +49 -11
- package/app/(board)/thread/[slug]/reply/page.tsx +5 -0
- package/app/(board)/unsubscribe/page.tsx +8 -11
- package/app/(board)/usercp/contacts/page.tsx +3 -5
- package/app/(board)/usercp/drafts/page.tsx +70 -0
- package/app/(board)/usercp/options/page.tsx +8 -1
- package/app/(board)/usercp/security/page.tsx +16 -0
- package/app/admin/antispam/page.tsx +4 -12
- package/app/admin/content/attachments/page.tsx +3 -6
- package/app/admin/content/page.tsx +3 -13
- package/app/admin/forums/[id]/permissions/page.tsx +9 -12
- package/app/admin/groups/page.tsx +7 -9
- package/app/admin/page.tsx +12 -6
- package/app/admin/plugins/[key]/[[...path]]/page.tsx +6 -7
- package/app/admin/plugins/page.tsx +3 -6
- package/app/admin/users/[id]/page.tsx +5 -11
- package/app/admin/users/prune/page.tsx +4 -12
- package/app/admin/webhooks/page.tsx +144 -0
- package/app/api/subscribe/thread/[id]/route.ts +53 -0
- package/app/api/unsubscribe/thread/[id]/route.ts +42 -0
- package/app/atom.xml/route.ts +2 -2
- package/app/auth/sso/[provider]/callback/route.ts +10 -0
- package/app/feed.xml/route.ts +2 -2
- package/app/offline/route.ts +98 -0
- package/next.config.mjs +1 -0
- package/package.json +49 -48
- package/public/sw.js +42 -2
- package/src/components/account/draft-forms.tsx +39 -0
- package/src/components/account/feed-token.tsx +95 -0
- package/src/components/account/notification-forms.tsx +37 -2
- package/src/components/account/relation-forms.tsx +3 -2
- package/src/components/account/subscription-forms.tsx +20 -6
- package/src/components/account/usercp-forms.tsx +44 -0
- package/src/components/admin/api-token-forms.tsx +11 -7
- package/src/components/admin/ban-filter-forms.tsx +15 -11
- package/src/components/admin/content-forms.tsx +31 -14
- package/src/components/admin/forum-forms.tsx +241 -129
- package/src/components/admin/forum-tree.tsx +5 -7
- package/src/components/admin/navigation-forms.tsx +43 -26
- package/src/components/admin/navigation-tree.tsx +8 -13
- package/src/components/admin/oklch-picker.tsx +2 -1
- package/src/components/admin/theme-changes.tsx +3 -2
- package/src/components/admin/theme-forms.tsx +3 -2
- package/src/components/admin/webhook-forms.tsx +137 -0
- package/src/components/auth/register-form.tsx +4 -7
- package/src/components/board/live-region.tsx +3 -1
- package/src/components/content/attachment-field.tsx +93 -22
- package/src/components/content/combobox-keys.ts +38 -0
- package/src/components/content/edit-post-form.tsx +75 -1
- package/src/components/content/markdown-editor.tsx +29 -86
- package/src/components/content/mention-combobox.tsx +137 -0
- package/src/components/content/multiquote-button.tsx +15 -12
- package/src/components/content/new-thread-form.tsx +95 -17
- package/src/components/content/poll-edit-form.tsx +61 -39
- package/src/components/content/poll-vote-form.tsx +91 -0
- package/src/components/content/poll.tsx +12 -48
- package/src/components/content/progressive-marker.tsx +13 -0
- package/src/components/content/recipient-segment.ts +39 -0
- package/src/components/content/reply-form.tsx +9 -1
- package/src/components/content/thanks-button.tsx +12 -7
- package/src/components/messages/message-forms.tsx +53 -43
- package/src/components/messages/recipient-field.tsx +88 -0
- package/src/components/moderation/inline-moderation-form.tsx +2 -0
- package/src/components/moderation/modcp-shell.tsx +17 -3
- package/src/components/moderation/queue-form.tsx +3 -6
- package/src/components/moderation/report-forms.tsx +24 -5
- package/src/components/moderation/thread-tools-form.tsx +2 -0
- package/src/components/shell/board-notice.tsx +22 -1
- package/src/components/shell/confirm-dialog.tsx +93 -0
- package/src/components/shell/nav-disclosure-enhancer.tsx +36 -0
- package/src/components/shell/notice-toast.tsx +40 -0
- package/src/components/shell/notification-menu.tsx +5 -3
- package/src/components/shell/page-shell.tsx +3 -0
- package/src/components/shell/scheme-toggle.tsx +72 -0
- package/src/components/shell/theme-switcher.tsx +16 -36
- package/src/server/admin-settings-actions.ts +5 -0
- package/src/server/api/search.ts +8 -2
- package/src/server/api-token-actions.ts +4 -0
- package/src/server/appearance-actions.ts +21 -0
- package/src/server/attachments.ts +35 -0
- package/src/server/auth-actions.ts +2 -0
- package/src/server/auth-form-state.ts +28 -0
- package/src/server/auto-watch.ts +31 -0
- package/src/server/ban-filter-admin-actions.ts +5 -0
- package/src/server/confirm.ts +19 -0
- package/src/server/content-actions.ts +129 -19
- package/src/server/content-admin-actions.ts +31 -0
- package/src/server/discovery.ts +11 -1
- package/src/server/feed-routes.ts +13 -4
- package/src/server/feed-token-actions.ts +61 -0
- package/src/server/feed-token.ts +78 -0
- package/src/server/fixture-post-repo.ts +18 -0
- package/src/server/forum-admin-actions.ts +23 -16
- package/src/server/forum-admin.ts +10 -1
- package/src/server/inline-moderation-actions.ts +6 -0
- package/src/server/message-actions.ts +9 -0
- package/src/server/navigation-admin-actions.ts +6 -0
- package/src/server/plugin-admin.ts +7 -2
- package/src/server/plugin-pages.ts +50 -0
- package/src/server/plugin-panel.ts +34 -1
- package/src/server/plugin-routes.ts +8 -0
- package/src/server/plugin-view.tsx +46 -7
- package/src/server/poll-actions.ts +13 -1
- package/src/server/presence.ts +23 -0
- package/src/server/reply-core.ts +8 -1
- package/src/server/report-actions.ts +13 -2
- package/src/server/reputation-actions.ts +12 -1
- package/src/server/search-page.ts +8 -2
- package/src/server/search.ts +18 -5
- package/src/server/subscription-actions.ts +9 -0
- package/src/server/thread-core.ts +10 -2
- package/src/server/thread-tool-actions.ts +6 -0
- package/src/server/unread-goto.ts +28 -0
- package/src/server/upgrade-notice.ts +1 -1
- package/src/server/usercp-actions.ts +31 -1
- package/src/server/viewer-preferences.ts +5 -0
- package/src/server/webhook-actions.ts +98 -0
- package/src/server/webhooks-admin.ts +122 -0
- package/src/theme/contract.fixture.ts +36 -5
- package/src/view/account-copy.ts +39 -0
- package/src/view/admin-copy.ts +4 -9
- package/src/view/admin-nav.ts +6 -0
- package/src/view/admin-panel-copy.ts +23 -0
- package/src/view/attachments.ts +33 -1
- package/src/view/board-index.ts +31 -2
- package/src/view/board-title.ts +1 -0
- package/src/view/content-copy.ts +22 -12
- package/src/view/copy.ts +17 -0
- package/src/view/discovery-view.ts +2 -1
- package/src/view/drafts.ts +45 -0
- package/src/view/editor-toolbar.ts +14 -0
- package/src/view/forum-display.ts +39 -7
- package/src/view/modcp-nav.ts +32 -0
- package/src/view/moderation-copy.ts +20 -0
- package/src/view/notifications.ts +1 -0
- package/src/view/plugin-panel.ts +13 -0
- package/src/view/poll-vote.ts +46 -0
- package/src/view/post-link.ts +8 -1
- package/src/view/progressive-enhancement.ts +5 -0
- package/src/view/shell.ts +2 -1
- package/src/view/subscriptions.ts +1 -0
- package/src/view/thread-view.ts +41 -2
- package/src/view/usercp-nav.ts +6 -0
- 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
|
-
|
|
144
|
-
<
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
<
|
|
423
|
-
<
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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 {
|
|
6
|
-
import {
|
|
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
|
-
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
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 (
|
|
131
|
-
return
|
|
140
|
+
if (kind === 'negative')
|
|
141
|
+
return effective
|
|
132
142
|
? fromCopy(copy, 'adminForum.perm.required')
|
|
133
143
|
: fromCopy(copy, 'adminForum.perm.notRequired')
|
|
134
|
-
return String(
|
|
144
|
+
return String(effective)
|
|
135
145
|
}
|
|
136
146
|
|
|
137
|
-
function
|
|
138
|
-
|
|
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
|
|
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'
|
|
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
|
|
199
|
-
|
|
200
|
-
|
|
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 =
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
216
|
-
}).filter((section) => section.cells.length > 0)
|
|
221
|
+
.filter((section) => section.rows.length > 0)
|
|
217
222
|
|
|
218
|
-
const leftover =
|
|
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',
|
|
226
|
+
: [...sections, { labelKey: 'adminForum.permGroup.other', rows: leftover }]
|
|
222
227
|
}
|
|
223
228
|
|
|
224
229
|
const SEGMENT =
|
|
225
|
-
'flex-1 cursor-pointer px-2
|
|
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
|
|
228
|
-
|
|
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
|
-
<
|
|
253
|
-
<
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
<
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
271
|
-
|
|
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
|
-
<
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
|
408
|
+
export function ForumPermissionMatrixForm({
|
|
293
409
|
forumId,
|
|
294
|
-
|
|
410
|
+
columns,
|
|
411
|
+
fields,
|
|
295
412
|
forumTitles,
|
|
296
413
|
copy,
|
|
297
414
|
}: {
|
|
298
415
|
forumId: number
|
|
299
|
-
|
|
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(
|
|
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
|
-
<
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
{
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
424
|
-
<
|
|
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
|
-
</
|
|
430
|
+
</TextLink>
|
|
433
431
|
</span>
|
|
434
432
|
</li>
|
|
435
433
|
)
|