@meith/web 0.30.1 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/app/(board)/[slug]/feed.xml/route.ts +5 -3
  2. package/app/(board)/[slug]/new/page.tsx +5 -0
  3. package/app/(board)/[slug]/page.tsx +7 -1
  4. package/app/(board)/discover/[view]/page.tsx +6 -1
  5. package/app/(board)/member/[id]/page.tsx +3 -5
  6. package/app/(board)/member/[id]/reputation/page.tsx +8 -15
  7. package/app/(board)/members/page.tsx +3 -8
  8. package/app/(board)/messages/[id]/page.tsx +3 -7
  9. package/app/(board)/messages/page.tsx +3 -5
  10. package/app/(board)/modcp/ip/page.tsx +3 -5
  11. package/app/(board)/modcp/plugins/[key]/[[...path]]/page.tsx +84 -0
  12. package/app/(board)/moderation/reports/page.tsx +53 -15
  13. package/app/(board)/moderation/warn/page.tsx +3 -6
  14. package/app/(board)/notifications/page.tsx +5 -10
  15. package/app/(board)/notifications/preferences/page.tsx +8 -0
  16. package/app/(board)/online/page.tsx +12 -6
  17. package/app/(board)/staff/page.tsx +4 -5
  18. package/app/(board)/stats/page.tsx +10 -3
  19. package/app/(board)/subscriptions/page.tsx +5 -10
  20. package/app/(board)/thread/[slug]/edit/page.tsx +21 -0
  21. package/app/(board)/thread/[slug]/feed.xml/route.ts +6 -3
  22. package/app/(board)/thread/[slug]/page.tsx +49 -11
  23. package/app/(board)/thread/[slug]/reply/page.tsx +5 -0
  24. package/app/(board)/unsubscribe/page.tsx +8 -11
  25. package/app/(board)/usercp/contacts/page.tsx +3 -5
  26. package/app/(board)/usercp/drafts/page.tsx +70 -0
  27. package/app/(board)/usercp/options/page.tsx +8 -1
  28. package/app/(board)/usercp/security/page.tsx +16 -0
  29. package/app/admin/antispam/page.tsx +4 -12
  30. package/app/admin/content/attachments/page.tsx +3 -6
  31. package/app/admin/content/page.tsx +3 -13
  32. package/app/admin/forums/[id]/permissions/page.tsx +9 -12
  33. package/app/admin/groups/page.tsx +7 -9
  34. package/app/admin/page.tsx +12 -6
  35. package/app/admin/plugins/[key]/[[...path]]/page.tsx +6 -7
  36. package/app/admin/plugins/page.tsx +3 -6
  37. package/app/admin/users/[id]/page.tsx +5 -11
  38. package/app/admin/users/prune/page.tsx +4 -12
  39. package/app/admin/webhooks/page.tsx +144 -0
  40. package/app/api/subscribe/thread/[id]/route.ts +53 -0
  41. package/app/api/unsubscribe/thread/[id]/route.ts +42 -0
  42. package/app/atom.xml/route.ts +2 -2
  43. package/app/auth/sso/[provider]/callback/route.ts +10 -0
  44. package/app/feed.xml/route.ts +2 -2
  45. package/app/offline/route.ts +98 -0
  46. package/next.config.mjs +1 -0
  47. package/package.json +49 -48
  48. package/public/sw.js +42 -2
  49. package/src/components/account/draft-forms.tsx +39 -0
  50. package/src/components/account/feed-token.tsx +95 -0
  51. package/src/components/account/notification-forms.tsx +37 -2
  52. package/src/components/account/relation-forms.tsx +3 -2
  53. package/src/components/account/subscription-forms.tsx +20 -6
  54. package/src/components/account/usercp-forms.tsx +44 -0
  55. package/src/components/admin/api-token-forms.tsx +11 -7
  56. package/src/components/admin/ban-filter-forms.tsx +15 -11
  57. package/src/components/admin/content-forms.tsx +31 -14
  58. package/src/components/admin/forum-forms.tsx +241 -129
  59. package/src/components/admin/forum-tree.tsx +5 -7
  60. package/src/components/admin/navigation-forms.tsx +43 -26
  61. package/src/components/admin/navigation-tree.tsx +8 -13
  62. package/src/components/admin/oklch-picker.tsx +2 -1
  63. package/src/components/admin/theme-changes.tsx +3 -2
  64. package/src/components/admin/theme-forms.tsx +3 -2
  65. package/src/components/admin/webhook-forms.tsx +137 -0
  66. package/src/components/auth/register-form.tsx +4 -7
  67. package/src/components/board/live-region.tsx +3 -1
  68. package/src/components/content/attachment-field.tsx +93 -22
  69. package/src/components/content/combobox-keys.ts +38 -0
  70. package/src/components/content/edit-post-form.tsx +75 -1
  71. package/src/components/content/markdown-editor.tsx +29 -86
  72. package/src/components/content/mention-combobox.tsx +137 -0
  73. package/src/components/content/multiquote-button.tsx +15 -12
  74. package/src/components/content/new-thread-form.tsx +95 -17
  75. package/src/components/content/poll-edit-form.tsx +61 -39
  76. package/src/components/content/poll-vote-form.tsx +91 -0
  77. package/src/components/content/poll.tsx +12 -48
  78. package/src/components/content/progressive-marker.tsx +13 -0
  79. package/src/components/content/recipient-segment.ts +39 -0
  80. package/src/components/content/reply-form.tsx +9 -1
  81. package/src/components/content/thanks-button.tsx +12 -7
  82. package/src/components/messages/message-forms.tsx +53 -43
  83. package/src/components/messages/recipient-field.tsx +88 -0
  84. package/src/components/moderation/inline-moderation-form.tsx +2 -0
  85. package/src/components/moderation/modcp-shell.tsx +17 -3
  86. package/src/components/moderation/queue-form.tsx +3 -6
  87. package/src/components/moderation/report-forms.tsx +24 -5
  88. package/src/components/moderation/thread-tools-form.tsx +2 -0
  89. package/src/components/shell/board-notice.tsx +22 -1
  90. package/src/components/shell/confirm-dialog.tsx +93 -0
  91. package/src/components/shell/nav-disclosure-enhancer.tsx +36 -0
  92. package/src/components/shell/notice-toast.tsx +40 -0
  93. package/src/components/shell/notification-menu.tsx +5 -3
  94. package/src/components/shell/page-shell.tsx +3 -0
  95. package/src/components/shell/scheme-toggle.tsx +72 -0
  96. package/src/components/shell/theme-switcher.tsx +16 -36
  97. package/src/server/admin-settings-actions.ts +5 -0
  98. package/src/server/api/search.ts +8 -2
  99. package/src/server/api-token-actions.ts +4 -0
  100. package/src/server/appearance-actions.ts +21 -0
  101. package/src/server/attachments.ts +35 -0
  102. package/src/server/auth-actions.ts +2 -0
  103. package/src/server/auth-form-state.ts +28 -0
  104. package/src/server/auto-watch.ts +31 -0
  105. package/src/server/ban-filter-admin-actions.ts +5 -0
  106. package/src/server/confirm.ts +19 -0
  107. package/src/server/content-actions.ts +129 -19
  108. package/src/server/content-admin-actions.ts +31 -0
  109. package/src/server/discovery.ts +11 -1
  110. package/src/server/feed-routes.ts +13 -4
  111. package/src/server/feed-token-actions.ts +61 -0
  112. package/src/server/feed-token.ts +78 -0
  113. package/src/server/fixture-post-repo.ts +18 -0
  114. package/src/server/forum-admin-actions.ts +23 -16
  115. package/src/server/forum-admin.ts +10 -1
  116. package/src/server/inline-moderation-actions.ts +6 -0
  117. package/src/server/message-actions.ts +9 -0
  118. package/src/server/navigation-admin-actions.ts +6 -0
  119. package/src/server/plugin-admin.ts +7 -2
  120. package/src/server/plugin-pages.ts +50 -0
  121. package/src/server/plugin-panel.ts +34 -1
  122. package/src/server/plugin-routes.ts +8 -0
  123. package/src/server/plugin-view.tsx +46 -7
  124. package/src/server/poll-actions.ts +13 -1
  125. package/src/server/presence.ts +23 -0
  126. package/src/server/reply-core.ts +8 -1
  127. package/src/server/report-actions.ts +13 -2
  128. package/src/server/reputation-actions.ts +12 -1
  129. package/src/server/search-page.ts +8 -2
  130. package/src/server/search.ts +18 -5
  131. package/src/server/subscription-actions.ts +9 -0
  132. package/src/server/thread-core.ts +10 -2
  133. package/src/server/thread-tool-actions.ts +6 -0
  134. package/src/server/unread-goto.ts +28 -0
  135. package/src/server/upgrade-notice.ts +1 -1
  136. package/src/server/usercp-actions.ts +31 -1
  137. package/src/server/viewer-preferences.ts +5 -0
  138. package/src/server/webhook-actions.ts +98 -0
  139. package/src/server/webhooks-admin.ts +122 -0
  140. package/src/theme/contract.fixture.ts +36 -5
  141. package/src/view/account-copy.ts +39 -0
  142. package/src/view/admin-copy.ts +4 -9
  143. package/src/view/admin-nav.ts +6 -0
  144. package/src/view/admin-panel-copy.ts +23 -0
  145. package/src/view/attachments.ts +33 -1
  146. package/src/view/board-index.ts +31 -2
  147. package/src/view/board-title.ts +1 -0
  148. package/src/view/content-copy.ts +22 -12
  149. package/src/view/copy.ts +17 -0
  150. package/src/view/discovery-view.ts +2 -1
  151. package/src/view/drafts.ts +45 -0
  152. package/src/view/editor-toolbar.ts +14 -0
  153. package/src/view/forum-display.ts +39 -7
  154. package/src/view/modcp-nav.ts +32 -0
  155. package/src/view/moderation-copy.ts +20 -0
  156. package/src/view/notifications.ts +1 -0
  157. package/src/view/plugin-panel.ts +13 -0
  158. package/src/view/poll-vote.ts +46 -0
  159. package/src/view/post-link.ts +8 -1
  160. package/src/view/progressive-enhancement.ts +5 -0
  161. package/src/view/shell.ts +2 -1
  162. package/src/view/subscriptions.ts +1 -0
  163. package/src/view/thread-view.ts +41 -2
  164. package/src/view/usercp-nav.ts +6 -0
  165. package/src/view/usercp.ts +14 -0
