@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.
- package/app/(board)/[slug]/feed.xml/route.ts +5 -3
- package/app/(board)/[slug]/new/page.tsx +5 -0
- package/app/(board)/[slug]/page.tsx +7 -1
- package/app/(board)/discover/[view]/page.tsx +6 -1
- package/app/(board)/member/[id]/page.tsx +3 -5
- package/app/(board)/member/[id]/reputation/page.tsx +8 -15
- package/app/(board)/members/page.tsx +3 -8
- package/app/(board)/messages/[id]/page.tsx +3 -7
- package/app/(board)/messages/page.tsx +3 -5
- package/app/(board)/modcp/ip/page.tsx +3 -5
- package/app/(board)/modcp/plugins/[key]/[[...path]]/page.tsx +84 -0
- package/app/(board)/moderation/reports/page.tsx +53 -15
- package/app/(board)/moderation/warn/page.tsx +3 -6
- package/app/(board)/notifications/page.tsx +5 -10
- package/app/(board)/notifications/preferences/page.tsx +8 -0
- package/app/(board)/online/page.tsx +12 -6
- package/app/(board)/staff/page.tsx +4 -5
- package/app/(board)/stats/page.tsx +10 -3
- package/app/(board)/subscriptions/page.tsx +5 -10
- package/app/(board)/thread/[slug]/edit/page.tsx +21 -0
- package/app/(board)/thread/[slug]/feed.xml/route.ts +6 -3
- package/app/(board)/thread/[slug]/page.tsx +49 -11
- package/app/(board)/thread/[slug]/reply/page.tsx +5 -0
- package/app/(board)/unsubscribe/page.tsx +8 -11
- package/app/(board)/usercp/contacts/page.tsx +3 -5
- package/app/(board)/usercp/drafts/page.tsx +70 -0
- package/app/(board)/usercp/options/page.tsx +8 -1
- package/app/(board)/usercp/security/page.tsx +16 -0
- package/app/admin/antispam/page.tsx +4 -12
- package/app/admin/content/attachments/page.tsx +3 -6
- package/app/admin/content/page.tsx +3 -13
- package/app/admin/forums/[id]/permissions/page.tsx +9 -12
- package/app/admin/groups/page.tsx +7 -9
- package/app/admin/page.tsx +12 -6
- package/app/admin/plugins/[key]/[[...path]]/page.tsx +6 -7
- package/app/admin/plugins/page.tsx +3 -6
- package/app/admin/users/[id]/page.tsx +5 -11
- package/app/admin/users/prune/page.tsx +4 -12
- package/app/admin/webhooks/page.tsx +144 -0
- package/app/api/subscribe/thread/[id]/route.ts +53 -0
- package/app/api/unsubscribe/thread/[id]/route.ts +42 -0
- package/app/atom.xml/route.ts +2 -2
- package/app/auth/sso/[provider]/callback/route.ts +10 -0
- package/app/feed.xml/route.ts +2 -2
- package/app/offline/route.ts +98 -0
- package/next.config.mjs +1 -0
- package/package.json +49 -48
- package/public/sw.js +42 -2
- package/src/components/account/draft-forms.tsx +39 -0
- package/src/components/account/feed-token.tsx +95 -0
- package/src/components/account/notification-forms.tsx +37 -2
- package/src/components/account/relation-forms.tsx +3 -2
- package/src/components/account/subscription-forms.tsx +20 -6
- package/src/components/account/usercp-forms.tsx +44 -0
- package/src/components/admin/api-token-forms.tsx +11 -7
- package/src/components/admin/ban-filter-forms.tsx +15 -11
- package/src/components/admin/content-forms.tsx +31 -14
- package/src/components/admin/forum-forms.tsx +241 -129
- package/src/components/admin/forum-tree.tsx +5 -7
- package/src/components/admin/navigation-forms.tsx +43 -26
- package/src/components/admin/navigation-tree.tsx +8 -13
- package/src/components/admin/oklch-picker.tsx +2 -1
- package/src/components/admin/theme-changes.tsx +3 -2
- package/src/components/admin/theme-forms.tsx +3 -2
- package/src/components/admin/webhook-forms.tsx +137 -0
- package/src/components/auth/register-form.tsx +4 -7
- package/src/components/board/live-region.tsx +3 -1
- package/src/components/content/attachment-field.tsx +93 -22
- package/src/components/content/combobox-keys.ts +38 -0
- package/src/components/content/edit-post-form.tsx +75 -1
- package/src/components/content/markdown-editor.tsx +29 -86
- package/src/components/content/mention-combobox.tsx +137 -0
- package/src/components/content/multiquote-button.tsx +15 -12
- package/src/components/content/new-thread-form.tsx +95 -17
- package/src/components/content/poll-edit-form.tsx +61 -39
- package/src/components/content/poll-vote-form.tsx +91 -0
- package/src/components/content/poll.tsx +12 -48
- package/src/components/content/progressive-marker.tsx +13 -0
- package/src/components/content/recipient-segment.ts +39 -0
- package/src/components/content/reply-form.tsx +9 -1
- package/src/components/content/thanks-button.tsx +12 -7
- package/src/components/messages/message-forms.tsx +53 -43
- package/src/components/messages/recipient-field.tsx +88 -0
- package/src/components/moderation/inline-moderation-form.tsx +2 -0
- package/src/components/moderation/modcp-shell.tsx +17 -3
- package/src/components/moderation/queue-form.tsx +3 -6
- package/src/components/moderation/report-forms.tsx +24 -5
- package/src/components/moderation/thread-tools-form.tsx +2 -0
- package/src/components/shell/board-notice.tsx +22 -1
- package/src/components/shell/confirm-dialog.tsx +93 -0
- package/src/components/shell/nav-disclosure-enhancer.tsx +36 -0
- package/src/components/shell/notice-toast.tsx +40 -0
- package/src/components/shell/notification-menu.tsx +5 -3
- package/src/components/shell/page-shell.tsx +3 -0
- package/src/components/shell/scheme-toggle.tsx +72 -0
- package/src/components/shell/theme-switcher.tsx +16 -36
- package/src/server/admin-settings-actions.ts +5 -0
- package/src/server/api/search.ts +8 -2
- package/src/server/api-token-actions.ts +4 -0
- package/src/server/appearance-actions.ts +21 -0
- package/src/server/attachments.ts +35 -0
- package/src/server/auth-actions.ts +2 -0
- package/src/server/auth-form-state.ts +28 -0
- package/src/server/auto-watch.ts +31 -0
- package/src/server/ban-filter-admin-actions.ts +5 -0
- package/src/server/confirm.ts +19 -0
- package/src/server/content-actions.ts +129 -19
- package/src/server/content-admin-actions.ts +31 -0
- package/src/server/discovery.ts +11 -1
- package/src/server/feed-routes.ts +13 -4
- package/src/server/feed-token-actions.ts +61 -0
- package/src/server/feed-token.ts +78 -0
- package/src/server/fixture-post-repo.ts +18 -0
- package/src/server/forum-admin-actions.ts +23 -16
- package/src/server/forum-admin.ts +10 -1
- package/src/server/inline-moderation-actions.ts +6 -0
- package/src/server/message-actions.ts +9 -0
- package/src/server/navigation-admin-actions.ts +6 -0
- package/src/server/plugin-admin.ts +7 -2
- package/src/server/plugin-pages.ts +50 -0
- package/src/server/plugin-panel.ts +34 -1
- package/src/server/plugin-routes.ts +8 -0
- package/src/server/plugin-view.tsx +46 -7
- package/src/server/poll-actions.ts +13 -1
- package/src/server/presence.ts +23 -0
- package/src/server/reply-core.ts +8 -1
- package/src/server/report-actions.ts +13 -2
- package/src/server/reputation-actions.ts +12 -1
- package/src/server/search-page.ts +8 -2
- package/src/server/search.ts +18 -5
- package/src/server/subscription-actions.ts +9 -0
- package/src/server/thread-core.ts +10 -2
- package/src/server/thread-tool-actions.ts +6 -0
- package/src/server/unread-goto.ts +28 -0
- package/src/server/upgrade-notice.ts +1 -1
- package/src/server/usercp-actions.ts +31 -1
- package/src/server/viewer-preferences.ts +5 -0
- package/src/server/webhook-actions.ts +98 -0
- package/src/server/webhooks-admin.ts +122 -0
- package/src/theme/contract.fixture.ts +36 -5
- package/src/view/account-copy.ts +39 -0
- package/src/view/admin-copy.ts +4 -9
- package/src/view/admin-nav.ts +6 -0
- package/src/view/admin-panel-copy.ts +23 -0
- package/src/view/attachments.ts +33 -1
- package/src/view/board-index.ts +31 -2
- package/src/view/board-title.ts +1 -0
- package/src/view/content-copy.ts +22 -12
- package/src/view/copy.ts +17 -0
- package/src/view/discovery-view.ts +2 -1
- package/src/view/drafts.ts +45 -0
- package/src/view/editor-toolbar.ts +14 -0
- package/src/view/forum-display.ts +39 -7
- package/src/view/modcp-nav.ts +32 -0
- package/src/view/moderation-copy.ts +20 -0
- package/src/view/notifications.ts +1 -0
- package/src/view/plugin-panel.ts +13 -0
- package/src/view/poll-vote.ts +46 -0
- package/src/view/post-link.ts +8 -1
- package/src/view/progressive-enhancement.ts +5 -0
- package/src/view/shell.ts +2 -1
- package/src/view/subscriptions.ts +1 -0
- package/src/view/thread-view.ts +41 -2
- package/src/view/usercp-nav.ts +6 -0
- 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
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
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 (
|
|
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
|
|
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 &&
|
|
468
|
-
|
|
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
|
+
['', '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
|
-
<
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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 {
|
|
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
|
-
(
|
|
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
|
-
{
|
|
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=
|
|
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
|
|
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
|
|
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
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
39
|
-
|
|
41
|
+
<Field
|
|
42
|
+
id={`field-optionLabel-${option.id}`}
|
|
43
|
+
label={t.t('pollEdit.option', { number: index + 1 })}
|
|
40
44
|
name="optionLabel"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
59
|
-
|
|
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
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
<
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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} />
|