@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.
- 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/layout.tsx +23 -8
- 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/admin-forms.tsx +57 -9
- 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/components/shell/view-tabs.tsx +1 -1
- package/src/server/admin-actions.ts +118 -49
- package/src/server/admin-settings-actions.ts +5 -0
- package/src/server/admin.ts +70 -2
- 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/cookies.ts +18 -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/push-actions.ts +9 -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/session-cookies.ts +18 -0
- 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/styles/globals.css +1 -1
- 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 +29 -1
- 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
|
@@ -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} />
|
|
@@ -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 {
|
|
5
|
+
import { patternCopy } from '@/view/copy'
|
|
6
|
+
import { pollVoteView } from '@/view/poll-vote'
|
|
5
7
|
|
|
6
|
-
import {
|
|
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
|
|
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
|
-
<
|
|
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
|
-
<
|
|
50
|
+
<TextLink href={editHref}>{t.t('pollForm.edit')}</TextLink>
|
|
87
51
|
</p>
|
|
88
52
|
)}
|
|
89
53
|
</section>
|