@meith/web 0.23.1 → 0.23.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/app/(board)/[slug]/new/page.tsx +7 -5
  2. package/app/(board)/thread/[slug]/edit/page.tsx +7 -5
  3. package/app/(board)/thread/[slug]/page.tsx +19 -1
  4. package/app/(board)/thread/[slug]/reply/page.tsx +7 -5
  5. package/app/(board)/usercp/security/verify/page.tsx +3 -0
  6. package/app/admin/layout.tsx +4 -4
  7. package/package.json +48 -48
  8. package/src/components/account/credential-proof-form.tsx +14 -4
  9. package/src/components/account/two-factor-forms.tsx +4 -4
  10. package/src/components/admin/admin-forms.tsx +8 -13
  11. package/src/components/admin/badge-forms.tsx +6 -7
  12. package/src/components/admin/branding-forms.tsx +5 -10
  13. package/src/components/admin/form-bits.tsx +6 -4
  14. package/src/components/admin/forum-forms.tsx +180 -78
  15. package/src/components/admin/mail-test-card.tsx +2 -5
  16. package/src/components/admin/marketplace-forms.tsx +4 -5
  17. package/src/components/admin/marketplace-listing.tsx +1 -1
  18. package/src/components/admin/plugin-forms.tsx +2 -6
  19. package/src/components/admin/settings-form.tsx +2 -6
  20. package/src/components/admin/system-forms.tsx +2 -5
  21. package/src/components/auth/otp-field.tsx +142 -0
  22. package/src/components/auth/second-factor-form.tsx +4 -3
  23. package/src/components/content/composer-recovery.tsx +19 -1
  24. package/src/components/content/edit-post-form.tsx +3 -2
  25. package/src/components/content/markdown-editor.tsx +123 -104
  26. package/src/components/content/new-thread-form.tsx +3 -2
  27. package/src/components/content/reply-form.tsx +3 -2
  28. package/src/components/moderation/thread-surgery-form.tsx +51 -47
  29. package/src/components/moderation/thread-tools-form.tsx +67 -87
  30. package/src/server/marketplace-admin.ts +2 -2
  31. package/src/server/upgrade-notice.ts +1 -1
  32. package/src/theme/contract.fixture.ts +2 -2
  33. package/src/view/account-copy.ts +2 -0
  34. package/src/view/admin-copy.ts +9 -0
  35. package/src/view/admin-panel-copy.ts +2 -0
  36. package/src/view/auth-copy.ts +17 -10
  37. package/src/view/content-copy.ts +11 -6
  38. package/src/view/forum-display.ts +1 -0
@@ -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)
@@ -1,5 +1,6 @@
1
1
  'use client'
2
2
 
3
+ import type { ReactNode } from 'react'
3
4
  import { useCallback, useEffect, useId, useRef, useState } from 'react'
4
5
 
5
6
  import type { MemberSuggestion } from '@meith/accounts'
@@ -84,7 +85,7 @@ export interface MarkdownEditorProps {
84
85
  readonly hint?: React.ReactNode
85
86
  readonly scope?: PreviewScope
86
87
  readonly attachTo?: AttachmentTarget | undefined
87
- readonly toolbar?: 'inline' | 'external' | undefined
88
+ readonly toolbar?: ReactNode
88
89
  }
89
90
 
