@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.
@@ -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="external"
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="external"
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={canAttach(actor, replyTarget) ? attachmentLimits(replyTarget) : null}
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="external"
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.0",
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.0",
47
- "@meith/admin": "0.23.0",
48
- "@meith/antispam": "0.23.0",
49
- "@meith/api": "0.23.0",
50
- "@meith/attachments": "0.23.0",
51
- "@meith/authorization": "0.23.0",
52
- "@meith/avatars": "0.23.0",
53
- "@meith/core": "0.23.0",
54
- "@meith/db": "0.23.0",
55
- "@meith/demo": "0.23.0",
56
- "@meith/drafts": "0.23.0",
57
- "@meith/drivers": "0.23.0",
58
- "@meith/events": "0.23.0",
59
- "@meith/forums": "0.23.0",
60
- "@meith/groups": "0.23.0",
61
- "@meith/i18n": "0.23.0",
62
- "@meith/import": "0.23.0",
63
- "@meith/install": "0.23.0",
64
- "@meith/mail": "0.23.0",
65
- "@meith/markdown": "0.23.0",
66
- "@meith/marketplace": "0.23.0",
67
- "@meith/messages": "0.23.0",
68
- "@meith/moderation": "0.23.0",
69
- "@meith/notifications": "0.23.0",
70
- "@meith/plugin-calendar": "0.23.0",
71
- "@meith/plugin-dues": "0.23.0",
72
- "@meith/plugin-kit": "0.23.0",
73
- "@meith/polls": "0.23.0",
74
- "@meith/posts": "0.23.0",
75
- "@meith/profile-fields": "0.23.0",
76
- "@meith/relations": "0.23.0",
77
- "@meith/reputation": "0.23.0",
78
- "@meith/search": "0.23.0",
79
- "@meith/runtime": "0.23.0",
80
- "@meith/settings": "0.23.0",
81
- "@meith/signatures": "0.23.0",
82
- "@meith/subscriptions": "0.23.0",
83
- "@meith/tasks": "0.23.0",
84
- "@meith/theme-clubhouse": "0.23.0",
85
- "@meith/theme-default": "0.23.0",
86
- "@meith/theme-kit": "0.23.0",
87
- "@meith/theme-midnight": "0.23.0",
88
- "@meith/theme-phasebook": "0.23.0",
89
- "@meith/theme-raidframe": "0.23.0",
90
- "@meith/threads": "0.23.0",
91
- "@meith/ui": "0.23.0",
92
- "@meith/upgrade": "0.23.0"
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
- function CellControl({ cell, copy }: { cell: MatrixCell; copy: Copy }) {
157
- if (cell.kind === 'numeric') {
158
- return (
159
- <input
160
- type="number"
161
- name={cell.key}
162
- min={0}
163
- placeholder={fromCopy(copy, 'adminForum.perm.inherit')}
164
- defaultValue={cell.control}
165
- className={INPUT}
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
- <select name={cell.key} defaultValue={cell.control} className={INPUT}>
173
- <option value="inherit">{fromCopy(copy, 'adminForum.perm.inherit')}</option>
174
- <option value="grant">
175
- {negative
176
- ? fromCopy(copy, 'adminForum.perm.requiredOption')
177
- : fromCopy(copy, 'adminForum.perm.grant')}
178
- </option>
179
- <option value="deny">
180
- {negative
181
- ? fromCopy(copy, 'adminForum.perm.notRequiredOption')
182
- : fromCopy(copy, 'adminForum.perm.deny')}
183
- </option>
184
- </select>
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
- <form action={action} className={PANEL_CARD}>
203
- <FormError message={state.error} />
204
- <div className="flex items-center justify-between gap-3">
205
- <h3 className="text-base font-semibold tracking-tight">{row.groupTitle}</h3>
206
- {state.notice === 'saved' && (
207
- <span className="text-xs text-muted-foreground">{fromCopy(copy, 'admin.saved')}</span>
208
- )}
209
- </div>
210
-
211
- <input type="hidden" name="forumId" value={forumId} />
212
- <input type="hidden" name="groupId" value={row.groupId} />
213
-
214
- <div className="grid gap-3 sm:grid-cols-2">
215
- {row.cells.map((cell) => (
216
- <label key={cell.key} className="flex flex-col gap-1 text-sm">
217
- <span className="font-medium">{cell.description}</span>
218
- <CellControl cell={cell} copy={copy} />
219
- <span className="text-xs text-muted-foreground">
220
- {effectiveLabel(cell, forumTitles, copy)}
221
- </span>
222
- </label>
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
- <div>
227
- <SubmitButton>
228
- {formatFromCopy(copy, 'adminForum.saveGroup', { group: row.groupTitle })}
229
- </SubmitButton>
230
- </div>
231
- </form>
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('pnpm ') || step.startsWith('community ') ? (
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 = 'inline',
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?: 'inline' | 'external'
25
+ toolbar?: ReactNode
25
26
  copy: Copy
26
27
  }) {
27
28
  const [state, action] = useActionState(editPostAction, EMPTY_STATE)