@meith/web 0.31.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/app/(board)/[slug]/feed.xml/route.ts +5 -3
  2. package/app/(board)/[slug]/new/page.tsx +5 -0
  3. package/app/(board)/[slug]/page.tsx +7 -1
  4. package/app/(board)/discover/[view]/page.tsx +6 -1
  5. package/app/(board)/member/[id]/page.tsx +3 -5
  6. package/app/(board)/member/[id]/reputation/page.tsx +8 -15
  7. package/app/(board)/members/page.tsx +3 -8
  8. package/app/(board)/messages/[id]/page.tsx +3 -7
  9. package/app/(board)/messages/page.tsx +3 -5
  10. package/app/(board)/modcp/ip/page.tsx +3 -5
  11. package/app/(board)/modcp/plugins/[key]/[[...path]]/page.tsx +84 -0
  12. package/app/(board)/moderation/reports/page.tsx +53 -15
  13. package/app/(board)/moderation/warn/page.tsx +3 -6
  14. package/app/(board)/notifications/page.tsx +5 -10
  15. package/app/(board)/notifications/preferences/page.tsx +8 -0
  16. package/app/(board)/online/page.tsx +12 -6
  17. package/app/(board)/staff/page.tsx +4 -5
  18. package/app/(board)/stats/page.tsx +10 -3
  19. package/app/(board)/subscriptions/page.tsx +5 -10
  20. package/app/(board)/thread/[slug]/edit/page.tsx +21 -0
  21. package/app/(board)/thread/[slug]/feed.xml/route.ts +6 -3
  22. package/app/(board)/thread/[slug]/page.tsx +49 -11
  23. package/app/(board)/thread/[slug]/reply/page.tsx +5 -0
  24. package/app/(board)/unsubscribe/page.tsx +8 -11
  25. package/app/(board)/usercp/contacts/page.tsx +3 -5
  26. package/app/(board)/usercp/drafts/page.tsx +70 -0
  27. package/app/(board)/usercp/options/page.tsx +8 -1
  28. package/app/(board)/usercp/security/page.tsx +16 -0
  29. package/app/admin/antispam/page.tsx +4 -12
  30. package/app/admin/content/attachments/page.tsx +3 -6
  31. package/app/admin/content/page.tsx +3 -13
  32. package/app/admin/forums/[id]/permissions/page.tsx +9 -12
  33. package/app/admin/groups/page.tsx +7 -9
  34. package/app/admin/layout.tsx +23 -8
  35. package/app/admin/page.tsx +12 -6
  36. package/app/admin/plugins/[key]/[[...path]]/page.tsx +6 -7
  37. package/app/admin/plugins/page.tsx +3 -6
  38. package/app/admin/users/[id]/page.tsx +5 -11
  39. package/app/admin/users/prune/page.tsx +4 -12
  40. package/app/admin/webhooks/page.tsx +144 -0
  41. package/app/api/subscribe/thread/[id]/route.ts +53 -0
  42. package/app/api/unsubscribe/thread/[id]/route.ts +42 -0
  43. package/app/atom.xml/route.ts +2 -2
  44. package/app/auth/sso/[provider]/callback/route.ts +10 -0
  45. package/app/feed.xml/route.ts +2 -2
  46. package/app/offline/route.ts +98 -0
  47. package/next.config.mjs +1 -0
  48. package/package.json +49 -48
  49. package/public/sw.js +42 -2
  50. package/src/components/account/draft-forms.tsx +39 -0
  51. package/src/components/account/feed-token.tsx +95 -0
  52. package/src/components/account/notification-forms.tsx +37 -2
  53. package/src/components/account/relation-forms.tsx +3 -2
  54. package/src/components/account/subscription-forms.tsx +20 -6
  55. package/src/components/account/usercp-forms.tsx +44 -0
  56. package/src/components/admin/admin-forms.tsx +57 -9
  57. package/src/components/admin/api-token-forms.tsx +11 -7
  58. package/src/components/admin/ban-filter-forms.tsx +15 -11
  59. package/src/components/admin/content-forms.tsx +31 -14
  60. package/src/components/admin/forum-forms.tsx +241 -129
  61. package/src/components/admin/forum-tree.tsx +5 -7
  62. package/src/components/admin/navigation-forms.tsx +43 -26
  63. package/src/components/admin/navigation-tree.tsx +8 -13
  64. package/src/components/admin/oklch-picker.tsx +2 -1
  65. package/src/components/admin/theme-changes.tsx +3 -2
  66. package/src/components/admin/theme-forms.tsx +3 -2
  67. package/src/components/admin/webhook-forms.tsx +137 -0
  68. package/src/components/auth/register-form.tsx +4 -7
  69. package/src/components/board/live-region.tsx +3 -1
  70. package/src/components/content/attachment-field.tsx +93 -22
  71. package/src/components/content/combobox-keys.ts +38 -0
  72. package/src/components/content/edit-post-form.tsx +75 -1
  73. package/src/components/content/markdown-editor.tsx +29 -86
  74. package/src/components/content/mention-combobox.tsx +137 -0
  75. package/src/components/content/multiquote-button.tsx +15 -12
  76. package/src/components/content/new-thread-form.tsx +95 -17
  77. package/src/components/content/poll-edit-form.tsx +61 -39
  78. package/src/components/content/poll-vote-form.tsx +91 -0
  79. package/src/components/content/poll.tsx +12 -48
  80. package/src/components/content/progressive-marker.tsx +13 -0
  81. package/src/components/content/recipient-segment.ts +39 -0
  82. package/src/components/content/reply-form.tsx +9 -1
  83. package/src/components/content/thanks-button.tsx +12 -7
  84. package/src/components/messages/message-forms.tsx +53 -43
  85. package/src/components/messages/recipient-field.tsx +88 -0
  86. package/src/components/moderation/inline-moderation-form.tsx +2 -0
  87. package/src/components/moderation/modcp-shell.tsx +17 -3
  88. package/src/components/moderation/queue-form.tsx +3 -6
  89. package/src/components/moderation/report-forms.tsx +24 -5
  90. package/src/components/moderation/thread-tools-form.tsx +2 -0
  91. package/src/components/shell/board-notice.tsx +22 -1
  92. package/src/components/shell/confirm-dialog.tsx +93 -0
  93. package/src/components/shell/nav-disclosure-enhancer.tsx +36 -0
  94. package/src/components/shell/notice-toast.tsx +40 -0
  95. package/src/components/shell/notification-menu.tsx +5 -3
  96. package/src/components/shell/page-shell.tsx +3 -0
  97. package/src/components/shell/scheme-toggle.tsx +72 -0
  98. package/src/components/shell/theme-switcher.tsx +16 -36
  99. package/src/components/shell/view-tabs.tsx +1 -1
  100. package/src/server/admin-actions.ts +118 -49
  101. package/src/server/admin-settings-actions.ts +5 -0
  102. package/src/server/admin.ts +70 -2
  103. package/src/server/api/search.ts +8 -2
  104. package/src/server/api-token-actions.ts +4 -0
  105. package/src/server/appearance-actions.ts +21 -0
  106. package/src/server/attachments.ts +35 -0
  107. package/src/server/auth-actions.ts +2 -0
  108. package/src/server/auth-form-state.ts +28 -0
  109. package/src/server/auto-watch.ts +31 -0
  110. package/src/server/ban-filter-admin-actions.ts +5 -0
  111. package/src/server/confirm.ts +19 -0
  112. package/src/server/content-actions.ts +129 -19
  113. package/src/server/content-admin-actions.ts +31 -0
  114. package/src/server/cookies.ts +18 -0
  115. package/src/server/discovery.ts +11 -1
  116. package/src/server/feed-routes.ts +13 -4
  117. package/src/server/feed-token-actions.ts +61 -0
  118. package/src/server/feed-token.ts +78 -0
  119. package/src/server/fixture-post-repo.ts +18 -0
  120. package/src/server/forum-admin-actions.ts +23 -16
  121. package/src/server/forum-admin.ts +10 -1
  122. package/src/server/inline-moderation-actions.ts +6 -0
  123. package/src/server/message-actions.ts +9 -0
  124. package/src/server/navigation-admin-actions.ts +6 -0
  125. package/src/server/plugin-admin.ts +7 -2
  126. package/src/server/plugin-pages.ts +50 -0
  127. package/src/server/plugin-panel.ts +34 -1
  128. package/src/server/plugin-routes.ts +8 -0
  129. package/src/server/plugin-view.tsx +46 -7
  130. package/src/server/poll-actions.ts +13 -1
  131. package/src/server/presence.ts +23 -0
  132. package/src/server/push-actions.ts +9 -0
  133. package/src/server/reply-core.ts +8 -1
  134. package/src/server/report-actions.ts +13 -2
  135. package/src/server/reputation-actions.ts +12 -1
  136. package/src/server/search-page.ts +8 -2
  137. package/src/server/search.ts +18 -5
  138. package/src/server/session-cookies.ts +18 -0
  139. package/src/server/subscription-actions.ts +9 -0
  140. package/src/server/thread-core.ts +10 -2
  141. package/src/server/thread-tool-actions.ts +6 -0
  142. package/src/server/unread-goto.ts +28 -0
  143. package/src/server/upgrade-notice.ts +1 -1
  144. package/src/server/usercp-actions.ts +31 -1
  145. package/src/server/viewer-preferences.ts +5 -0
  146. package/src/server/webhook-actions.ts +98 -0
  147. package/src/server/webhooks-admin.ts +122 -0
  148. package/src/styles/globals.css +1 -1
  149. package/src/theme/contract.fixture.ts +36 -5
  150. package/src/view/account-copy.ts +39 -0
  151. package/src/view/admin-copy.ts +4 -9
  152. package/src/view/admin-nav.ts +6 -0
  153. package/src/view/admin-panel-copy.ts +29 -1
  154. package/src/view/attachments.ts +33 -1
  155. package/src/view/board-index.ts +31 -2
  156. package/src/view/board-title.ts +1 -0
  157. package/src/view/content-copy.ts +22 -12
  158. package/src/view/copy.ts +17 -0
  159. package/src/view/discovery-view.ts +2 -1
  160. package/src/view/drafts.ts +45 -0
  161. package/src/view/editor-toolbar.ts +14 -0
  162. package/src/view/forum-display.ts +39 -7
  163. package/src/view/modcp-nav.ts +32 -0
  164. package/src/view/moderation-copy.ts +20 -0
  165. package/src/view/notifications.ts +1 -0
  166. package/src/view/plugin-panel.ts +13 -0
  167. package/src/view/poll-vote.ts +46 -0
  168. package/src/view/post-link.ts +8 -1
  169. package/src/view/progressive-enhancement.ts +5 -0
  170. package/src/view/shell.ts +2 -1
  171. package/src/view/subscriptions.ts +1 -0
  172. package/src/view/thread-view.ts +41 -2
  173. package/src/view/usercp-nav.ts +6 -0
  174. package/src/view/usercp.ts +14 -0
