@meith/web 0.23.0 → 0.23.2
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]/new/page.tsx +7 -5
- package/app/(board)/thread/[slug]/edit/page.tsx +7 -5
- package/app/(board)/thread/[slug]/page.tsx +19 -1
- package/app/(board)/thread/[slug]/reply/page.tsx +7 -5
- package/package.json +48 -48
- package/src/components/admin/forum-forms.tsx +172 -54
- package/src/components/admin/marketplace-listing.tsx +1 -1
- package/src/components/content/composer-recovery.tsx +19 -1
- package/src/components/content/edit-post-form.tsx +3 -2
- package/src/components/content/markdown-editor.tsx +123 -104
- package/src/components/content/new-thread-form.tsx +3 -2
- package/src/components/content/reply-form.tsx +3 -2
- package/src/components/moderation/thread-surgery-form.tsx +51 -47
- package/src/components/moderation/thread-tools-form.tsx +67 -87
- package/src/server/marketplace-admin.ts +2 -2
- package/src/server/upgrade-notice.ts +1 -1
- package/src/theme/contract.fixture.ts +2 -2
- package/src/view/admin-copy.ts +9 -0
- package/src/view/content-copy.ts +11 -6
- package/src/view/forum-display.ts +1 -0
|
@@ -70,6 +70,11 @@ export default async function NewThreadPage({ params }: { params: Promise<{ slug
|
|
|
70
70
|
viewerRef(actor),
|
|
71
71
|
)
|
|
72
72
|
|
|
73
|
+
const editorToolbar =
|
|
74
|
+
toolbarModel === null ? undefined : (
|
|
75
|
+
<EditorToolbar {...toolbarModel} copy={slotCopy(theme, 'EditorToolbar', translator)} />
|
|
76
|
+
)
|
|
77
|
+
|
|
73
78
|
const formModel = await filterView(
|
|
74
79
|
'view.post-form',
|
|
75
80
|
{
|
|
@@ -89,13 +94,10 @@ export default async function NewThreadPage({ params }: { params: Promise<{ slug
|
|
|
89
94
|
? null
|
|
90
95
|
: await drafts.find(actor.userId, id, null)
|
|
91
96
|
}
|
|
92
|
-
toolbar=
|
|
97
|
+
toolbar={editorToolbar}
|
|
93
98
|
/>
|
|
94
99
|
) : null,
|
|
95
|
-
toolbar:
|
|
96
|
-
toolbarModel === null ? null : (
|
|
97
|
-
<EditorToolbar {...toolbarModel} copy={slotCopy(theme, 'EditorToolbar', translator)} />
|
|
98
|
-
),
|
|
100
|
+
toolbar: null,
|
|
99
101
|
},
|
|
100
102
|
},
|
|
101
103
|
viewerRef(actor),
|
|
@@ -68,6 +68,11 @@ export default async function EditPostPage({
|
|
|
68
68
|
viewerRef(await getActor()),
|
|
69
69
|
)
|
|
70
70
|
|
|
71
|
+
const editorToolbar =
|
|
72
|
+
toolbarModel === null ? undefined : (
|
|
73
|
+
<EditorToolbar {...toolbarModel} copy={slotCopy(theme, 'EditorToolbar', translator)} />
|
|
74
|
+
)
|
|
75
|
+
|
|
71
76
|
const formModel = await filterView(
|
|
72
77
|
'view.post-form',
|
|
73
78
|
{
|
|
@@ -84,16 +89,13 @@ export default async function EditPostPage({
|
|
|
84
89
|
postId={post}
|
|
85
90
|
message={scope.target.post.message}
|
|
86
91
|
reason={null}
|
|
87
|
-
toolbar=
|
|
92
|
+
toolbar={editorToolbar}
|
|
88
93
|
/>
|
|
89
94
|
)}
|
|
90
95
|
{scope.mayDelete && <DeletePostForm copy={formCopy} threadId={thread} postId={post} />}
|
|
91
96
|
</>
|
|
92
97
|
),
|
|
93
|
-
toolbar:
|
|
94
|
-
toolbarModel === null ? null : (
|
|
95
|
-
<EditorToolbar {...toolbarModel} copy={slotCopy(theme, 'EditorToolbar', translator)} />
|
|
96
|
-
),
|
|
98
|
+
toolbar: null,
|
|
97
99
|
},
|
|
98
100
|
},
|
|
99
101
|
viewerRef(await getActor()),
|
|
@@ -41,6 +41,7 @@ import { followFormCopy } from '@/view/account-copy'
|
|
|
41
41
|
import { attachmentsByPost } from '@/view/attachments'
|
|
42
42
|
import { buildBreadcrumb } from '@/view/breadcrumb'
|
|
43
43
|
import { replyFormCopy, threadRatingCopy } from '@/view/content-copy'
|
|
44
|
+
import { buildEditorToolbarModel } from '@/view/editor-toolbar'
|
|
44
45
|
import {
|
|
45
46
|
anyInlineTool,
|
|
46
47
|
INLINE_FORM_ID,
|
|
@@ -402,6 +403,7 @@ export default async function ThreadPage({
|
|
|
402
403
|
const PostActions = requireSlot(theme, 'PostActions')
|
|
403
404
|
const Pagination = requireSlot(theme, 'Pagination')
|
|
404
405
|
const QuickReply = requireSlot(theme, 'QuickReply')
|
|
406
|
+
const EditorToolbar = requireSlot(theme, 'EditorToolbar')
|
|
405
407
|
const translator = await getTranslator()
|
|
406
408
|
const toolNotice = query.tool === undefined ? undefined : TOOL_NOTICE_KEYS[query.tool]
|
|
407
409
|
|
|
@@ -528,6 +530,14 @@ export default async function ThreadPage({
|
|
|
528
530
|
forum: matrix,
|
|
529
531
|
allowsAttachments: replyRules?.allowAttachments === true,
|
|
530
532
|
}
|
|
533
|
+
const quickReplyAttachable = canAttach(actor, replyTarget)
|
|
534
|
+
const quickReplyToolbar = !canReply
|
|
535
|
+
? null
|
|
536
|
+
: await filterView(
|
|
537
|
+
'view.editor-toolbar',
|
|
538
|
+
buildEditorToolbarModel({ attachments: quickReplyAttachable, t: translator }),
|
|
539
|
+
viewerRef(actor),
|
|
540
|
+
)
|
|
531
541
|
const quickReplyForm = !canReply ? null : (
|
|
532
542
|
<>
|
|
533
543
|
<QuoteInPlace threadId={thread.id} />
|
|
@@ -538,12 +548,20 @@ export default async function ThreadPage({
|
|
|
538
548
|
seenLastPostId={thread.lastPost?.postId ?? null}
|
|
539
549
|
prefill=""
|
|
540
550
|
canSubscribe={authorizer.can(actor, 'forum.subscribe', replyTarget)}
|
|
541
|
-
attachmentLimits={
|
|
551
|
+
attachmentLimits={quickReplyAttachable ? attachmentLimits(replyTarget) : null}
|
|
542
552
|
draft={
|
|
543
553
|
actor.userId === null || drafts === null
|
|
544
554
|
? null
|
|
545
555
|
: await drafts.find(actor.userId, forum.id, thread.id)
|
|
546
556
|
}
|
|
557
|
+
toolbar={
|
|
558
|
+
quickReplyToolbar === null ? undefined : (
|
|
559
|
+
<EditorToolbar
|
|
560
|
+
{...quickReplyToolbar}
|
|
561
|
+
copy={slotCopy(theme, 'EditorToolbar', translator)}
|
|
562
|
+
/>
|
|
563
|
+
)
|
|
564
|
+
}
|
|
547
565
|
collapsible
|
|
548
566
|
/>
|
|
549
567
|
</>
|
|
@@ -94,6 +94,11 @@ export default async function ReplyPage({
|
|
|
94
94
|
viewerRef(actor),
|
|
95
95
|
)
|
|
96
96
|
|
|
97
|
+
const editorToolbar =
|
|
98
|
+
toolbarModel === null ? undefined : (
|
|
99
|
+
<EditorToolbar {...toolbarModel} copy={slotCopy(theme, 'EditorToolbar', translator)} />
|
|
100
|
+
)
|
|
101
|
+
|
|
97
102
|
const formModel = await filterView(
|
|
98
103
|
'view.post-form',
|
|
99
104
|
{
|
|
@@ -114,14 +119,11 @@ export default async function ReplyPage({
|
|
|
114
119
|
? null
|
|
115
120
|
: await drafts.find(actor.userId, target.forum.id, target.threadId)
|
|
116
121
|
}
|
|
117
|
-
toolbar=
|
|
122
|
+
toolbar={editorToolbar}
|
|
118
123
|
/>
|
|
119
124
|
</>
|
|
120
125
|
),
|
|
121
|
-
toolbar:
|
|
122
|
-
toolbarModel === null ? null : (
|
|
123
|
-
<EditorToolbar {...toolbarModel} copy={slotCopy(theme, 'EditorToolbar', translator)} />
|
|
124
|
-
),
|
|
126
|
+
toolbar: null,
|
|
125
127
|
},
|
|
126
128
|
},
|
|
127
129
|
viewerRef(actor),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meith/web",
|
|
3
|
-
"version": "0.23.
|
|
3
|
+
"version": "0.23.2",
|
|
4
4
|
"description": "The board itself: the Next.js app, and the forum-web bin that materializes it into an external board workspace.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -43,53 +43,53 @@
|
|
|
43
43
|
"react-dom": "19.2.8",
|
|
44
44
|
"tailwindcss": "^4.3.3",
|
|
45
45
|
"typescript": "7.0.2",
|
|
46
|
-
"@meith/accounts": "0.23.
|
|
47
|
-
"@meith/admin": "0.23.
|
|
48
|
-
"@meith/antispam": "0.23.
|
|
49
|
-
"@meith/api": "0.23.
|
|
50
|
-
"@meith/attachments": "0.23.
|
|
51
|
-
"@meith/authorization": "0.23.
|
|
52
|
-
"@meith/avatars": "0.23.
|
|
53
|
-
"@meith/core": "0.23.
|
|
54
|
-
"@meith/db": "0.23.
|
|
55
|
-
"@meith/demo": "0.23.
|
|
56
|
-
"@meith/drafts": "0.23.
|
|
57
|
-
"@meith/drivers": "0.23.
|
|
58
|
-
"@meith/events": "0.23.
|
|
59
|
-
"@meith/forums": "0.23.
|
|
60
|
-
"@meith/groups": "0.23.
|
|
61
|
-
"@meith/i18n": "0.23.
|
|
62
|
-
"@meith/import": "0.23.
|
|
63
|
-
"@meith/install": "0.23.
|
|
64
|
-
"@meith/mail": "0.23.
|
|
65
|
-
"@meith/markdown": "0.23.
|
|
66
|
-
"@meith/marketplace": "0.23.
|
|
67
|
-
"@meith/messages": "0.23.
|
|
68
|
-
"@meith/moderation": "0.23.
|
|
69
|
-
"@meith/notifications": "0.23.
|
|
70
|
-
"@meith/plugin-calendar": "0.23.
|
|
71
|
-
"@meith/plugin-dues": "0.23.
|
|
72
|
-
"@meith/plugin-kit": "0.23.
|
|
73
|
-
"@meith/polls": "0.23.
|
|
74
|
-
"@meith/posts": "0.23.
|
|
75
|
-
"@meith/profile-fields": "0.23.
|
|
76
|
-
"@meith/relations": "0.23.
|
|
77
|
-
"@meith/reputation": "0.23.
|
|
78
|
-
"@meith/
|
|
79
|
-
"@meith/
|
|
80
|
-
"@meith/
|
|
81
|
-
"@meith/signatures": "0.23.
|
|
82
|
-
"@meith/subscriptions": "0.23.
|
|
83
|
-
"@meith/tasks": "0.23.
|
|
84
|
-
"@meith/theme-clubhouse": "0.23.
|
|
85
|
-
"@meith/theme-
|
|
86
|
-
"@meith/theme-
|
|
87
|
-
"@meith/theme-midnight": "0.23.
|
|
88
|
-
"@meith/theme-phasebook": "0.23.
|
|
89
|
-
"@meith/theme-raidframe": "0.23.
|
|
90
|
-
"@meith/threads": "0.23.
|
|
91
|
-
"@meith/ui": "0.23.
|
|
92
|
-
"@meith/upgrade": "0.23.
|
|
46
|
+
"@meith/accounts": "0.23.2",
|
|
47
|
+
"@meith/admin": "0.23.2",
|
|
48
|
+
"@meith/antispam": "0.23.2",
|
|
49
|
+
"@meith/api": "0.23.2",
|
|
50
|
+
"@meith/attachments": "0.23.2",
|
|
51
|
+
"@meith/authorization": "0.23.2",
|
|
52
|
+
"@meith/avatars": "0.23.2",
|
|
53
|
+
"@meith/core": "0.23.2",
|
|
54
|
+
"@meith/db": "0.23.2",
|
|
55
|
+
"@meith/demo": "0.23.2",
|
|
56
|
+
"@meith/drafts": "0.23.2",
|
|
57
|
+
"@meith/drivers": "0.23.2",
|
|
58
|
+
"@meith/events": "0.23.2",
|
|
59
|
+
"@meith/forums": "0.23.2",
|
|
60
|
+
"@meith/groups": "0.23.2",
|
|
61
|
+
"@meith/i18n": "0.23.2",
|
|
62
|
+
"@meith/import": "0.23.2",
|
|
63
|
+
"@meith/install": "0.23.2",
|
|
64
|
+
"@meith/mail": "0.23.2",
|
|
65
|
+
"@meith/markdown": "0.23.2",
|
|
66
|
+
"@meith/marketplace": "0.23.2",
|
|
67
|
+
"@meith/messages": "0.23.2",
|
|
68
|
+
"@meith/moderation": "0.23.2",
|
|
69
|
+
"@meith/notifications": "0.23.2",
|
|
70
|
+
"@meith/plugin-calendar": "0.23.2",
|
|
71
|
+
"@meith/plugin-dues": "0.23.2",
|
|
72
|
+
"@meith/plugin-kit": "0.23.2",
|
|
73
|
+
"@meith/polls": "0.23.2",
|
|
74
|
+
"@meith/posts": "0.23.2",
|
|
75
|
+
"@meith/profile-fields": "0.23.2",
|
|
76
|
+
"@meith/relations": "0.23.2",
|
|
77
|
+
"@meith/reputation": "0.23.2",
|
|
78
|
+
"@meith/runtime": "0.23.2",
|
|
79
|
+
"@meith/settings": "0.23.2",
|
|
80
|
+
"@meith/search": "0.23.2",
|
|
81
|
+
"@meith/signatures": "0.23.2",
|
|
82
|
+
"@meith/subscriptions": "0.23.2",
|
|
83
|
+
"@meith/tasks": "0.23.2",
|
|
84
|
+
"@meith/theme-clubhouse": "0.23.2",
|
|
85
|
+
"@meith/theme-kit": "0.23.2",
|
|
86
|
+
"@meith/theme-default": "0.23.2",
|
|
87
|
+
"@meith/theme-midnight": "0.23.2",
|
|
88
|
+
"@meith/theme-phasebook": "0.23.2",
|
|
89
|
+
"@meith/theme-raidframe": "0.23.2",
|
|
90
|
+
"@meith/threads": "0.23.2",
|
|
91
|
+
"@meith/ui": "0.23.2",
|
|
92
|
+
"@meith/upgrade": "0.23.2"
|
|
93
93
|
},
|
|
94
94
|
"scripts": {
|
|
95
95
|
"dev": "next dev",
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useActionState } from 'react'
|
|
4
4
|
|
|
5
5
|
import type { MatrixCell, MatrixRow } from '@meith/authorization'
|
|
6
|
+
import { cn, Disclosure } from '@meith/ui'
|
|
6
7
|
|
|
7
8
|
import { PANEL_CARD, PANEL_LIST, PANEL_ROW } from '@/components/shell/panel-list'
|
|
8
9
|
import { EMPTY_STATE } from '@/server/auth-form-state'
|
|
@@ -153,38 +154,145 @@ function effectiveLabel(
|
|
|
153
154
|
})
|
|
154
155
|
}
|
|
155
156
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
157
|
+
const PERM_GROUPS: readonly { readonly labelKey: string; readonly fields: readonly string[] }[] = [
|
|
158
|
+
{
|
|
159
|
+
labelKey: 'adminForum.permGroup.viewing',
|
|
160
|
+
fields: ['canView', 'canViewThreads', 'canViewOthersThreads', 'canSearch'],
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
labelKey: 'adminForum.permGroup.posting',
|
|
164
|
+
fields: [
|
|
165
|
+
'canPostThreads',
|
|
166
|
+
'canPostReplies',
|
|
167
|
+
'canPostPolls',
|
|
168
|
+
'canVotePolls',
|
|
169
|
+
'canRateThreads',
|
|
170
|
+
'canSubscribe',
|
|
171
|
+
],
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
labelKey: 'adminForum.permGroup.ownContent',
|
|
175
|
+
fields: ['canEditOwnPosts', 'canDeleteOwnPosts', 'canDeleteOwnThreads', 'editTimeLimitMinutes'],
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
labelKey: 'adminForum.permGroup.moderation',
|
|
179
|
+
fields: [
|
|
180
|
+
'canEditOthersPosts',
|
|
181
|
+
'canSoftDeletePosts',
|
|
182
|
+
'canViewUnapproved',
|
|
183
|
+
'canViewDeleted',
|
|
184
|
+
'canApproveContent',
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
labelKey: 'adminForum.permGroup.attachments',
|
|
189
|
+
fields: [
|
|
190
|
+
'canUploadAttachments',
|
|
191
|
+
'canDownloadAttachments',
|
|
192
|
+
'maxAttachmentsPerPost',
|
|
193
|
+
'maxAttachmentSizeKb',
|
|
194
|
+
],
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
labelKey: 'adminForum.permGroup.approvals',
|
|
198
|
+
fields: ['requiresThreadApproval', 'requiresPostApproval', 'requiresApprovalOnEdit'],
|
|
199
|
+
},
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
function firstSentence(text: string): string {
|
|
203
|
+
const at = text.indexOf('. ')
|
|
204
|
+
return at === -1 ? text : text.slice(0, at + 1)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function groupCells(
|
|
208
|
+
cells: readonly MatrixCell[],
|
|
209
|
+
): readonly { readonly labelKey: string; readonly cells: readonly MatrixCell[] }[] {
|
|
210
|
+
const byKey = new Map(cells.map((cell) => [cell.key, cell]))
|
|
211
|
+
const taken = new Set<string>()
|
|
212
|
+
const sections = PERM_GROUPS.map((group) => {
|
|
213
|
+
const found = group.fields.flatMap((key) => {
|
|
214
|
+
const cell = byKey.get(key)
|
|
215
|
+
if (cell === undefined) return []
|
|
216
|
+
taken.add(key)
|
|
217
|
+
return [cell]
|
|
218
|
+
})
|
|
219
|
+
return { labelKey: group.labelKey, cells: found }
|
|
220
|
+
}).filter((section) => section.cells.length > 0)
|
|
221
|
+
|
|
222
|
+
const leftover = cells.filter((cell) => !taken.has(cell.key))
|
|
223
|
+
return leftover.length === 0
|
|
224
|
+
? sections
|
|
225
|
+
: [...sections, { labelKey: 'adminForum.permGroup.other', cells: leftover }]
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const SEGMENT =
|
|
229
|
+
'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'
|
|
169
230
|
|
|
231
|
+
function TriState({ cell, copy }: { cell: MatrixCell; copy: Copy }) {
|
|
170
232
|
const negative = cell.kind === 'negative'
|
|
233
|
+
const options = [
|
|
234
|
+
{
|
|
235
|
+
value: 'inherit',
|
|
236
|
+
label: fromCopy(copy, 'adminForum.perm.inherit'),
|
|
237
|
+
on: 'has-[:checked]:bg-secondary has-[:checked]:text-secondary-foreground',
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
value: 'grant',
|
|
241
|
+
label: negative
|
|
242
|
+
? fromCopy(copy, 'adminForum.perm.requiredOption')
|
|
243
|
+
: fromCopy(copy, 'adminForum.perm.grant'),
|
|
244
|
+
on: 'has-[:checked]:bg-primary has-[:checked]:text-primary-foreground',
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
value: 'deny',
|
|
248
|
+
label: negative
|
|
249
|
+
? fromCopy(copy, 'adminForum.perm.notRequiredOption')
|
|
250
|
+
: fromCopy(copy, 'adminForum.perm.deny'),
|
|
251
|
+
on: 'has-[:checked]:bg-destructive has-[:checked]:text-destructive-foreground',
|
|
252
|
+
},
|
|
253
|
+
]
|
|
254
|
+
|
|
171
255
|
return (
|
|
172
|
-
<
|
|
173
|
-
<
|
|
174
|
-
|
|
175
|
-
{
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
256
|
+
<fieldset className="flex w-full shrink-0 divide-x divide-border overflow-hidden rounded-md border border-border sm:w-56">
|
|
257
|
+
<legend className="sr-only">{firstSentence(cell.description)}</legend>
|
|
258
|
+
{options.map((option) => (
|
|
259
|
+
<label key={option.value} className={cn(SEGMENT, option.on)}>
|
|
260
|
+
<input
|
|
261
|
+
type="radio"
|
|
262
|
+
name={cell.key}
|
|
263
|
+
value={option.value}
|
|
264
|
+
defaultChecked={cell.control === option.value}
|
|
265
|
+
className="sr-only"
|
|
266
|
+
/>
|
|
267
|
+
{option.label}
|
|
268
|
+
</label>
|
|
269
|
+
))}
|
|
270
|
+
</fieldset>
|
|
185
271
|
)
|
|
186
272
|
}
|
|
187
273
|
|
|
274
|
+
function CellControl({ cell, copy }: { cell: MatrixCell; copy: Copy }) {
|
|
275
|
+
if (cell.kind !== 'numeric') return <TriState cell={cell} copy={copy} />
|
|
276
|
+
|
|
277
|
+
return (
|
|
278
|
+
<input
|
|
279
|
+
type="number"
|
|
280
|
+
name={cell.key}
|
|
281
|
+
min={0}
|
|
282
|
+
placeholder={fromCopy(copy, 'adminForum.perm.inherit')}
|
|
283
|
+
defaultValue={cell.control}
|
|
284
|
+
className={cn(INPUT, 'w-full shrink-0 sm:w-56')}
|
|
285
|
+
/>
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function overridesAside(row: MatrixRow, copy: Copy): string {
|
|
290
|
+
const count = row.cells.filter((cell) => cell.stored !== null).length
|
|
291
|
+
return count === 0
|
|
292
|
+
? fromCopy(copy, 'adminForum.inheritsAll')
|
|
293
|
+
: formatFromCopy(copy, 'adminForum.overridesSet', { count })
|
|
294
|
+
}
|
|
295
|
+
|
|
188
296
|
export function ForumPermissionRowForm({
|
|
189
297
|
forumId,
|
|
190
298
|
row,
|
|
@@ -199,36 +307,46 @@ export function ForumPermissionRowForm({
|
|
|
199
307
|
const [state, action] = useActionState(saveForumPermissionsAction, EMPTY_STATE)
|
|
200
308
|
|
|
201
309
|
return (
|
|
202
|
-
<
|
|
203
|
-
<
|
|
204
|
-
|
|
205
|
-
<
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
)
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
310
|
+
<Disclosure summary={row.groupTitle} aside={overridesAside(row, copy)}>
|
|
311
|
+
<form action={action} className="flex flex-col gap-5">
|
|
312
|
+
<FormError message={state.error} />
|
|
313
|
+
<input type="hidden" name="forumId" value={forumId} />
|
|
314
|
+
<input type="hidden" name="groupId" value={row.groupId} />
|
|
315
|
+
|
|
316
|
+
{groupCells(row.cells).map((section) => (
|
|
317
|
+
<section key={section.labelKey} className="flex flex-col gap-2.5">
|
|
318
|
+
<h4 className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
|
|
319
|
+
{fromCopy(copy, section.labelKey)}
|
|
320
|
+
</h4>
|
|
321
|
+
{section.cells.map((cell) => (
|
|
322
|
+
<div
|
|
323
|
+
key={cell.key}
|
|
324
|
+
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"
|
|
325
|
+
>
|
|
326
|
+
<div className="min-w-0">
|
|
327
|
+
<p className="text-sm font-medium" title={cell.description}>
|
|
328
|
+
{firstSentence(cell.description)}
|
|
329
|
+
</p>
|
|
330
|
+
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
331
|
+
{effectiveLabel(cell, forumTitles, copy)}
|
|
332
|
+
</p>
|
|
333
|
+
</div>
|
|
334
|
+
<CellControl cell={cell} copy={copy} />
|
|
335
|
+
</div>
|
|
336
|
+
))}
|
|
337
|
+
</section>
|
|
223
338
|
))}
|
|
224
|
-
</div>
|
|
225
339
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
340
|
+
<div className="flex items-center gap-3">
|
|
341
|
+
<SubmitButton>
|
|
342
|
+
{formatFromCopy(copy, 'adminForum.saveGroup', { group: row.groupTitle })}
|
|
343
|
+
</SubmitButton>
|
|
344
|
+
{state.notice === 'saved' && (
|
|
345
|
+
<span className="text-xs text-muted-foreground">{fromCopy(copy, 'admin.saved')}</span>
|
|
346
|
+
)}
|
|
347
|
+
</div>
|
|
348
|
+
</form>
|
|
349
|
+
</Disclosure>
|
|
232
350
|
)
|
|
233
351
|
}
|
|
234
352
|
|
|
@@ -70,7 +70,7 @@ export function MarketplaceListingCard({
|
|
|
70
70
|
<ol className="flex flex-col gap-1">
|
|
71
71
|
{listing.installSteps.map((step) => (
|
|
72
72
|
<li key={step} className="text-xs">
|
|
73
|
-
{step.startsWith('
|
|
73
|
+
{step.startsWith('npm ') || step.startsWith('community ') ? (
|
|
74
74
|
<code className="rounded bg-muted px-1.5 py-0.5">{step}</code>
|
|
75
75
|
) : (
|
|
76
76
|
<span className="text-muted-foreground">{step}</span>
|
|
@@ -90,12 +90,13 @@ export function ComposerRecovery({
|
|
|
90
90
|
const latest = useRef('')
|
|
91
91
|
const saved = useRef('')
|
|
92
92
|
const submitting = useRef(false)
|
|
93
|
+
const held = useRef(false)
|
|
93
94
|
const [recovery, setRecovery] = useState<Backup | null>(null)
|
|
94
95
|
const [saveState, setSaveState] = useState<SaveState>('idle')
|
|
95
96
|
|
|
96
97
|
const persist = useCallback(async () => {
|
|
97
98
|
const form = root.current?.closest('form')
|
|
98
|
-
if (form === null || form === undefined) return
|
|
99
|
+
if (form === null || form === undefined || held.current) return
|
|
99
100
|
const payload = readPayload(form, { ...scope, title: '', message: '', prefixId: null })
|
|
100
101
|
const next = signature(payload)
|
|
101
102
|
latest.current = next
|
|
@@ -134,6 +135,8 @@ export function ComposerRecovery({
|
|
|
134
135
|
|
|
135
136
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
136
137
|
const onInput = () => {
|
|
138
|
+
submitting.current = false
|
|
139
|
+
held.current = false
|
|
137
140
|
const payload = readPayload(form, { ...scope, title: '', message: '', prefixId: null })
|
|
138
141
|
latest.current = signature(payload)
|
|
139
142
|
try {
|
|
@@ -146,6 +149,12 @@ export function ComposerRecovery({
|
|
|
146
149
|
timer = setTimeout(() => void persist(), AUTOSAVE_DELAY_MS)
|
|
147
150
|
}
|
|
148
151
|
const onBlur = () => void persist()
|
|
152
|
+
const onPointerDown = (event: Event) => {
|
|
153
|
+
const target = event.target
|
|
154
|
+
if (target instanceof Element && target.closest('button[type="submit"]') !== null) {
|
|
155
|
+
held.current = true
|
|
156
|
+
}
|
|
157
|
+
}
|
|
149
158
|
const onSubmit = (event: SubmitEvent) => {
|
|
150
159
|
const submitter = event.submitter
|
|
151
160
|
submitting.current =
|
|
@@ -163,6 +172,7 @@ export function ComposerRecovery({
|
|
|
163
172
|
|
|
164
173
|
form.addEventListener('input', onInput)
|
|
165
174
|
form.addEventListener('blur', onBlur, true)
|
|
175
|
+
form.addEventListener('pointerdown', onPointerDown, true)
|
|
166
176
|
form.addEventListener('submit', onSubmit)
|
|
167
177
|
window.addEventListener('pagehide', onPageHide)
|
|
168
178
|
window.addEventListener('beforeunload', onBeforeUnload)
|
|
@@ -170,12 +180,20 @@ export function ComposerRecovery({
|
|
|
170
180
|
clearTimeout(timer)
|
|
171
181
|
form.removeEventListener('input', onInput)
|
|
172
182
|
form.removeEventListener('blur', onBlur, true)
|
|
183
|
+
form.removeEventListener('pointerdown', onPointerDown, true)
|
|
173
184
|
form.removeEventListener('submit', onSubmit)
|
|
174
185
|
window.removeEventListener('pagehide', onPageHide)
|
|
175
186
|
window.removeEventListener('beforeunload', onBeforeUnload)
|
|
176
187
|
}
|
|
177
188
|
}, [persist, scope, serverUpdatedAt, storageKey])
|
|
178
189
|
|
|
190
|
+
useEffect(
|
|
191
|
+
() => () => {
|
|
192
|
+
if (submitting.current) localStorage.removeItem(storageKey)
|
|
193
|
+
},
|
|
194
|
+
[storageKey],
|
|
195
|
+
)
|
|
196
|
+
|
|
179
197
|
function restore(): void {
|
|
180
198
|
const form = root.current?.closest('form')
|
|
181
199
|
if (form === null || form === undefined || recovery === null) return
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
+
import type { ReactNode } from 'react'
|
|
3
4
|
import { useActionState } from 'react'
|
|
4
5
|
|
|
5
6
|
import { EMPTY_STATE } from '@/server/auth-form-state'
|
|
@@ -14,14 +15,14 @@ export function EditPostForm({
|
|
|
14
15
|
postId,
|
|
15
16
|
message,
|
|
16
17
|
reason,
|
|
17
|
-
toolbar
|
|
18
|
+
toolbar,
|
|
18
19
|
copy,
|
|
19
20
|
}: {
|
|
20
21
|
threadId: number
|
|
21
22
|
postId: number
|
|
22
23
|
message: string
|
|
23
24
|
reason: string | null
|
|
24
|
-
toolbar?:
|
|
25
|
+
toolbar?: ReactNode
|
|
25
26
|
copy: Copy
|
|
26
27
|
}) {
|
|
27
28
|
const [state, action] = useActionState(editPostAction, EMPTY_STATE)
|