90
91
  export function MarkdownEditor({
@@ -99,7 +100,7 @@ export function MarkdownEditor({
99
100
  hint,
100
101
  scope = 'post',
101
102
  attachTo,
102
- toolbar = 'inline',
103
+ toolbar,
103
104
  }: MarkdownEditorProps) {
104
105
  const field = useRef<HTMLTextAreaElement>(null)
105
106
  const attachmentInput = useRef<HTMLInputElement>(null)
@@ -308,6 +309,9 @@ export function MarkdownEditor({
308
309
  }
309
310
 
310
311
  const writing = !enhanced || tab === 'write'
312
+ const onWriteTab = enhanced && tab === 'write'
313
+ const showThemedToolbar = toolbar != null && onWriteTab
314
+ const showBuiltInToolbar = toolbar == null && onWriteTab
311
315
 
312
316
  const tabClass = (active: boolean): string =>
313
317
  cn(
@@ -359,121 +363,136 @@ export function MarkdownEditor({
359
363
  )}
360
364
  </div>
361
365
 
362
- {toolbar === 'inline' && enhanced && tab === 'write' && (
363
- <div
364
- role="group"
365
- aria-label={fromCopy(copy, 'composer.formatting')}
366
- aria-controls={id}
367
- className="flex flex-wrap gap-0.5 rounded-md border border-border bg-muted/40 p-1"
368
- >
369
- {TOOLS.map((tool) => (
370
- <button
371
- key={tool.labelKey}
372
- type="button"
373
- onMouseDown={(event) => event.preventDefault()}
374
- onClick={() => {
375
- if (field.current !== null) runTool(field.current, tool, copy)
376
- }}
377
- title={
378
- tool.shortcut === undefined
379
- ? fromCopy(copy, tool.labelKey)
380
- : formatFromCopy(copy, 'composer.toolShortcut', {
381
- label: fromCopy(copy, tool.labelKey),
382
- key: tool.shortcut.toUpperCase(),
383
- })
384
- }
385
- aria-label={fromCopy(copy, tool.labelKey)}
386
- {...(tool.shortcut === undefined
387
- ? {}
388
- : { 'aria-keyshortcuts': `Control+${tool.shortcut}` })}
389
- className="min-w-8 rounded px-2 py-1 text-xs font-medium text-muted-foreground hover:bg-background hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
390
- >
391
- <span aria-hidden="true">{tool.glyph}</span>
392
- </button>
393
- ))}
394
-
395
- {attachTo !== undefined && (
396
- <button
397
- type="button"
398
- disabled={attachmentUpload === 'uploading'}
399
- onMouseDown={(event) => event.preventDefault()}
400
- onClick={() => attachmentInput.current?.click()}
401
- title={fromCopy(copy, 'composer.tool.attachment')}
402
- aria-label={fromCopy(copy, 'composer.tool.attachment')}
403
- className="min-w-8 rounded px-2 py-1 text-xs font-medium text-muted-foreground hover:bg-background hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:opacity-50"
404
- >
405
- <span aria-hidden="true">Attach</span>
406
- </button>
407
- )}
408
- </div>
409
- )}
410
-
411
- <div
412
- {...(enhanced
413
- ? { role: 'tabpanel', id: `${panelId}-write`, 'aria-labelledby': `${panelId}-write-tab` }
414
- : {})}
415
- hidden={!writing}
416
- className="relative"
417
- >
418
- <textarea
419
- ref={field}
420
- id={id}
421
- name={name}
422
- rows={rows}
423
- required={required === true && writing}
424
- defaultValue={defaultValue}
425
- {...(maxLength === undefined ? {} : { maxLength })}
426
- onKeyDown={onKeyDown}
427
- onPaste={onPaste}
428
- onInput={(event) => {
429
- fit()
430
- syncMentionToken(event.currentTarget)
431
- }}
432
- onSelect={(event) => syncMentionToken(event.currentTarget)}
433
- onBlur={() => setMentionToken(null)}
434
- {...(enhanced && mentionToken !== null && mentionMatches.length > 0
435
- ? {
436
- role: 'combobox',
437
- 'aria-expanded': true,
438
- 'aria-controls': `${panelId}-mentions`,
439
- 'aria-activedescendant': `${panelId}-mention-${mentionActive}`,
440
- 'aria-autocomplete': 'list' as const,
441
- }
442
- : {})}
443
- className="w-full rounded-md border border-input bg-card px-3 py-2 font-normal text-sm leading-relaxed focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
444
- />
366
+ <div className="flex flex-col">
367
+ {showThemedToolbar && (
368
+ <div className="overflow-hidden rounded-t-md border border-b-0 border-input">
369
+ {toolbar}
370
+ </div>
371
+ )}
445
372
 
446
- {enhanced && mentionToken !== null && mentionMatches.length > 0 && (
373
+ {showBuiltInToolbar && (
447
374
  <div
448
- id={`${panelId}-mentions`}
449
- role="listbox"
450
- aria-label={fromCopy(copy, 'composer.mention.suggestions')}
451
- className="absolute inset-x-0 top-full z-10 mt-1 max-h-48 overflow-y-auto rounded-md border border-border bg-card py-1 text-sm shadow-lg"
375
+ role="group"
376
+ aria-label={fromCopy(copy, 'composer.formatting')}
377
+ aria-controls={id}
378
+ className="flex flex-wrap gap-0.5 rounded-t-md border border-b-0 border-input bg-muted/40 p-1"
452
379
  >
453
- {mentionMatches.map((candidate, index) => (
380
+ {TOOLS.map((tool) => (
454
381
  <button
455
- key={candidate.id}
382
+ key={tool.labelKey}
456
383
  type="button"
457
- id={`${panelId}-mention-${index}`}
458
- role="option"
459
- aria-selected={index === mentionActive}
460
384
  onMouseDown={(event) => event.preventDefault()}
461
385
  onClick={() => {
462
- if (field.current !== null) applyMention(field.current, candidate)
386
+ if (field.current !== null) runTool(field.current, tool, copy)
463
387
  }}
464
- onMouseEnter={() => setMentionActive(index)}
465
- className={cn(
466
- 'block w-full px-3 py-1.5 text-start',
467
- index === mentionActive
468
- ? 'bg-muted text-foreground'
469
- : 'text-foreground hover:bg-muted',
470
- )}
388
+ title={
389
+ tool.shortcut === undefined
390
+ ? fromCopy(copy, tool.labelKey)
391
+ : formatFromCopy(copy, 'composer.toolShortcut', {
392
+ label: fromCopy(copy, tool.labelKey),
393
+ key: tool.shortcut.toUpperCase(),
394
+ })
395
+ }
396
+ aria-label={fromCopy(copy, tool.labelKey)}
397
+ {...(tool.shortcut === undefined
398
+ ? {}
399
+ : { 'aria-keyshortcuts': `Control+${tool.shortcut}` })}
400
+ className="min-w-8 rounded px-2 py-1 text-xs font-medium text-muted-foreground hover:bg-background hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
471
401
  >
472
- @{candidate.username}
402
+ <span aria-hidden="true">{tool.glyph}</span>
473
403
  </button>
474
404
  ))}
405
+
406
+ {attachTo !== undefined && (
407
+ <button
408
+ type="button"
409
+ disabled={attachmentUpload === 'uploading'}
410
+ onMouseDown={(event) => event.preventDefault()}
411
+ onClick={() => attachmentInput.current?.click()}
412
+ title={fromCopy(copy, 'composer.tool.attachment')}
413
+ aria-label={fromCopy(copy, 'composer.tool.attachment')}
414
+ className="min-w-8 rounded px-2 py-1 text-xs font-medium text-muted-foreground hover:bg-background hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:opacity-50"
415
+ >
416
+ <span aria-hidden="true">Attach</span>
417
+ </button>
418
+ )}
475
419
  </div>
476
420
  )}
421
+
422
+ <div
423
+ {...(enhanced
424
+ ? {
425
+ role: 'tabpanel',
426
+ id: `${panelId}-write`,
427
+ 'aria-labelledby': `${panelId}-write-tab`,
428
+ }
429
+ : {})}
430
+ hidden={!writing}
431
+ className="relative"
432
+ >
433
+ <textarea
434
+ ref={field}
435
+ id={id}
436
+ name={name}
437
+ rows={rows}
438
+ required={required === true && writing}
439
+ defaultValue={defaultValue}
440
+ {...(maxLength === undefined ? {} : { maxLength })}
441
+ onKeyDown={onKeyDown}
442
+ onPaste={onPaste}
443
+ onInput={(event) => {
444
+ fit()
445
+ syncMentionToken(event.currentTarget)
446
+ }}
447
+ onSelect={(event) => syncMentionToken(event.currentTarget)}
448
+ onBlur={() => setMentionToken(null)}
449
+ {...(enhanced && mentionToken !== null && mentionMatches.length > 0
450
+ ? {
451
+ role: 'combobox',
452
+ 'aria-expanded': true,
453
+ 'aria-controls': `${panelId}-mentions`,
454
+ 'aria-activedescendant': `${panelId}-mention-${mentionActive}`,
455
+ 'aria-autocomplete': 'list' as const,
456
+ }
457
+ : {})}
458
+ className={cn(
459
+ 'w-full rounded-md border border-input bg-card px-3 py-2 font-normal text-sm leading-relaxed focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
460
+ onWriteTab && 'rounded-t-none border-t-0',
461
+ )}
462
+ />
463
+
464
+ {enhanced && mentionToken !== null && mentionMatches.length > 0 && (
465
+ <div
466
+ id={`${panelId}-mentions`}
467
+ role="listbox"
468
+ aria-label={fromCopy(copy, 'composer.mention.suggestions')}
469
+ className="absolute inset-x-0 top-full z-10 mt-1 max-h-48 overflow-y-auto rounded-md border border-border bg-card py-1 text-sm shadow-lg"
470
+ >
471
+ {mentionMatches.map((candidate, index) => (
472
+ <button
473
+ key={candidate.id}
474
+ type="button"
475
+ id={`${panelId}-mention-${index}`}
476
+ role="option"
477
+ aria-selected={index === mentionActive}
478
+ onMouseDown={(event) => event.preventDefault()}
479
+ onClick={() => {
480
+ if (field.current !== null) applyMention(field.current, candidate)
481
+ }}
482
+ onMouseEnter={() => setMentionActive(index)}
483
+ className={cn(
484
+ 'block w-full px-3 py-1.5 text-start',
485
+ index === mentionActive
486
+ ? 'bg-muted text-foreground'
487
+ : 'text-foreground hover:bg-muted',
488
+ )}
489
+ >
490
+ @{candidate.username}
491
+ </button>
492
+ ))}
493
+ </div>
494
+ )}
495
+ </div>
477
496
  </div>
478
497
 
479
498
  {attachTo !== undefined && (
@@ -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 type { UploadLimits } from '@meith/attachments/limits'
@@ -29,7 +30,7 @@ export function NewThreadForm({
29
30
  canPostPoll,
30
31
  attachmentLimits,
31
32
  draft,
32
- toolbar = 'inline',
33
+ toolbar,
33
34
  copy,
34
35
  }: {
35
36
  forumId: number
@@ -39,7 +40,7 @@ export function NewThreadForm({
39
40
  canPostPoll: boolean
40
41
  attachmentLimits: UploadLimits | null
41
42
  draft: Draft | null
42
- toolbar?: 'inline' | 'external'
43
+ toolbar?: ReactNode
43
44
  copy: Copy
44
45
  }) {
45
46
  const [state, action] = useActionState(createThreadAction, EMPTY_STATE)
@@ -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 type { UploadLimits } from '@meith/attachments/limits'
@@ -24,7 +25,7 @@ export function ReplyForm({
24
25
  attachmentLimits,
25
26
  draft,
26
27
  collapsible = false,
27
- toolbar = 'inline',
28
+ toolbar,
28
29
  copy,
29
30
  }: {
30
31
  threadId: number
@@ -34,7 +35,7 @@ export function ReplyForm({
34
35
  attachmentLimits: UploadLimits | null
35
36
  draft: Draft | null
36
37
  collapsible?: boolean
37
- toolbar?: 'inline' | 'external'
38
+ toolbar?: ReactNode
38
39
  copy: Copy
39
40
  }) {
40
41
  const [state, action] = useActionState(createReplyAction, EMPTY_STATE)
@@ -36,59 +36,63 @@ export function ThreadSurgeryForm({
36
36
  return (
37
37
  <>
38
38
  {rights.split && splitPoints.length > 0 && (
39
- <form action={splitAction} className="flex flex-wrap items-center gap-2">
39
+ <form action={splitAction} className="flex flex-col gap-2">
40
+ <div className="flex flex-wrap items-center gap-2">
41
+ <input type="hidden" name="threadId" value={threadId} />
42
+ <label className="flex items-center gap-2 text-xs">
43
+ <span className="sr-only">{fromCopy(copy, 'moderationForm.surgery.fromSr')}</span>
44
+ <select name="fromPostId" className={FIELD}>
45
+ {splitPoints.map((point) => (
46
+ <option key={point.id} value={point.id}>
47
+ {formatFromCopy(copy, 'moderationForm.surgery.splitPoint', {
48
+ number: point.number,
49
+ author: point.author,
50
+ })}
51
+ </option>
52
+ ))}
53
+ </select>
54
+ </label>
55
+ <label className="flex items-center gap-2 text-xs">
56
+ <span className="sr-only">{fromCopy(copy, 'moderationForm.surgery.titleSr')}</span>
57
+ <input
58
+ type="text"
59
+ name="title"
60
+ required
61
+ minLength={3}
62
+ maxLength={150}
63
+ placeholder={fromCopy(copy, 'moderationForm.newThreadTitle')}
64
+ className={`${FIELD} w-48`}
65
+ />
66
+ </label>
67
+ <button type="submit" className={BUTTON}>
68
+ {fromCopy(copy, 'moderationForm.surgery.split')}
69
+ </button>
70
+ </div>
40
71
  <FormError message={splitState.error} />
41
- <input type="hidden" name="threadId" value={threadId} />
42
- <label className="flex items-center gap-2 text-xs">
43
- <span className="sr-only">{fromCopy(copy, 'moderationForm.surgery.fromSr')}</span>
44
- <select name="fromPostId" className={FIELD}>
45
- {splitPoints.map((point) => (
46
- <option key={point.id} value={point.id}>
47
- {formatFromCopy(copy, 'moderationForm.surgery.splitPoint', {
48
- number: point.number,
49
- author: point.author,
50
- })}
51
- </option>
52
- ))}
53
- </select>
54
- </label>
55
- <label className="flex items-center gap-2 text-xs">
56
- <span className="sr-only">{fromCopy(copy, 'moderationForm.surgery.titleSr')}</span>
57
- <input
58
- type="text"
59
- name="title"
60
- required
61
- minLength={3}
62
- maxLength={150}
63
- placeholder={fromCopy(copy, 'moderationForm.newThreadTitle')}
64
- className={`${FIELD} w-48`}
65
- />
66
- </label>
67
- <button type="submit" className={BUTTON}>
68
- {fromCopy(copy, 'moderationForm.surgery.split')}
69
- </button>
70
72
  </form>
71
73
  )}
72
74
 
73
75
  {rights.merge && (
74
- <form action={mergeAction} className="flex flex-wrap items-center gap-2">
76
+ <form action={mergeAction} className="flex flex-col gap-2">
77
+ <div className="flex flex-wrap items-center gap-2">
78
+ <input type="hidden" name="threadId" value={threadId} />
79
+ <label className="flex items-center gap-2 text-xs">
80
+ <span className="sr-only">{fromCopy(copy, 'moderationForm.surgery.mergeSr')}</span>
81
+ <input
82
+ type="number"
83
+ name="targetThreadId"
84
+ required
85
+ min={1}
86
+ step={1}
87
+ placeholder={fromCopy(copy, 'moderationForm.surgery.mergePlaceholder')}
88
+ className={`${FIELD} w-40`}
89
+ />
90
+ </label>
91
+ <button type="submit" className={`${BUTTON} border-destructive/40 text-destructive`}>
92
+ {fromCopy(copy, 'moderationForm.surgery.merge')}
93
+ </button>
94
+ </div>
75
95
  <FormError message={mergeState.error} />
76
- <input type="hidden" name="threadId" value={threadId} />
77
- <label className="flex items-center gap-2 text-xs">
78
- <span className="sr-only">{fromCopy(copy, 'moderationForm.surgery.mergeSr')}</span>
79
- <input
80
- type="number"
81
- name="targetThreadId"
82
- required
83
- min={1}
84
- step={1}
85
- placeholder={fromCopy(copy, 'moderationForm.surgery.mergePlaceholder')}
86
- className={`${FIELD} w-40`}
87
- />
88
- </label>
89
- <button type="submit" className={`${BUTTON} border-destructive/40 text-destructive`}>
90
- {fromCopy(copy, 'moderationForm.surgery.merge')}
91
- </button>
92
96
  </form>
93
97
  )}
94
98
  </>