@meith/web 0.31.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) 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/layout.tsx +23 -8
  35. package/app/admin/page.tsx +12 -6
  36. package/app/admin/plugins/[key]/[[...path]]/page.tsx +6 -7
  37. package/app/admin/plugins/page.tsx +3 -6
  38. package/app/admin/users/[id]/page.tsx +5 -11
  39. package/app/admin/users/prune/page.tsx +4 -12
  40. package/app/admin/webhooks/page.tsx +144 -0
  41. package/app/api/subscribe/thread/[id]/route.ts +53 -0
  42. package/app/api/unsubscribe/thread/[id]/route.ts +42 -0
  43. package/app/atom.xml/route.ts +2 -2
  44. package/app/auth/sso/[provider]/callback/route.ts +10 -0
  45. package/app/feed.xml/route.ts +2 -2
  46. package/app/offline/route.ts +98 -0
  47. package/next.config.mjs +1 -0
  48. package/package.json +49 -48
  49. package/public/sw.js +42 -2
  50. package/src/components/account/draft-forms.tsx +39 -0
  51. package/src/components/account/feed-token.tsx +95 -0
  52. package/src/components/account/notification-forms.tsx +37 -2
  53. package/src/components/account/relation-forms.tsx +3 -2
  54. package/src/components/account/subscription-forms.tsx +20 -6
  55. package/src/components/account/usercp-forms.tsx +44 -0
  56. package/src/components/admin/admin-forms.tsx +57 -9
  57. package/src/components/admin/api-token-forms.tsx +11 -7
  58. package/src/components/admin/ban-filter-forms.tsx +15 -11
  59. package/src/components/admin/content-forms.tsx +31 -14
  60. package/src/components/admin/forum-forms.tsx +241 -129
  61. package/src/components/admin/forum-tree.tsx +5 -7
  62. package/src/components/admin/navigation-forms.tsx +43 -26
  63. package/src/components/admin/navigation-tree.tsx +8 -13
  64. package/src/components/admin/oklch-picker.tsx +2 -1
  65. package/src/components/admin/theme-changes.tsx +3 -2
  66. package/src/components/admin/theme-forms.tsx +3 -2
  67. package/src/components/admin/webhook-forms.tsx +137 -0
  68. package/src/components/auth/register-form.tsx +4 -7
  69. package/src/components/board/live-region.tsx +3 -1
  70. package/src/components/content/attachment-field.tsx +93 -22
  71. package/src/components/content/combobox-keys.ts +38 -0
  72. package/src/components/content/edit-post-form.tsx +75 -1
  73. package/src/components/content/markdown-editor.tsx +29 -86
  74. package/src/components/content/mention-combobox.tsx +137 -0
  75. package/src/components/content/multiquote-button.tsx +15 -12
  76. package/src/components/content/new-thread-form.tsx +95 -17
  77. package/src/components/content/poll-edit-form.tsx +61 -39
  78. package/src/components/content/poll-vote-form.tsx +91 -0
  79. package/src/components/content/poll.tsx +12 -48
  80. package/src/components/content/progressive-marker.tsx +13 -0
  81. package/src/components/content/recipient-segment.ts +39 -0
  82. package/src/components/content/reply-form.tsx +9 -1
  83. package/src/components/content/thanks-button.tsx +12 -7
  84. package/src/components/messages/message-forms.tsx +53 -43
  85. package/src/components/messages/recipient-field.tsx +88 -0
  86. package/src/components/moderation/inline-moderation-form.tsx +2 -0
  87. package/src/components/moderation/modcp-shell.tsx +17 -3
  88. package/src/components/moderation/queue-form.tsx +3 -6
  89. package/src/components/moderation/report-forms.tsx +24 -5
  90. package/src/components/moderation/thread-tools-form.tsx +2 -0
  91. package/src/components/shell/board-notice.tsx +22 -1
  92. package/src/components/shell/confirm-dialog.tsx +93 -0
  93. package/src/components/shell/nav-disclosure-enhancer.tsx +36 -0
  94. package/src/components/shell/notice-toast.tsx +40 -0
  95. package/src/components/shell/notification-menu.tsx +5 -3
  96. package/src/components/shell/page-shell.tsx +3 -0
  97. package/src/components/shell/scheme-toggle.tsx +72 -0
  98. package/src/components/shell/theme-switcher.tsx +16 -36
  99. package/src/components/shell/view-tabs.tsx +1 -1
  100. package/src/server/admin-actions.ts +118 -49
  101. package/src/server/admin-settings-actions.ts +5 -0
  102. package/src/server/admin.ts +70 -2
  103. package/src/server/api/search.ts +8 -2
  104. package/src/server/api-token-actions.ts +4 -0
  105. package/src/server/appearance-actions.ts +21 -0
  106. package/src/server/attachments.ts +35 -0
  107. package/src/server/auth-actions.ts +2 -0
  108. package/src/server/auth-form-state.ts +28 -0
  109. package/src/server/auto-watch.ts +31 -0
  110. package/src/server/ban-filter-admin-actions.ts +5 -0
  111. package/src/server/confirm.ts +19 -0
  112. package/src/server/content-actions.ts +129 -19
  113. package/src/server/content-admin-actions.ts +31 -0
  114. package/src/server/cookies.ts +18 -0
  115. package/src/server/discovery.ts +11 -1
  116. package/src/server/feed-routes.ts +13 -4
  117. package/src/server/feed-token-actions.ts +61 -0
  118. package/src/server/feed-token.ts +78 -0
  119. package/src/server/fixture-post-repo.ts +18 -0
  120. package/src/server/forum-admin-actions.ts +23 -16
  121. package/src/server/forum-admin.ts +10 -1
  122. package/src/server/inline-moderation-actions.ts +6 -0
  123. package/src/server/message-actions.ts +9 -0
  124. package/src/server/navigation-admin-actions.ts +6 -0
  125. package/src/server/plugin-admin.ts +7 -2
  126. package/src/server/plugin-pages.ts +50 -0
  127. package/src/server/plugin-panel.ts +34 -1
  128. package/src/server/plugin-routes.ts +8 -0
  129. package/src/server/plugin-view.tsx +46 -7
  130. package/src/server/poll-actions.ts +13 -1
  131. package/src/server/presence.ts +23 -0
  132. package/src/server/push-actions.ts +9 -0
  133. package/src/server/reply-core.ts +8 -1
  134. package/src/server/report-actions.ts +13 -2
  135. package/src/server/reputation-actions.ts +12 -1
  136. package/src/server/search-page.ts +8 -2
  137. package/src/server/search.ts +18 -5
  138. package/src/server/session-cookies.ts +18 -0
  139. package/src/server/subscription-actions.ts +9 -0
  140. package/src/server/thread-core.ts +10 -2
  141. package/src/server/thread-tool-actions.ts +6 -0
  142. package/src/server/unread-goto.ts +28 -0
  143. package/src/server/upgrade-notice.ts +1 -1
  144. package/src/server/usercp-actions.ts +31 -1
  145. package/src/server/viewer-preferences.ts +5 -0
  146. package/src/server/webhook-actions.ts +98 -0
  147. package/src/server/webhooks-admin.ts +122 -0
  148. package/src/styles/globals.css +1 -1
  149. package/src/theme/contract.fixture.ts +36 -5
  150. package/src/view/account-copy.ts +39 -0
  151. package/src/view/admin-copy.ts +4 -9
  152. package/src/view/admin-nav.ts +6 -0
  153. package/src/view/admin-panel-copy.ts +29 -1
  154. package/src/view/attachments.ts +33 -1
  155. package/src/view/board-index.ts +31 -2
  156. package/src/view/board-title.ts +1 -0
  157. package/src/view/content-copy.ts +22 -12
  158. package/src/view/copy.ts +17 -0
  159. package/src/view/discovery-view.ts +2 -1
  160. package/src/view/drafts.ts +45 -0
  161. package/src/view/editor-toolbar.ts +14 -0
  162. package/src/view/forum-display.ts +39 -7
  163. package/src/view/modcp-nav.ts +32 -0
  164. package/src/view/moderation-copy.ts +20 -0
  165. package/src/view/notifications.ts +1 -0
  166. package/src/view/plugin-panel.ts +13 -0
  167. package/src/view/poll-vote.ts +46 -0
  168. package/src/view/post-link.ts +8 -1
  169. package/src/view/progressive-enhancement.ts +5 -0
  170. package/src/view/shell.ts +2 -1
  171. package/src/view/subscriptions.ts +1 -0
  172. package/src/view/thread-view.ts +41 -2
  173. package/src/view/usercp-nav.ts +6 -0
  174. package/src/view/usercp.ts +14 -0
