@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
|
@@ -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>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import { PROGRESSIVE_FIELD } from '@/view/progressive-enhancement'
|
|
6
|
+
|
|
7
|
+
export function ProgressiveMarker() {
|
|
8
|
+
const [enhanced, setEnhanced] = useState(false)
|
|
9
|
+
|
|
10
|
+
useEffect(() => setEnhanced(true), [])
|
|
11
|
+
|
|
12
|
+
return enhanced ? <input type="hidden" name={PROGRESSIVE_FIELD} value="1" /> : null
|
|
13
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface RecipientSegment {
|
|
2
|
+
readonly start: number
|
|
3
|
+
readonly end: number
|
|
4
|
+
readonly query: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const SEPARATORS = new Set([',', ';', '\n'])
|
|
8
|
+
|
|
9
|
+
const MAX_QUERY_LENGTH = 32
|
|
10
|
+
|
|
11
|
+
export function activeRecipientSegment(value: string, caret: number): RecipientSegment | null {
|
|
12
|
+
let start = 0
|
|
13
|
+
for (let index = caret - 1; index >= 0; index -= 1) {
|
|
14
|
+
const char = value[index]
|
|
15
|
+
if (char !== undefined && SEPARATORS.has(char)) {
|
|
16
|
+
start = index + 1
|
|
17
|
+
break
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const raw = value.slice(start, caret)
|
|
22
|
+
const query = raw.trim()
|
|
23
|
+
if (query === '' || query.length > MAX_QUERY_LENGTH || /\s/.test(query)) return null
|
|
24
|
+
|
|
25
|
+
return { start, end: caret, query }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function fillRecipient(
|
|
29
|
+
value: string,
|
|
30
|
+
segment: RecipientSegment,
|
|
31
|
+
username: string,
|
|
32
|
+
): { readonly value: string; readonly caret: number } {
|
|
33
|
+
const before = value.slice(0, segment.start)
|
|
34
|
+
const after = value.slice(segment.end).replace(/^[\s,;]+/, '')
|
|
35
|
+
const lead = before === '' || before.endsWith(' ') ? '' : ' '
|
|
36
|
+
const insert = `${lead}${username}, `
|
|
37
|
+
|
|
38
|
+
return { value: before + insert + after, caret: before.length + insert.length }
|
|
39
|
+
}
|
|
@@ -22,6 +22,7 @@ export function ReplyForm({
|
|
|
22
22
|
seenLastPostId,
|
|
23
23
|
prefill,
|
|
24
24
|
canSubscribe,
|
|
25
|
+
subscribeDefault = false,
|
|
25
26
|
attachmentLimits,
|
|
26
27
|
draft,
|
|
27
28
|
collapsible = false,
|
|
@@ -32,6 +33,7 @@ export function ReplyForm({
|
|
|
32
33
|
seenLastPostId: number | null
|
|
33
34
|
prefill: string
|
|
34
35
|
canSubscribe: boolean
|
|
36
|
+
subscribeDefault?: boolean
|
|
35
37
|
attachmentLimits: UploadLimits | null
|
|
36
38
|
draft: Draft | null
|
|
37
39
|
collapsible?: boolean
|
|
@@ -79,7 +81,13 @@ export function ReplyForm({
|
|
|
79
81
|
|
|
80
82
|
{canSubscribe && (
|
|
81
83
|
<label className="flex items-center gap-2 text-sm">
|
|
82
|
-
<input
|
|
84
|
+
<input
|
|
85
|
+
type="checkbox"
|
|
86
|
+
name="subscribe"
|
|
87
|
+
value="1"
|
|
88
|
+
defaultChecked={subscribeDefault}
|
|
89
|
+
className="size-4 accent-primary"
|
|
90
|
+
/>
|
|
83
91
|
<span>{fromCopy(copy, 'composer.notifyReplies')}</span>
|
|
84
92
|
</label>
|
|
85
93
|
)}
|
|
@@ -9,6 +9,7 @@ import { thankForPostAction } from '@/server/reputation-actions'
|
|
|
9
9
|
|
|
10
10
|
import { PendingButton } from '../auth/form-controls'
|
|
11
11
|
import { formatFromCopy, fromCopy, useCopy } from '../shell/copy'
|
|
12
|
+
import { ProgressiveMarker } from './progressive-marker'
|
|
12
13
|
|
|
13
14
|
export function ThanksButton({
|
|
14
15
|
postId,
|
|
@@ -26,32 +27,36 @@ export function ThanksButton({
|
|
|
26
27
|
const [state, action] = useActionState(thankForPostAction, EMPTY_STATE)
|
|
27
28
|
const copy = useCopy()
|
|
28
29
|
|
|
30
|
+
const displayThanked = state.thanks?.thanked ?? thanked
|
|
31
|
+
const displayCount = state.thanks?.count ?? count
|
|
32
|
+
|
|
29
33
|
return (
|
|
30
34
|
<form action={action} className="inline-flex items-center">
|
|
31
35
|
<input type="hidden" name="postId" value={postId} />
|
|
32
36
|
<input type="hidden" name="userId" value={authorUserId} />
|
|
33
37
|
<input type="hidden" name="returnTo" value={returnTo} />
|
|
38
|
+
<ProgressiveMarker />
|
|
34
39
|
|
|
35
40
|
<PendingButton
|
|
36
|
-
aria-pressed={
|
|
41
|
+
aria-pressed={displayThanked}
|
|
37
42
|
className={cn(
|
|
38
43
|
'inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-sm font-medium transition-colors',
|
|
39
44
|
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
|
|
40
|
-
|
|
45
|
+
displayThanked
|
|
41
46
|
? 'bg-moderation-approved/10 text-moderation-approved hover:bg-moderation-approved/20'
|
|
42
47
|
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
|
43
48
|
)}
|
|
44
49
|
>
|
|
45
|
-
<span aria-hidden="true">{
|
|
46
|
-
{
|
|
50
|
+
<span aria-hidden="true">{displayThanked ? '★' : '☆'}</span>
|
|
51
|
+
{displayThanked
|
|
47
52
|
? fromCopy(copy, 'composer.thanks.thanked')
|
|
48
53
|
: fromCopy(copy, 'composer.thanks.thanks')}
|
|
49
|
-
{
|
|
54
|
+
{displayCount > 0 && (
|
|
50
55
|
<span className="tabular-nums opacity-70">
|
|
51
|
-
{
|
|
56
|
+
{displayCount}
|
|
52
57
|
<span className="sr-only">
|
|
53
58
|
{' '}
|
|
54
|
-
{formatFromCopy(copy, 'composer.thanks.count', { count })}
|
|
59
|
+
{formatFromCopy(copy, 'composer.thanks.count', { count: displayCount })}
|
|
55
60
|
</span>
|
|
56
61
|
</span>
|
|
57
62
|
)}
|
|
@@ -7,7 +7,9 @@ import { messageBulkAction, sendMessageAction } from '@/server/message-actions'
|
|
|
7
7
|
|
|
8
8
|
import { FormError, PendingButton } from '../auth/form-controls'
|
|
9
9
|
import { MarkdownEditor } from '../content/markdown-editor'
|
|
10
|
+
import { ConfirmDialog } from '../shell/confirm-dialog'
|
|
10
11
|
import { type Copy, fromCopy } from '../shell/copy'
|
|
12
|
+
import { RecipientField } from './recipient-field'
|
|
11
13
|
|
|
12
14
|
const FIELD =
|
|
13
15
|
'w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
|
|
@@ -42,13 +44,13 @@ export function ComposeForm({
|
|
|
42
44
|
<FormError message={state.error} />
|
|
43
45
|
{replyToId === null ? null : <input type="hidden" name="replyTo" value={replyToId} />}
|
|
44
46
|
|
|
45
|
-
<label className="flex flex-col gap-1 text-sm">
|
|
47
|
+
<label htmlFor="message-to" className="flex flex-col gap-1 text-sm">
|
|
46
48
|
<span className="font-medium">{fromCopy(copy, 'messageForm.to')}</span>
|
|
47
|
-
<
|
|
49
|
+
<RecipientField
|
|
50
|
+
id="message-to"
|
|
48
51
|
name="to"
|
|
49
52
|
defaultValue={values.to ?? to}
|
|
50
53
|
className={FIELD}
|
|
51
|
-
autoComplete="off"
|
|
52
54
|
required
|
|
53
55
|
/>
|
|
54
56
|
<span className="text-xs text-muted-foreground">
|
|
@@ -56,9 +58,14 @@ export function ComposeForm({
|
|
|
56
58
|
</span>
|
|
57
59
|
</label>
|
|
58
60
|
|
|
59
|
-
<label className="flex flex-col gap-1 text-sm">
|
|
61
|
+
<label htmlFor="message-bcc" className="flex flex-col gap-1 text-sm">
|
|
60
62
|
<span className="font-medium">{fromCopy(copy, 'messageForm.bcc')}</span>
|
|
61
|
-
<
|
|
63
|
+
<RecipientField
|
|
64
|
+
id="message-bcc"
|
|
65
|
+
name="bcc"
|
|
66
|
+
defaultValue={values.bcc ?? ''}
|
|
67
|
+
className={FIELD}
|
|
68
|
+
/>
|
|
62
69
|
<span className="text-xs text-muted-foreground">
|
|
63
70
|
{fromCopy(copy, 'messageForm.bccHint')}
|
|
64
71
|
</span>
|
|
@@ -109,44 +116,47 @@ export function MessageActionBar({
|
|
|
109
116
|
const [state, action] = useActionState(messageBulkAction, EMPTY_STATE)
|
|
110
117
|
|
|
111
118
|
return (
|
|
112
|
-
|
|
113
|
-
<
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
<
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
{
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
{
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
119
|
+
<>
|
|
120
|
+
<form id={formId} action={action} className="flex flex-col gap-2">
|
|
121
|
+
<FormError message={state.error} />
|
|
122
|
+
<input type="hidden" name="folder" value={folder} />
|
|
123
|
+
|
|
124
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
125
|
+
<span className="text-xs text-muted-foreground">
|
|
126
|
+
{fromCopy(copy, 'messageForm.withSelected')}
|
|
127
|
+
</span>
|
|
128
|
+
|
|
129
|
+
{folder === 'inbox' && (
|
|
130
|
+
<>
|
|
131
|
+
<PendingButton name="command" value="read" className={SECONDARY}>
|
|
132
|
+
{fromCopy(copy, 'messageForm.markRead')}
|
|
133
|
+
</PendingButton>
|
|
134
|
+
<PendingButton name="command" value="unread" className={SECONDARY}>
|
|
135
|
+
{fromCopy(copy, 'messageForm.markUnread')}
|
|
136
|
+
</PendingButton>
|
|
137
|
+
</>
|
|
138
|
+
)}
|
|
139
|
+
|
|
140
|
+
{folder === 'trash' ? (
|
|
141
|
+
<>
|
|
142
|
+
<PendingButton name="command" value="restore" className={SECONDARY}>
|
|
143
|
+
{fromCopy(copy, 'messageForm.restore')}
|
|
144
|
+
</PendingButton>
|
|
145
|
+
<PendingButton name="command" value="delete" className={SECONDARY}>
|
|
146
|
+
{fromCopy(copy, 'messageForm.deleteForever')}
|
|
147
|
+
</PendingButton>
|
|
148
|
+
<PendingButton name="command" value="empty" className={SECONDARY}>
|
|
149
|
+
{fromCopy(copy, 'messageForm.emptyTrash')}
|
|
150
|
+
</PendingButton>
|
|
151
|
+
</>
|
|
152
|
+
) : (
|
|
153
|
+
<PendingButton name="command" value="trash" className={SECONDARY}>
|
|
154
|
+
{fromCopy(copy, 'messageForm.moveToTrash')}
|
|
136
155
|
</PendingButton>
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
</PendingButton>
|
|
143
|
-
</>
|
|
144
|
-
) : (
|
|
145
|
-
<PendingButton name="command" value="trash" className={SECONDARY}>
|
|
146
|
-
{fromCopy(copy, 'messageForm.moveToTrash')}
|
|
147
|
-
</PendingButton>
|
|
148
|
-
)}
|
|
149
|
-
</div>
|
|
150
|
-
</form>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
</form>
|
|
159
|
+
<ConfirmDialog confirm={state.confirm} action={action} />
|
|
160
|
+
</>
|
|
151
161
|
)
|
|
152
162
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useRef, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import type { MemberSuggestion } from '@meith/accounts'
|
|
6
|
+
|
|
7
|
+
import { useMentionCombobox } from '../content/mention-combobox'
|
|
8
|
+
import {
|
|
9
|
+
activeRecipientSegment,
|
|
10
|
+
fillRecipient,
|
|
11
|
+
type RecipientSegment,
|
|
12
|
+
} from '../content/recipient-segment'
|
|
13
|
+
import { fromCopy, useCopy } from '../shell/copy'
|
|
14
|
+
|
|
15
|
+
export function RecipientField({
|
|
16
|
+
id,
|
|
17
|
+
name,
|
|
18
|
+
defaultValue,
|
|
19
|
+
className,
|
|
20
|
+
required = false,
|
|
21
|
+
}: {
|
|
22
|
+
readonly id: string
|
|
23
|
+
readonly name: string
|
|
24
|
+
readonly defaultValue: string
|
|
25
|
+
readonly className?: string
|
|
26
|
+
readonly required?: boolean
|
|
27
|
+
}) {
|
|
28
|
+
const input = useRef<HTMLInputElement>(null)
|
|
29
|
+
const segment = useRef<RecipientSegment | null>(null)
|
|
30
|
+
const [query, setQuery] = useState<string | null>(null)
|
|
31
|
+
const copy = useCopy()
|
|
32
|
+
|
|
33
|
+
function sync(element: HTMLInputElement): void {
|
|
34
|
+
const caret = element.selectionStart
|
|
35
|
+
const found =
|
|
36
|
+
caret !== null && caret === element.selectionEnd
|
|
37
|
+
? activeRecipientSegment(element.value, caret)
|
|
38
|
+
: null
|
|
39
|
+
segment.current = found
|
|
40
|
+
setQuery(found?.query ?? null)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function close(): void {
|
|
44
|
+
segment.current = null
|
|
45
|
+
setQuery(null)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function choose(candidate: MemberSuggestion): void {
|
|
49
|
+
const element = input.current
|
|
50
|
+
const active = segment.current
|
|
51
|
+
if (element === null || active === null) return
|
|
52
|
+
|
|
53
|
+
const next = fillRecipient(element.value, active, candidate.username)
|
|
54
|
+
element.value = next.value
|
|
55
|
+
element.setSelectionRange(next.caret, next.caret)
|
|
56
|
+
element.focus()
|
|
57
|
+
close()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const combobox = useMentionCombobox({
|
|
61
|
+
query,
|
|
62
|
+
onChoose: choose,
|
|
63
|
+
onDismiss: close,
|
|
64
|
+
label: (candidate) => candidate.username,
|
|
65
|
+
listboxLabel: fromCopy(copy, 'composer.recipient.suggestions'),
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className="relative">
|
|
70
|
+
<input
|
|
71
|
+
ref={input}
|
|
72
|
+
id={id}
|
|
73
|
+
name={name}
|
|
74
|
+
defaultValue={defaultValue}
|
|
75
|
+
className={className}
|
|
76
|
+
required={required}
|
|
77
|
+
autoComplete="off"
|
|
78
|
+
onKeyDown={combobox.handleKeyDown}
|
|
79
|
+
onInput={(event) => sync(event.currentTarget)}
|
|
80
|
+
onSelect={(event) => sync(event.currentTarget)}
|
|
81
|
+
onBlur={close}
|
|
82
|
+
{...combobox.anchorProps}
|
|
83
|
+
/>
|
|
84
|
+
{combobox.listbox}
|
|
85
|
+
{combobox.liveRegion}
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
@@ -8,6 +8,7 @@ import { inlineModerateAction } from '@/server/inline-moderation-actions'
|
|
|
8
8
|
import { splitSelectedAction } from '@/server/surgery-actions'
|
|
9
9
|
|
|
10
10
|
import { FormError, PendingButton } from '../auth/form-controls'
|
|
11
|
+
import { ConfirmDialog } from '../shell/confirm-dialog'
|
|
11
12
|
import { type Copy, fromCopy } from '../shell/copy'
|
|
12
13
|
|
|
13
14
|
const BUTTON =
|
|
@@ -152,6 +153,7 @@ export function InlineModerationForm({
|
|
|
152
153
|
</PendingButton>
|
|
153
154
|
)}
|
|
154
155
|
</form>
|
|
156
|
+
<ConfirmDialog confirm={state.confirm} action={action} />
|
|
155
157
|
</div>
|
|
156
158
|
)
|
|
157
159
|
}
|
|
@@ -3,20 +3,34 @@ import { notFound } from 'next/navigation'
|
|
|
3
3
|
import { ModCpNav } from '@/components/moderation/modcp-nav'
|
|
4
4
|
import { PanelShell } from '@/components/shell/panel-shell'
|
|
5
5
|
import { getActor } from '@/server/context'
|
|
6
|
+
import { currentLocation } from '@/server/current-location'
|
|
6
7
|
import { getTranslator } from '@/server/i18n'
|
|
7
8
|
import { modCpCounts, resolveModCpAccess } from '@/server/modcp'
|
|
8
|
-
import {
|
|
9
|
+
import { pluginStaffPanelSection } from '@/server/plugin-panel'
|
|
10
|
+
import { modCpNavWithPlugin, pluginKeyAtModCp } from '@/view/modcp-nav'
|
|
11
|
+
import { pluginNavChildren } from '@/view/plugin-panel'
|
|
9
12
|
import { buildPanelLinks } from '@/view/shell'
|
|
10
13
|
|
|
11
14
|
export async function ModCpShell({ children }: { children: React.ReactNode }) {
|
|
12
15
|
const access = await resolveModCpAccess()
|
|
13
16
|
if (access === null) notFound()
|
|
14
17
|
|
|
18
|
+
const t = await getTranslator()
|
|
15
19
|
const counts = await modCpCounts()
|
|
16
20
|
|
|
21
|
+
const pluginKey = pluginKeyAtModCp(await currentLocation())
|
|
22
|
+
const plugin = pluginKey === null ? null : await pluginStaffPanelSection(pluginKey, t)
|
|
23
|
+
const nav = modCpNavWithPlugin(
|
|
24
|
+
access,
|
|
25
|
+
plugin === null
|
|
26
|
+
? null
|
|
27
|
+
: { key: plugin.key, name: plugin.name, pages: pluginNavChildren(plugin.pages) },
|
|
28
|
+
t,
|
|
29
|
+
)
|
|
30
|
+
|
|
17
31
|
const actor = await getActor()
|
|
18
32
|
const links = buildPanelLinks({
|
|
19
|
-
t
|
|
33
|
+
t,
|
|
20
34
|
current: 'modcp',
|
|
21
35
|
canAccessAdminCp: actor.global.canAccessAdminCp === true,
|
|
22
36
|
})
|
|
@@ -26,7 +40,7 @@ export async function ModCpShell({ children }: { children: React.ReactNode }) {
|
|
|
26
40
|
panel="modcp"
|
|
27
41
|
nav={
|
|
28
42
|
<ModCpNav
|
|
29
|
-
nav={
|
|
43
|
+
nav={nav}
|
|
30
44
|
counts={{
|
|
31
45
|
'/moderation': counts.pending,
|
|
32
46
|
'/moderation/reports': counts.openReports,
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { useActionState } from 'react'
|
|
4
4
|
|
|
5
|
+
import { TextLink } from '@meith/ui'
|
|
6
|
+
|
|
5
7
|
import { EMPTY_STATE } from '@/server/auth-form-state'
|
|
6
8
|
import { moderateQueueAction } from '@/server/moderation-actions'
|
|
7
9
|
|
|
@@ -45,12 +47,7 @@ export function QueueForm({ rows, copy }: { rows: readonly QueueFormRow[]; copy:
|
|
|
45
47
|
forum: row.forumTitle,
|
|
46
48
|
})}
|
|
47
49
|
</span>
|
|
48
|
-
<
|
|
49
|
-
href={row.href}
|
|
50
|
-
className="font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
|
|
51
|
-
>
|
|
52
|
-
{row.threadTitle}
|
|
53
|
-
</a>
|
|
50
|
+
<TextLink href={row.href}>{row.threadTitle}</TextLink>
|
|
54
51
|
</span>
|
|
55
52
|
<span className="text-xs text-muted-foreground">
|
|
56
53
|
{copy['moderationForm.queue.byLead']}
|