@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,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']}
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import { useActionState } from 'react'
|
|
4
4
|
|
|
5
|
-
import { Card, CardContent, Field, Textarea } from '@meith/ui'
|
|
5
|
+
import { Card, CardContent, Field, Textarea, textLinkVariants } from '@meith/ui'
|
|
6
6
|
|
|
7
7
|
import { EMPTY_STATE } from '@/server/auth-form-state'
|
|
8
8
|
import { assignReportAction, closeReportAction, fileReportAction } from '@/server/report-actions'
|
|
9
|
+
import { REPORT_CATEGORY_LABEL_KEYS, REPORT_CATEGORY_ORDER } from '@/view/moderation-copy'
|
|
9
10
|
|
|
10
11
|
import { FormError, PendingButton, SubmitButton } from '../auth/form-controls'
|
|
11
12
|
import { type Copy, fromCopy } from '../shell/copy'
|
|
@@ -29,14 +30,32 @@ export function ReportForm({
|
|
|
29
30
|
<input type="hidden" name="kind" value={kind} />
|
|
30
31
|
<input type="hidden" name="targetId" value={targetId} />
|
|
31
32
|
|
|
33
|
+
<fieldset className="flex flex-col gap-2">
|
|
34
|
+
<legend className="mb-1 text-sm font-medium leading-none text-foreground">
|
|
35
|
+
{fromCopy(copy, 'moderationForm.report.categoryLegend')}
|
|
36
|
+
</legend>
|
|
37
|
+
<div className="flex flex-col gap-1.5">
|
|
38
|
+
{REPORT_CATEGORY_ORDER.map((category) => (
|
|
39
|
+
<label key={category} className="flex items-center gap-2 text-sm">
|
|
40
|
+
<input
|
|
41
|
+
type="radio"
|
|
42
|
+
name="category"
|
|
43
|
+
value={category}
|
|
44
|
+
defaultChecked={state.values?.category === category}
|
|
45
|
+
className="size-4 accent-primary"
|
|
46
|
+
/>
|
|
47
|
+
{fromCopy(copy, REPORT_CATEGORY_LABEL_KEYS[category])}
|
|
48
|
+
</label>
|
|
49
|
+
))}
|
|
50
|
+
</div>
|
|
51
|
+
</fieldset>
|
|
52
|
+
|
|
32
53
|
<Field
|
|
33
54
|
name="reason"
|
|
34
55
|
label={fromCopy(copy, 'moderationForm.report.label')}
|
|
35
56
|
description={fromCopy(copy, 'moderationForm.report.hint')}
|
|
36
57
|
>
|
|
37
|
-
{(control) =>
|
|
38
|
-
<Textarea {...control} required defaultValue={state.values?.reason ?? ''} />
|
|
39
|
-
)}
|
|
58
|
+
{(control) => <Textarea {...control} defaultValue={state.values?.reason ?? ''} />}
|
|
40
59
|
</Field>
|
|
41
60
|
|
|
42
61
|
<div>
|
|
@@ -68,7 +87,7 @@ export function AssignReportForm({
|
|
|
68
87
|
<input type="hidden" name="take" value={mine ? '0' : '1'} />
|
|
69
88
|
<PendingButton
|
|
70
89
|
showWorking
|
|
71
|
-
className=
|
|
90
|
+
className={`${textLinkVariants({ size: 'xs' })} focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring`}
|
|
72
91
|
>
|
|
73
92
|
{mine
|
|
74
93
|
? fromCopy(copy, 'moderationForm.report.putBack')
|
|
@@ -6,6 +6,7 @@ import { EMPTY_STATE } from '@/server/auth-form-state'
|
|
|
6
6
|
import { threadToolAction } from '@/server/thread-tool-actions'
|
|
7
7
|
|
|
8
8
|
import { FormError, PendingButton } from '../auth/form-controls'
|
|
9
|
+
import { ConfirmDialog } from '../shell/confirm-dialog'
|
|
9
10
|
import { type Copy, fromCopy } from '../shell/copy'
|
|
10
11
|
|
|
11
12
|
const BUTTON =
|
|
@@ -101,6 +102,7 @@ export function ThreadToolsForm({
|
|
|
101
102
|
</div>
|
|
102
103
|
|
|
103
104
|
<FormError message={state.error} />
|
|
105
|
+
<ConfirmDialog confirm={state.confirm} action={action} />
|
|
104
106
|
</section>
|
|
105
107
|
)
|
|
106
108
|
}
|
|
@@ -7,7 +7,9 @@ import { getTranslator } from '@/server/i18n'
|
|
|
7
7
|
import { filterView, viewerRef } from '@/server/plugin-view'
|
|
8
8
|
import { currentTheme } from '@/server/theme'
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
import { NoticeToast } from './notice-toast'
|
|
11
|
+
|
|
12
|
+
async function NoticeBanner({ kind, message, dismissHref }: NoticeModel) {
|
|
11
13
|
const theme = await currentTheme()
|
|
12
14
|
const Notice = requireSlot(theme, 'Notice')
|
|
13
15
|
|
|
@@ -19,3 +21,22 @@ export async function BoardNotice({ kind, message, dismissHref }: NoticeModel) {
|
|
|
19
21
|
|
|
20
22
|
return <Notice {...model} copy={slotCopy(theme, 'Notice', await getTranslator())} />
|
|
21
23
|
}
|
|
24
|
+
|
|
25
|
+
export async function BoardNotice({ kind, message, dismissHref }: NoticeModel) {
|
|
26
|
+
return (
|
|
27
|
+
<>
|
|
28
|
+
<noscript
|
|
29
|
+
dangerouslySetInnerHTML={{
|
|
30
|
+
__html:
|
|
31
|
+
'<style>[data-notice="toast"]{display:none!important}[data-notice="banner"]{display:block!important}</style>',
|
|
32
|
+
}}
|
|
33
|
+
/>
|
|
34
|
+
|
|
35
|
+
<NoticeToast kind={kind} message={message} dismissHref={dismissHref} />
|
|
36
|
+
|
|
37
|
+
<div data-notice="banner" className="hidden">
|
|
38
|
+
<NoticeBanner kind={kind} message={message} dismissHref={dismissHref} />
|
|
39
|
+
</div>
|
|
40
|
+
</>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import { AlertDialog } from '@meith/ui/dialog'
|
|
6
|
+
|
|
7
|
+
import type { ConfirmRequest } from '@/server/auth-form-state'
|
|
8
|
+
|
|
9
|
+
import { PendingButton } from '../auth/form-controls'
|
|
10
|
+
import { fromCopy, useCopy } from './copy'
|
|
11
|
+
|
|
12
|
+
const CONFIRM_BUTTON =
|
|
13
|
+
'inline-flex h-9 items-center justify-center rounded-md border border-destructive/30 bg-destructive/10 px-3 text-sm font-medium text-destructive transition-colors hover:bg-destructive/20 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:cursor-not-allowed disabled:opacity-60'
|
|
14
|
+
|
|
15
|
+
const CANCEL_BUTTON =
|
|
16
|
+
'inline-flex h-9 items-center justify-center rounded-md border border-border px-3 text-sm font-medium transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
|
|
17
|
+
|
|
18
|
+
export function ConfirmDialog({
|
|
19
|
+
confirm,
|
|
20
|
+
action,
|
|
21
|
+
}: {
|
|
22
|
+
confirm: ConfirmRequest | undefined
|
|
23
|
+
action: (formData: FormData) => void
|
|
24
|
+
}) {
|
|
25
|
+
const copy = useCopy()
|
|
26
|
+
const [open, setOpen] = useState(false)
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (confirm !== undefined) setOpen(true)
|
|
30
|
+
}, [confirm])
|
|
31
|
+
|
|
32
|
+
if (confirm === undefined) return null
|
|
33
|
+
|
|
34
|
+
const hidden = confirm.fields.map((field, index) => {
|
|
35
|
+
const fieldKey = `${index}:${field.name}`
|
|
36
|
+
return <input key={fieldKey} type="hidden" name={field.name} value={field.value} />
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const confirmLabel = fromCopy(copy, 'confirm.confirm')
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<>
|
|
43
|
+
<noscript
|
|
44
|
+
dangerouslySetInnerHTML={{
|
|
45
|
+
__html:
|
|
46
|
+
'<style>[data-confirm="dialog"]{display:none}[data-confirm="plain"]{display:flex!important}</style>',
|
|
47
|
+
}}
|
|
48
|
+
/>
|
|
49
|
+
|
|
50
|
+
<span data-confirm="dialog">
|
|
51
|
+
<AlertDialog.Root open={open} onOpenChange={setOpen}>
|
|
52
|
+
<AlertDialog.Portal>
|
|
53
|
+
<AlertDialog.Backdrop className="fixed inset-0 z-50 bg-foreground/40 transition-opacity duration-100 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0" />
|
|
54
|
+
<AlertDialog.Popup className="fixed left-1/2 top-1/2 z-50 flex w-[min(28rem,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg transition-[opacity,transform] duration-100 ease-out data-[ending-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:scale-95 data-[starting-style]:opacity-0">
|
|
55
|
+
<AlertDialog.Title className="text-base font-semibold">
|
|
56
|
+
{fromCopy(copy, 'confirm.heading')}
|
|
57
|
+
</AlertDialog.Title>
|
|
58
|
+
<AlertDialog.Description className="text-sm text-muted-foreground">
|
|
59
|
+
{confirm.message}
|
|
60
|
+
</AlertDialog.Description>
|
|
61
|
+
<div className="flex justify-end gap-2">
|
|
62
|
+
<AlertDialog.Close className={CANCEL_BUTTON}>
|
|
63
|
+
{fromCopy(copy, 'confirm.cancel')}
|
|
64
|
+
</AlertDialog.Close>
|
|
65
|
+
<form action={action}>
|
|
66
|
+
{hidden}
|
|
67
|
+
<input type="hidden" name="confirmed" value="1" />
|
|
68
|
+
<PendingButton showWorking className={CONFIRM_BUTTON}>
|
|
69
|
+
{confirmLabel}
|
|
70
|
+
</PendingButton>
|
|
71
|
+
</form>
|
|
72
|
+
</div>
|
|
73
|
+
</AlertDialog.Popup>
|
|
74
|
+
</AlertDialog.Portal>
|
|
75
|
+
</AlertDialog.Root>
|
|
76
|
+
</span>
|
|
77
|
+
|
|
78
|
+
<div
|
|
79
|
+
data-confirm="plain"
|
|
80
|
+
className="hidden flex-col gap-3 rounded-md border border-destructive/40 bg-destructive/5 p-4"
|
|
81
|
+
>
|
|
82
|
+
<p className="text-sm text-card-foreground">{confirm.message}</p>
|
|
83
|
+
<form action={action}>
|
|
84
|
+
{hidden}
|
|
85
|
+
<input type="hidden" name="confirmed" value="1" />
|
|
86
|
+
<PendingButton showWorking className={CONFIRM_BUTTON}>
|
|
87
|
+
{confirmLabel}
|
|
88
|
+
</PendingButton>
|
|
89
|
+
</form>
|
|
90
|
+
</div>
|
|
91
|
+
</>
|
|
92
|
+
)
|
|
93
|
+
}
|