@@ -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} />
@@ -0,0 +1,91 @@
1
+ 'use client'
2
+
3
+ import { useActionState } from 'react'
4
+
5
+ import { votePollAction } from '@/server/poll-actions'
6
+ import type { PollVoteView } from '@/view/poll-vote'
7
+
8
+ import { PendingButton } from '../auth/form-controls'
9
+ import { type Copy, formatFromCopy } from '../shell/copy'
10
+ import { ProgressiveMarker } from './progressive-marker'
11
+
12
+ export function PollVoteForm({
13
+ threadId,
14
+ pollId,
15
+ initial,
16
+ copy,
17
+ }: {
18
+ threadId: number
19
+ pollId: number
20
+ initial: PollVoteView
21
+ copy: Copy
22
+ }) {
23
+ const [state, action] = useActionState(votePollAction, initial)
24
+ const view = state ?? initial
25
+
26
+ return (
27
+ <form action={action} className="mt-3 flex flex-col gap-2">
28
+ <input type="hidden" name="threadId" value={threadId} />
29
+ <input type="hidden" name="pollId" value={pollId} />
30
+ <ProgressiveMarker />
31
+
32
+ {view.options.map((option) => (
33
+ <div key={option.id} className="flex flex-col gap-1">
34
+ {view.mayCast ? (
35
+ <label className="flex items-center gap-2 text-sm">
36
+ <input
37
+ type={view.multiple ? 'checkbox' : 'radio'}
38
+ name="optionId"
39
+ value={option.id}
40
+ defaultChecked={option.checked}
41
+ />
42
+ <span>
43
+ {option.label} ({option.votes})
44
+ </span>
45
+ </label>
46
+ ) : (
47
+ <p className="text-sm">
48
+ {option.label} ({option.votes})
49
+ </p>
50
+ )}
51
+ <div className="flex items-center gap-2 pl-6">
52
+ <div aria-hidden="true" className="h-2 flex-1 overflow-hidden rounded-full bg-muted">
53
+ <div
54
+ className="h-full rounded-full bg-primary"
55
+ style={{ width: `${option.share}%` }}
56
+ />
57
+ </div>
58
+ <span className="w-9 shrink-0 text-right text-xs text-muted-foreground">
59
+ {option.share}%
60
+ </span>
61
+ </div>
62
+ {view.publicVotes && option.voterNames.length > 0 && (
63
+ <p className="pl-6 text-sm text-muted-foreground">
64
+ {formatFromCopy(copy, 'pollForm.votedBy', { names: option.voterNames.join(', ') })}
65
+ {option.moreVoters > 0 &&
66
+ ` ${formatFromCopy(copy, 'pollForm.moreVoters', { count: option.moreVoters })}`}
67
+ </p>
68
+ )}
69
+ </div>
70
+ ))}
71
+
72
+ <p className="text-sm text-muted-foreground">
73
+ {formatFromCopy(copy, 'pollForm.votes', { count: view.total })}
74
+ </p>
75
+
76
+ {view.mayCast ? (
77
+ <PendingButton showWorking>
78
+ {view.hasVoted
79
+ ? formatFromCopy(copy, 'pollForm.changeVote', {})
80
+ : formatFromCopy(copy, 'pollForm.vote', {})}
81
+ </PendingButton>
82
+ ) : (
83
+ view.closed && (
84
+ <p className="text-sm text-muted-foreground">
85
+ {formatFromCopy(copy, 'pollForm.closed', {})}
86
+ </p>
87
+ )
88
+ )}
89
+ </form>
90
+ )
91
+ }
@@ -1,9 +1,14 @@
1
1
  import { POLL_CHOICES_UNLIMITED, type Poll } from '@meith/polls'