@@ -0,0 +1,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 type="checkbox" name="subscribe" value="1" className="size-4 accent-primary" />
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={thanked}
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
- thanked
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">{thanked ? '★' : '☆'}</span>
46
- {thanked
50
+ <span aria-hidden="true">{displayThanked ? '★' : '☆'}</span>
51
+ {displayThanked
47
52
  ? fromCopy(copy, 'composer.thanks.thanked')
48
53
  : fromCopy(copy, 'composer.thanks.thanks')}
49
- {count > 0 && (
54
+ {displayCount > 0 && (
50
55
  <span className="tabular-nums opacity-70">
51
- {count}
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
- <input
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
- <input name="bcc" defaultValue={values.bcc ?? ''} className={FIELD} autoComplete="off" />
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
- <form id={formId} action={action} className="flex flex-col gap-2">
113
- <FormError message={state.error} />
114
- <input type="hidden" name="folder" value={folder} />
115
-
116
- <div className="flex flex-wrap items-center gap-2">
117
- <span className="text-xs text-muted-foreground">
118
- {fromCopy(copy, 'messageForm.withSelected')}
119
- </span>
120
-
121
- {folder === 'inbox' && (
122
- <>
123
- <PendingButton name="command" value="read" className={SECONDARY}>
124
- {fromCopy(copy, 'messageForm.markRead')}
125
- </PendingButton>
126
- <PendingButton name="command" value="unread" className={SECONDARY}>
127
- {fromCopy(copy, 'messageForm.markUnread')}
128
- </PendingButton>
129
- </>
130
- )}
131
-
132
- {folder === 'trash' ? (
133
- <>
134
- <PendingButton name="command" value="restore" className={SECONDARY}>
135
- {fromCopy(copy, 'messageForm.restore')}
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
- <PendingButton name="command" value="delete" className={SECONDARY}>
138
- {fromCopy(copy, 'messageForm.deleteForever')}
139
- </PendingButton>
140
- <PendingButton name="command" value="empty" className={SECONDARY}>
141
- {fromCopy(copy, 'messageForm.emptyTrash')}
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 { modCpNav } from '@/view/modcp-nav'
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: await getTranslator(),
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={modCpNav(access)}
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
- <a
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="text-xs font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
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
- export async function BoardNotice({ kind, message, dismissHref }: NoticeModel) {
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
+ }