@meith/web 0.31.0 → 0.33.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/layout.tsx +23 -8
- 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/admin-forms.tsx +57 -9
- 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/components/shell/view-tabs.tsx +1 -1
- package/src/server/admin-actions.ts +118 -49
- package/src/server/admin-settings-actions.ts +5 -0
- package/src/server/admin.ts +70 -2
- 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/cookies.ts +18 -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/push-actions.ts +9 -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/session-cookies.ts +18 -0
- 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/styles/globals.css +1 -1
- 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 +29 -1
- 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
|
@@ -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
|
)
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from '@/server/navigation-admin-actions'
|
|
11
11
|
|
|
12
12
|
import { FormError, PendingButton, SubmitButton } from '../auth/form-controls'
|
|
13
|
+
import { ConfirmDialog } from '../shell/confirm-dialog'
|
|
13
14
|
import { type Copy, fromCopy } from '../shell/copy'
|
|
14
15
|
import { INPUT, Saved } from './form-bits'
|
|
15
16
|
|
|
@@ -161,43 +162,59 @@ export function NavigationItemRowForm({
|
|
|
161
162
|
groups,
|
|
162
163
|
parents,
|
|
163
164
|
copy,
|
|
165
|
+
summary,
|
|
166
|
+
className,
|
|
167
|
+
style,
|
|
164
168
|
}: {
|
|
165
169
|
item: NavigationItemValues
|
|
166
170
|
audiences: readonly AudienceChoice[]
|
|
167
171
|
groups: readonly GroupChoice[]
|
|
168
172
|
parents: readonly ParentChoice[]
|
|
169
173
|
copy: Copy
|
|
174
|
+
summary: React.ReactNode
|
|
175
|
+
className?: string
|
|
176
|
+
style?: React.CSSProperties
|
|
170
177
|
}) {
|
|
171
178
|
const [state, action] = useActionState(updateNavigationItemAction, EMPTY_STATE)
|
|
172
179
|
const [removeState, removeAction] = useActionState(deleteNavigationItemAction, EMPTY_STATE)
|
|
173
180
|
|
|
174
181
|
return (
|
|
175
|
-
|
|
176
|
-
<
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
<
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
182
|
+
<>
|
|
183
|
+
<details className={className} style={style}>
|
|
184
|
+
<summary className="cursor-pointer py-2 text-xs font-medium text-muted-foreground hover:text-foreground">
|
|
185
|
+
{summary}
|
|
186
|
+
</summary>
|
|
187
|
+
|
|
188
|
+
<div className="flex flex-col gap-3 py-4">
|
|
189
|
+
<FormError message={state.error ?? removeState.error} />
|
|
190
|
+
<Saved when={state.notice === 'saved'}>{fromCopy(copy, 'admin.saved')}</Saved>
|
|
191
|
+
|
|
192
|
+
<form action={action} className="flex flex-col gap-3" noValidate>
|
|
193
|
+
<input type="hidden" name="id" value={item.id} />
|
|
194
|
+
<NavigationFields
|
|
195
|
+
audiences={audiences}
|
|
196
|
+
groups={groups}
|
|
197
|
+
parents={parents}
|
|
198
|
+
values={item}
|
|
199
|
+
copy={copy}
|
|
200
|
+
/>
|
|
201
|
+
|
|
202
|
+
<span className="min-w-28">
|
|
203
|
+
<SubmitButton>{fromCopy(copy, 'adminContent.save')}</SubmitButton>
|
|
204
|
+
</span>
|
|
205
|
+
</form>
|
|
206
|
+
|
|
207
|
+
<form action={removeAction}>
|
|
208
|
+
<input type="hidden" name="id" value={item.id} />
|
|
209
|
+
<PendingButton showWorking className="text-xs text-destructive hover:underline">
|
|
210
|
+
{fromCopy(copy, 'adminNavigation.removeThis')}
|
|
211
|
+
</PendingButton>
|
|
212
|
+
</form>
|
|
213
|
+
</div>
|
|
214
|
+
</details>
|
|
215
|
+
|
|
216
|
+
<ConfirmDialog confirm={removeState.confirm} action={removeAction} />
|
|
217
|
+
</>
|
|
201
218
|
)
|
|
202
219
|
}
|
|
203
220
|
|
|
@@ -404,21 +404,16 @@ export function NavigationTree({
|
|
|
404
404
|
</form>
|
|
405
405
|
</div>
|
|
406
406
|
|
|
407
|
-
<
|
|
407
|
+
<NavigationItemRowForm
|
|
408
|
+
item={row}
|
|
409
|
+
audiences={audiences}
|
|
410
|
+
groups={groups}
|
|
411
|
+
parents={parents}
|
|
412
|
+
copy={copy}
|
|
413
|
+
summary={formatFromCopy(copy, 'adminNavigation.tree.edit', { name: row.name })}
|
|
408
414
|
className="rounded-md border border-border bg-card px-3"
|
|
409
415
|
style={{ marginLeft: `${row.depth * INDENT_PX}px` }}
|
|
410
|
-
|
|
411
|
-
<summary className="cursor-pointer py-2 text-xs font-medium text-muted-foreground hover:text-foreground">
|
|
412
|
-
{formatFromCopy(copy, 'adminNavigation.tree.edit', { name: row.name })}
|
|
413
|
-
</summary>
|
|
414
|
-
<NavigationItemRowForm
|
|
415
|
-
item={row}
|
|
416
|
-
audiences={audiences}
|
|
417
|
-
groups={groups}
|
|
418
|
-
parents={parents}
|
|
419
|
-
copy={copy}
|
|
420
|
-
/>
|
|
421
|
-
</details>
|
|
416
|
+
/>
|
|
422
417
|
</li>
|
|
423
418
|
)
|
|
424
419
|
})}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
parseColour,
|
|
11
11
|
rgbToHex,
|
|
12
12
|
} from '@meith/theme-kit'
|
|
13
|
+
import { textLinkVariants } from '@meith/ui'
|
|
13
14
|
|
|
14
15
|
import { type Copy, formatFromCopy, fromCopy } from '../shell/copy'
|
|
15
16
|
|
|
@@ -167,7 +168,7 @@ export function OklchPicker({
|
|
|
167
168
|
<button
|
|
168
169
|
type="button"
|
|
169
170
|
onClick={() => onChange('')}
|
|
170
|
-
className=
|
|
171
|
+
className={textLinkVariants({ tone: 'inherit', size: 'xs' })}
|
|
171
172
|
>
|
|
172
173
|
{fromCopy(copy, 'adminTheme.oklch.clear')}
|
|
173
174
|
</button>
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
+
import { textLinkVariants } from '@meith/ui'
|
|
4
|
+
|
|
3
5
|
import { type ContrastCheck, checkContrast, contrastGrade, formatRatio } from '@/view/contrast'
|
|
4
6
|
import {
|
|
5
7
|
changeCounts,
|
|
@@ -14,8 +16,7 @@ import {
|
|
|
14
16
|
|
|
15
17
|
import { type Copy, formatFromCopy, fromCopy } from '../shell/copy'
|
|
16
18
|
|
|
17
|
-
const LINK =
|
|
18
|
-
'text-xs font-medium underline decoration-border underline-offset-2 hover:decoration-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
|
|
19
|
+
const LINK = `${textLinkVariants({ tone: 'inherit', size: 'xs' })} focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring`
|
|
19
20
|
|
|
20
21
|
function StateBadge({ change, copy }: { change: TokenChange; copy: Copy }) {
|
|
21
22
|
const [label, tone] =
|