2
+ import { TextLink } from '@meith/ui'
2
3
 
3
4
  import { getTranslator } from '@/server/i18n'
4
- import { votePollAction } from '@/server/poll-actions'
5
+ import { patternCopy } from '@/view/copy'
6
+ import { pollVoteView } from '@/view/poll-vote'
5
7
 
6
- import { PendingButton } from '../auth/form-controls'
8
+ import { PollVoteForm } from './poll-vote-form'
9
+
10
+ const POLL_VOTE_KEYS = ['pollForm.vote', 'pollForm.changeVote', 'pollForm.closed'] as const
11
+ const POLL_PATTERN_KEYS = ['pollForm.votes', 'pollForm.votedBy', 'pollForm.moreVoters'] as const
7
12
 
8
13
  export async function PollForm({
9
14
  poll,
@@ -16,13 +21,9 @@ export async function PollForm({
16
21
  canVote: boolean
17
22
  editHref?: string | null
18
23
  }) {
19
- const total = poll.options.reduce((sum, option) => sum + option.votes, 0)
20
24
  const t = await getTranslator()
21
-
22
- const closed = poll.closesAt !== null && poll.closesAt <= new Date()
23
- const hasVoted = poll.votedOptionIds.length > 0
24
- const mayCast = canVote && !closed && (!hasVoted || poll.allowRevote)
25
- const multiple = poll.maxOptions !== 1
25
+ const initial = pollVoteView(poll, canVote, new Date())
26
+ const copy = patternCopy([...POLL_VOTE_KEYS, ...POLL_PATTERN_KEYS], t)
26
27
 
27
28
  return (
28
29
  <section
@@ -31,7 +32,7 @@ export async function PollForm({
31
32
  >
32
33
  <h2 className="font-medium">{poll.question}</h2>
33
34
 
34
- {multiple && (
35
+ {initial.multiple && (
35
36
  <p className="mt-1 text-sm text-muted-foreground">
36
37
  {poll.maxOptions === POLL_CHOICES_UNLIMITED
37
38
  ? t.t('pollForm.chooseAny')
@@ -42,48 +43,11 @@ export async function PollForm({
42
43
  <p className="mt-1 text-sm text-muted-foreground">{t.t('pollForm.publicNotice')}</p>
43
44
  )}
44
45
 
45
- <form action={votePollAction} className="mt-3 flex flex-col gap-2">
46
- <input type="hidden" name="threadId" value={threadId} />
47
- <input type="hidden" name="pollId" value={poll.id} />
48
- {poll.options.map((option) => (
49
- <div key={option.id} className="flex flex-col gap-1">
50
- <label className="flex items-center gap-2 text-sm">
51
- <input
52
- type={multiple ? 'checkbox' : 'radio'}
53
- name="optionId"
54
- value={option.id}
55
- defaultChecked={poll.votedOptionIds.includes(option.id)}
56
- disabled={!mayCast}
57
- />
58
- <span>
59
- {option.label} ({option.votes})
60
- </span>
61
- </label>
62
- {poll.publicVotes && option.voters.length > 0 && (
63
- <p className="pl-6 text-sm text-muted-foreground">
64
- {t.t('pollForm.votedBy', {
65
- names: option.voters.map((voter) => voter.username).join(', '),
66
- })}
67
- {option.votes > option.voters.length &&
68
- ` ${t.t('pollForm.moreVoters', { count: option.votes - option.voters.length })}`}
69
- </p>
70
- )}
71
- </div>
72
- ))}
73
- {mayCast ? (
74
- <PendingButton showWorking>
75
- {hasVoted ? t.t('pollForm.changeVote') : t.t('pollForm.vote')}
76
- </PendingButton>
77
- ) : (
78
- <p className="text-sm text-muted-foreground">
79
- {closed ? t.t('pollForm.closed') : t.t('pollForm.votes', { count: total })}
80
- </p>
81
- )}
82
- </form>
46
+ <PollVoteForm threadId={threadId} pollId={poll.id} initial={initial} copy={copy} />
83
47
 
84
48
  {typeof editHref === 'string' && (
85
49
  <p className="mt-3 text-sm">
86
- <a href={editHref}>{t.t('pollForm.edit')}</a>
50
+ <TextLink href={editHref}>{t.t('pollForm.edit')}</TextLink>
87
51
  </p>
88
52
  )}
89
53
  </section>