@@ -13,15 +13,13 @@ import {
13
13
  uploadInlineAttachmentAction,
14
14
  } from '@/server/attachment-upload-actions'
15
15
  import { type PreviewScope, renderPreviewAction } from '@/server/content-actions'
16
- import { searchMentionCandidatesAction } from '@/server/mention-actions'
17
16
 
18
17
  import { type Copy, formatFromCopy, fromCopy, useCopy } from '../shell/copy'
19
18
  import { attachmentFieldId, MESSAGE_TEXTAREA_ID } from './composer-ids'
20
19
  import { listContinuation, pasteAsLink } from './markdown-syntax'
20
+ import { useMentionCombobox } from './mention-combobox'
21
21
  import { activeMentionToken, type MentionToken } from './mention-token'
22
22
 
23
- const MENTION_DEBOUNCE_MS = 150
24
-
25
23
  interface Tool {
26
24
  readonly tag: EditorTag
27
25
  readonly labelKey: string
@@ -52,6 +50,12 @@ const TOOLS: readonly Tool[] = [
52
50
  placeholderKey: 'composer.placeholder.struck',
53
51
  },
54
52
  { tag: 'link', labelKey: 'composer.tool.link', glyph: 'Link', shortcut: 'k' },
53
+ {
54
+ tag: 'image',
55
+ labelKey: 'composer.tool.image',
56
+ glyph: 'Image',
57
+ placeholderKey: 'composer.placeholder.image',
58
+ },
55
59
  { tag: 'quote', labelKey: 'composer.tool.quote', glyph: '“”' },
56
60
  { tag: 'code', labelKey: 'composer.tool.code', glyph: '</>' },
57
61
  {
@@ -62,7 +66,14 @@ const TOOLS: readonly Tool[] = [
62
66
  },
63
67
  { tag: 'bulletedList', labelKey: 'composer.tool.bulletedList', glyph: '•' },
64
68
  { tag: 'numberedList', labelKey: 'composer.tool.numberedList', glyph: '1.' },
69
+ { tag: 'taskList', labelKey: 'composer.tool.taskList', glyph: '☑' },
65
70
  { tag: 'heading', labelKey: 'composer.tool.heading', glyph: 'H' },
71
+ {
72
+ tag: 'table',
73
+ labelKey: 'composer.tool.table',
74
+ glyph: 'Table',
75
+ placeholderKey: 'composer.placeholder.table',
76
+ },
66
77
  ]
67
78
 
68
79
  function runTool(field: HTMLTextAreaElement, tool: Tool, copy: Copy): void {
@@ -122,10 +133,14 @@ export function MarkdownEditor({
122
133
 
123
134
  const mentions = scope === 'post'
124
135
  const [mentionToken, setMentionToken] = useState<MentionToken | null>(null)
125
- const [mentionMatches, setMentionMatches] = useState<readonly MemberSuggestion[]>([])
126
- const [mentionActive, setMentionActive] = useState(0)
127
- const latestMentionToken = useRef(mentionToken)
128
- latestMentionToken.current = mentionToken
136
+
137
+ const combobox = useMentionCombobox({
138
+ query: mentionToken?.query ?? null,
139
+ onChoose: (candidate) => {
140
+ if (field.current !== null) applyMention(field.current, candidate)
141
+ },
142
+ onDismiss: () => setMentionToken(null),
143
+ })
129
144
 
130
145
  const fit = useCallback(() => {
131
146
  const element = field.current
@@ -159,23 +174,6 @@ export function MarkdownEditor({
159
174
  }
160
175
  }
161
176
 
162
- useEffect(() => {
163
- if (mentionToken === null) {
164
- setMentionMatches([])
165
- return
166
- }
167
-
168
- const handle = setTimeout(() => {
169
- void searchMentionCandidatesAction(mentionToken.query).then((matches) => {
170
- if (latestMentionToken.current !== mentionToken) return
171
- setMentionMatches(matches)
172
- setMentionActive(0)
173
- })
174
- }, MENTION_DEBOUNCE_MS)
175
-
176
- return () => clearTimeout(handle)
177
- }, [mentionToken])
178
-
179
177
  function syncMentionToken(element: HTMLTextAreaElement): void {
180
178
  if (!mentions) return
181
179
  const found =
@@ -243,28 +241,7 @@ export function MarkdownEditor({
243
241
  function onKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>): void {
244
242
  const element = event.currentTarget
245
243
 
246
- if (mentionToken !== null && mentionMatches.length > 0) {
247
- if (event.key === 'ArrowDown') {
248
- event.preventDefault()
249
- setMentionActive((index) => (index + 1) % mentionMatches.length)
250
- return
251
- }
252
- if (event.key === 'ArrowUp') {
253
- event.preventDefault()
254
- setMentionActive((index) => (index - 1 + mentionMatches.length) % mentionMatches.length)
255
- return
256
- }
257
- if (event.key === 'Escape') {
258
- event.preventDefault()
259
- setMentionToken(null)
260
- return
261
- }
262
- if ((event.key === 'Enter' || event.key === 'Tab') && !event.metaKey && !event.ctrlKey) {
263
- event.preventDefault()
264
- applyMention(element, mentionMatches[mentionActive]!)
265
- return
266
- }
267
- }
244
+ if (combobox.handleKeyDown(event)) return
268
245
 
269
246
  if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
270
247
  element.form?.requestSubmit()
@@ -449,52 +426,15 @@ export function MarkdownEditor({
449
426
  }}
450
427
  onSelect={(event) => syncMentionToken(event.currentTarget)}
451
428
  onBlur={() => setMentionToken(null)}
452
- {...(enhanced && mentionToken !== null && mentionMatches.length > 0
453
- ? {
454
- role: 'combobox',
455
- 'aria-expanded': true,
456
- 'aria-controls': `${panelId}-mentions`,
457
- 'aria-activedescendant': `${panelId}-mention-${mentionActive}`,
458
- 'aria-autocomplete': 'list' as const,
459
- }
460
- : {})}
429
+ {...(enhanced ? combobox.anchorProps : {})}
461
430
  className={cn(
462
431
  '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',
463
432
  onWriteTab && 'rounded-t-none border-t-0',
464
433
  )}
465
434
  />
466
435
 
467
- {enhanced && mentionToken !== null && mentionMatches.length > 0 && (
468
- <div
469
- id={`${panelId}-mentions`}
470
- role="listbox"
471
- aria-label={fromCopy(copy, 'composer.mention.suggestions')}
472
- 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"
473
- >
474
- {mentionMatches.map((candidate, index) => (
475
- <button
476
- key={candidate.id}
477
- type="button"
478
- id={`${panelId}-mention-${index}`}
479
- role="option"
480
- aria-selected={index === mentionActive}
481
- onMouseDown={(event) => event.preventDefault()}
482
- onClick={() => {
483
- if (field.current !== null) applyMention(field.current, candidate)
484
- }}
485
- onMouseEnter={() => setMentionActive(index)}
486
- className={cn(
487
- 'block w-full px-3 py-1.5 text-start',
488
- index === mentionActive
489
- ? 'bg-muted text-foreground'
490
- : 'text-foreground hover:bg-muted',
491
- )}
492
- >
493
- @{candidate.username}
494
- </button>
495
- ))}
496
- </div>
497
- )}
436
+ {enhanced && combobox.listbox}
437
+ {enhanced && combobox.liveRegion}
498
438
  </div>
499
439
  </div>
500
440
 
@@ -577,8 +517,11 @@ function FormattingHelp({ scope }: { scope: PreviewScope }) {
577
517
  ['```', 'composer.help.codeBlock'],
578
518
  ['> quoted', 'composer.help.quote'],
579
519
  ['- item', 'composer.help.list'],
520
+ ['- [ ] task', 'composer.help.taskList'],
580
521
  ['## Heading', 'composer.help.heading'],
522
+ ['| a | b |', 'composer.help.table'],
581
523
  [':::spoiler', 'composer.help.spoiler'],
524
+ ['![alt](url)', 'composer.help.image'],
582
525
  ['@name', 'composer.help.mention'],
583
526
  ['[attachment=id]', 'composer.help.attachment'],
584
527
  ]
@@ -0,0 +1,137 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AriaAttributes,
5
+ type AriaRole,
6
+ type KeyboardEvent,
7
+ type ReactNode,
8
+ useEffect,
9
+ useId,
10
+ useRef,
11
+ useState,
12
+ } from 'react'
13
+
14
+ import type { MemberSuggestion } from '@meith/accounts'
15
+ import { cn } from '@meith/ui'
16
+
17
+ import { searchMentionCandidatesAction } from '@/server/mention-actions'
18
+
19
+ import { formatFromCopy, fromCopy, useCopy } from '../shell/copy'
20
+ import { comboboxKeyAction } from './combobox-keys'
21
+
22
+ const SEARCH_DEBOUNCE_MS = 150
23
+
24
+ export type AnchorProps = AriaAttributes & { readonly role?: AriaRole }
25
+
26
+ export interface MentionCombobox {
27
+ readonly open: boolean
28
+ readonly anchorProps: AnchorProps
29
+ readonly handleKeyDown: (event: KeyboardEvent) => boolean
30
+ readonly listbox: ReactNode
31
+ readonly liveRegion: ReactNode
32
+ }
33
+
34
+ export interface MentionComboboxOptions {
35
+ readonly query: string | null
36
+ readonly onChoose: (candidate: MemberSuggestion) => void
37
+ readonly onDismiss: () => void
38
+ readonly label?: (candidate: MemberSuggestion) => string
39
+ readonly listboxLabel?: string
40
+ }
41
+
42
+ export function useMentionCombobox({
43
+ query,
44
+ onChoose,
45
+ onDismiss,
46
+ label,
47
+ listboxLabel,
48
+ }: MentionComboboxOptions): MentionCombobox {
49
+ const copy = useCopy()
50
+ const listboxId = useId()
51
+
52
+ const [matches, setMatches] = useState<readonly MemberSuggestion[]>([])
53
+ const [active, setActive] = useState(0)
54
+
55
+ const latestQuery = useRef(query)
56
+ latestQuery.current = query
57
+
58
+ useEffect(() => {
59
+ if (query === null) {
60
+ setMatches([])
61
+ return
62
+ }
63
+
64
+ const handle = setTimeout(() => {
65
+ void searchMentionCandidatesAction(query).then((found) => {
66
+ if (latestQuery.current !== query) return
67
+ setMatches(found)
68
+ setActive(0)
69
+ })
70
+ }, SEARCH_DEBOUNCE_MS)
71
+
72
+ return () => clearTimeout(handle)
73
+ }, [query])
74
+
75
+ const open = query !== null && matches.length > 0
76
+ const optionId = (index: number): string => `${listboxId}-option-${index}`
77
+ const render = label ?? ((candidate: MemberSuggestion): string => `@${candidate.username}`)
78
+
79
+ function handleKeyDown(event: KeyboardEvent): boolean {
80
+ if (!open) return false
81
+
82
+ const action = comboboxKeyAction(event, { count: matches.length, active })
83
+ if (action === null) return false
84
+
85
+ event.preventDefault()
86
+ if (action.type === 'move') setActive(action.active)
87
+ else if (action.type === 'dismiss') onDismiss()
88
+ else onChoose(matches[action.index]!)
89
+ return true
90
+ }
91
+
92
+ const anchorProps: AnchorProps = open
93
+ ? {
94
+ role: 'combobox',
95
+ 'aria-expanded': true,
96
+ 'aria-controls': listboxId,
97
+ 'aria-activedescendant': optionId(active),
98
+ 'aria-autocomplete': 'list',
99
+ }
100
+ : {}
101
+
102
+ const listbox = open ? (
103
+ <div
104
+ id={listboxId}
105
+ role="listbox"
106
+ aria-label={listboxLabel ?? fromCopy(copy, 'composer.mention.suggestions')}
107
+ 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"
108
+ >
109
+ {matches.map((candidate, index) => (
110
+ <button
111
+ key={candidate.id}
112
+ type="button"
113
+ id={optionId(index)}
114
+ role="option"
115
+ aria-selected={index === active}
116
+ onMouseDown={(event) => event.preventDefault()}
117
+ onClick={() => onChoose(candidate)}
118
+ onMouseEnter={() => setActive(index)}
119
+ className={cn(
120
+ 'block w-full px-3 py-1.5 text-start',
121
+ index === active ? 'bg-muted text-foreground' : 'text-foreground hover:bg-muted',
122
+ )}
123
+ >
124
+ {render(candidate)}
125
+ </button>
126
+ ))}
127
+ </div>
128
+ ) : null
129
+
130
+ const liveRegion = (
131
+ <p role="status" aria-live="polite" className="sr-only">
132
+ {open ? formatFromCopy(copy, 'composer.mention.available', { count: matches.length }) : ''}
133
+ </p>
134
+ )
135
+
136
+ return { open, anchorProps, handleKeyDown, listbox, liveRegion }
137
+ }
@@ -3,6 +3,7 @@
3
3
  import { useEffect, useState } from 'react'
4
4
 
5
5
  import { buttonVariants, cn } from '@meith/ui'
6
+ import { Tooltip } from '@meith/ui/tooltip'
6
7
 
7
8
  import { fromCopy, useCopy } from '../shell/copy'
8
9
  import { toggleMultiquote, useMultiquote } from './multiquote'
@@ -16,17 +17,19 @@ export function MultiQuoteButton({ postId }: { postId: number }) {
16
17
  if (!enhanced) return null
17
18
 
18
19
  return (
19
- <button
20
- type="button"
21
- aria-pressed={held}
22
- onClick={() => toggleMultiquote(postId, copy)}
23
- className={cn(
24
- buttonVariants({ variant: held ? 'secondary' : 'ghost', size: 'sm' }),
25
- held && 'text-foreground',
26
- )}
27
- >
28
- <span aria-hidden="true">{held ? '✓' : '+'}</span>
29
- {fromCopy(copy, 'composer.multiquote.button')}
30
- </button>
20
+ <Tooltip label={fromCopy(copy, 'composer.multiquote.tooltip')}>
21
+ <button
22
+ type="button"
23
+ aria-pressed={held}
24
+ onClick={() => toggleMultiquote(postId, copy)}
25
+ className={cn(
26
+ buttonVariants({ variant: held ? 'secondary' : 'ghost', size: 'sm' }),
27
+ held && 'text-foreground',
28
+ )}
29
+ >
30
+ <span aria-hidden="true">{held ? '' : '+'}</span>
31
+ {fromCopy(copy, 'composer.multiquote.button')}
32
+ </button>
33
+ </Tooltip>
31
34
  )
32
35
  }
@@ -1,16 +1,18 @@
1
1
  'use client'
2
2
 
3
3
  import type { ReactNode } from 'react'
4
- import { useActionState } from 'react'
4
+ import { useActionState, useEffect, useState } from 'react'
5
5
 
6
6
  import type { UploadLimits } from '@meith/attachments/limits'
7
7
  import type { Draft } from '@meith/drafts'
8
- import { Disclosure } from '@meith/ui'
8
+ import { POLL_OPTION_MAX } from '@meith/polls/limits'
9
+ import { buttonVariants, Disclosure } from '@meith/ui'
9
10
 
11
+ import type { PollDraftValues } from '@/server/auth-form-state'
10
12
  import { EMPTY_STATE } from '@/server/auth-form-state'
11
13
  import { createThreadAction } from '@/server/content-actions'
12
14
 
13
- import { Field, FormError, SubmitButton } from '../auth/form-controls'
15
+ import { Field, FormError, PendingButton, SubmitButton } from '../auth/form-controls'
14
16
  import { type Copy, formatFromCopy, fromCopy } from '../shell/copy'
15
17
  import { AttachmentField } from './attachment-field'
16
18
  import { ComposerIntents } from './composer-intents'
@@ -22,11 +24,61 @@ export interface PrefixOption {
22
24
  readonly label: string
23
25
  }
24
26
 
27
+ const POLL_OPTION_STARTING_SLOTS = 4
28
+ const POLL_OPTION_ADD_STEP = 4
29
+
30
+ function PollOptionFields({
31
+ pollDraft,
32
+ copy,
33
+ }: {
34
+ pollDraft: PollDraftValues | undefined
35
+ copy: Copy
36
+ }) {
37
+ const [slots, setSlots] = useState(() =>
38
+ Math.max(pollDraft?.options.length ?? 0, POLL_OPTION_STARTING_SLOTS),
39
+ )
40
+
41
+ useEffect(() => {
42
+ setSlots(Math.max(pollDraft?.options.length ?? 0, POLL_OPTION_STARTING_SLOTS))
43
+ }, [pollDraft])
44
+
45
+ const atMax = slots >= POLL_OPTION_MAX
46
+
47
+ return (
48
+ <>
49
+ {Array.from({ length: slots }, (_, index) => (
50
+ <Field
51
+ // biome-ignore lint/suspicious/noArrayIndexKey: slots are appended only, in stable order — the index is the slot number
52
+ key={index}
53
+ id={`field-pollOption-${index + 1}`}
54
+ label={formatFromCopy(copy, 'composer.newThread.option', { number: index + 1 })}
55
+ name="pollOption"
56
+ maxLength={200}
57
+ defaultValue={pollDraft?.options[index]}
58
+ />
59
+ ))}
60
+ <PendingButton
61
+ name="intent"
62
+ value="more_options"
63
+ disabled={atMax}
64
+ onClick={(event) => {
65
+ event.preventDefault()
66
+ setSlots((current) => Math.min(current + POLL_OPTION_ADD_STEP, POLL_OPTION_MAX))
67
+ }}
68
+ className={buttonVariants({ variant: 'outline', size: 'sm', className: 'w-auto' })}
69
+ >
70
+ {fromCopy(copy, 'composer.newThread.morePollOptions')}
71
+ </PendingButton>
72
+ </>
73
+ )
74
+ }
75
+
25
76
  export function NewThreadForm({
26
77
  forumId,
27
78
  prefixes,
28
79
  requiresPrefix,
29
80
  canSubscribe,
81
+ subscribeDefault = false,
30
82
  canPostPoll,
31
83
  attachmentLimits,
32
84
  draft,
@@ -37,6 +89,7 @@ export function NewThreadForm({
37
89
  prefixes: readonly PrefixOption[]
38
90
  requiresPrefix: boolean
39
91
  canSubscribe: boolean
92
+ subscribeDefault?: boolean
40
93
  canPostPoll: boolean
41
94
  attachmentLimits: UploadLimits | null
42
95
  draft: Draft | null
@@ -44,9 +97,11 @@ export function NewThreadForm({
44
97
  copy: Copy
45
98
  }) {
46
99
  const [state, action] = useActionState(createThreadAction, EMPTY_STATE)
100
+ const pollDraft = state.poll
47
101
 
48
102
  const hasPollDraft =
49
- (state.values?.pollQuestion ?? '') !== '' || (state.values?.pollOption ?? '') !== ''
103
+ (pollDraft?.question ?? '') !== '' ||
104
+ (pollDraft?.options.some((option) => option.trim() !== '') ?? false)
50
105
 
51
106
  return (
52
107
  <form action={action} className="flex flex-col gap-4" noValidate>
@@ -117,16 +172,9 @@ export function NewThreadForm({
117
172
  label={fromCopy(copy, 'composer.newThread.question')}
118
173
  name="pollQuestion"
119
174
  maxLength={250}
175
+ defaultValue={pollDraft?.question}
120
176
  />
121
- {[1, 2, 3, 4].map((number) => (
122
- <Field
123
- key={number}
124
- id={`field-pollOption-${number}`}
125
- label={formatFromCopy(copy, 'composer.newThread.option', { number })}
126
- name="pollOption"
127
- maxLength={200}
128
- />
129
- ))}
177
+ <PollOptionFields pollDraft={pollDraft} copy={copy} />
130
178
  <label className="flex flex-col gap-1 text-sm">
131
179
  <span className="font-medium">{fromCopy(copy, 'composer.newThread.pollChoices')}</span>
132
180
  <input
@@ -134,7 +182,7 @@ export function NewThreadForm({
134
182
  name="pollMaxOptions"
135
183
  min={0}
136
184
  step={1}
137
- defaultValue="1"
185
+ defaultValue={pollDraft?.maxOptions ?? '1'}
138
186
  className="h-10 rounded-md border border-border bg-background px-3 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
139
187
  />
140
188
  <span className="text-xs text-muted-foreground">
@@ -142,19 +190,49 @@ export function NewThreadForm({
142
190
  </span>
143
191
  </label>
144
192
  <label className="flex items-center gap-2 text-sm">
145
- <input type="checkbox" name="pollAllowRevote" value="1" className="size-4" />
193
+ <input
194
+ type="checkbox"
195
+ name="pollAllowRevote"
196
+ value="1"
197
+ defaultChecked={pollDraft?.allowRevote ?? false}
198
+ className="size-4"
199
+ />
146
200
  <span>{fromCopy(copy, 'composer.newThread.pollRevote')}</span>
147
201
  </label>
148
202
  <label className="flex items-center gap-2 text-sm">
149
- <input type="checkbox" name="pollPublicVotes" value="1" className="size-4" />
203
+ <input
204
+ type="checkbox"
205
+ name="pollPublicVotes"
206
+ value="1"
207
+ defaultChecked={pollDraft?.publicVotes ?? false}
208
+ className="size-4"
209
+ />
150
210
  <span>{fromCopy(copy, 'composer.newThread.pollPublic')}</span>
151
211
  </label>
212
+ <label className="flex flex-col gap-1 text-sm">
213
+ <span className="font-medium">{fromCopy(copy, 'pollEdit.closesAt')}</span>
214
+ <input
215
+ type="datetime-local"
216
+ name="pollClosesAt"
217
+ defaultValue={pollDraft?.closesAt ?? ''}
218
+ className="h-10 rounded-md border border-border bg-background px-3 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
219
+ />
220
+ <span className="text-xs text-muted-foreground">
221
+ {fromCopy(copy, 'pollEdit.closesAtHint')}
222
+ </span>
223
+ </label>
152
224
  </Disclosure>
153
225
  )}
154
226
 
155
227
  {canSubscribe && (
156
228
  <label className="flex items-center gap-2 text-sm">
157
- <input type="checkbox" name="subscribe" value="1" className="size-4" />
229
+ <input
230
+ type="checkbox"
231
+ name="subscribe"
232
+ value="1"
233
+ defaultChecked={subscribeDefault}
234
+ className="size-4"
235
+ />
158
236
  <span>{fromCopy(copy, 'composer.notifyReplies')}</span>
159
237
  </label>
160
238
  )}
@@ -1,4 +1,7 @@
1
+ import { Fragment } from 'react'
2
+
1
3
  import { POLL_OPTION_LENGTH_MAX, POLL_QUESTION_MAX, type Poll } from '@meith/polls'
4
+ import { Field, Input } from '@meith/ui'
2
5
 
3
6
  import { getTranslator } from '@/server/i18n'
4
7
  import { editPollAction } from '@/server/poll-actions'
@@ -20,56 +23,75 @@ export async function PollEditForm({ poll, threadId }: { poll: Poll; threadId: n
20
23
  <input type="hidden" name="threadId" value={threadId} />
21
24
  <input type="hidden" name="pollId" value={poll.id} />
22
25
 
23
- <label className="flex flex-col gap-1 text-sm">
24
- <span className="font-medium">{t.t('pollEdit.question')}</span>
25
- <input
26
- type="text"
27
- name="question"
28
- required
29
- maxLength={POLL_QUESTION_MAX}
30
- defaultValue={poll.question}
31
- />
32
- </label>
26
+ <Field label={t.t('pollEdit.question')} name="question">
27
+ {(control) => (
28
+ <Input
29
+ {...control}
30
+ type="text"
31
+ required
32
+ maxLength={POLL_QUESTION_MAX}
33
+ defaultValue={poll.question}
34
+ />
35
+ )}
36
+ </Field>
33
37
 
34
38
  {poll.options.map((option, index) => (
35
- <label key={option.id} className="flex flex-col gap-1 text-sm">
36
- <span className="font-medium">{t.t('pollEdit.option', { number: index + 1 })}</span>
39
+ <Fragment key={option.id}>
37
40
  <input type="hidden" name="optionRef" value={option.id} />
38
- <input
39
- type="text"
41
+ <Field
42
+ id={`field-optionLabel-${option.id}`}
43
+ label={t.t('pollEdit.option', { number: index + 1 })}
40
44
  name="optionLabel"
41
- maxLength={POLL_OPTION_LENGTH_MAX}
42
- defaultValue={option.label}
43
- />
44
- <span className="text-xs text-muted-foreground">
45
- {option.votes > 0
46
- ? t.t('pollEdit.optionVoted', { count: option.votes })
47
- : t.t('pollEdit.optionEmpty')}
48
- </span>
49
- </label>
45
+ description={
46
+ option.votes > 0
47
+ ? t.t('pollEdit.optionVoted', { count: option.votes })
48
+ : t.t('pollEdit.optionEmpty')
49
+ }
50
+ >
51
+ {(control) => (
52
+ <Input
53
+ {...control}
54
+ type="text"
55
+ maxLength={POLL_OPTION_LENGTH_MAX}
56
+ defaultValue={option.label}
57
+ />
58
+ )}
59
+ </Field>
60
+ </Fragment>
50
61
  ))}
51
62
 
52
63
  {NEW_OPTION_SLOTS.map((slot, index) => (
53
- <label key={slot} className="flex flex-col gap-1 text-sm">
54
- <span className="font-medium">
55
- {t.t('pollEdit.option', { number: poll.options.length + index + 1 })}
56
- </span>
64
+ <Fragment key={slot}>
57
65
  <input type="hidden" name="optionRef" value="" />
58
- <input type="text" name="optionLabel" maxLength={POLL_OPTION_LENGTH_MAX} />
59
- </label>
66
+ <Field
67
+ id={`field-optionLabel-${slot}`}
68
+ label={t.t('pollEdit.option', { number: poll.options.length + index + 1 })}
69
+ name="optionLabel"
70
+ >
71
+ {(control) => <Input {...control} type="text" maxLength={POLL_OPTION_LENGTH_MAX} />}
72
+ </Field>
73
+ </Fragment>
60
74
  ))}
61
75
 
62
- <label className="flex flex-col gap-1 text-sm">
63
- <span className="font-medium">{t.t('pollEdit.maxOptions')}</span>
64
- <input type="number" name="maxOptions" min={0} step={1} defaultValue={poll.maxOptions} />
65
- <span className="text-xs text-muted-foreground">{t.t('pollEdit.maxOptionsHint')}</span>
66
- </label>
76
+ <Field
77
+ label={t.t('pollEdit.maxOptions')}
78
+ name="maxOptions"
79
+ description={t.t('pollEdit.maxOptionsHint')}
80
+ >
81
+ {(control) => (
82
+ <Input {...control} type="number" min={0} step={1} defaultValue={poll.maxOptions} />
83
+ )}
84
+ </Field>
67
85
 
68
- <label className="flex flex-col gap-1 text-sm">
69
- <span className="font-medium">{t.t('pollEdit.closesAt')}</span>
70
- <input type="datetime-local" name="closesAt" defaultValue={inputValue(poll.closesAt)} />
71
- <span className="text-xs text-muted-foreground">{t.t('pollEdit.closesAtHint')}</span>
72
- </label>
86
+ <Field
87
+ label={t.t('pollEdit.closesAt')}
88
+ name="closesAt"
89
+ description={t.t('pollEdit.closesAtHint')}
90
+ >
91
+ {(control) => (
92
+ <Input {...control} type="datetime-local" defaultValue={inputValue(poll.closesAt)} />
93
+ )}
94
+ </Field>
73
95
 
74
96
  <label className="flex items-center gap-2 text-sm">
75
97
  <input type="checkbox" name="allowRevote" value="1" defaultChecked={poll.allowRevote} />