@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.
Files changed (165) 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/page.tsx +12 -6
  35. package/app/admin/plugins/[key]/[[...path]]/page.tsx +6 -7
  36. package/app/admin/plugins/page.tsx +3 -6
  37. package/app/admin/users/[id]/page.tsx +5 -11
  38. package/app/admin/users/prune/page.tsx +4 -12
  39. package/app/admin/webhooks/page.tsx +144 -0
  40. package/app/api/subscribe/thread/[id]/route.ts +53 -0
  41. package/app/api/unsubscribe/thread/[id]/route.ts +42 -0
  42. package/app/atom.xml/route.ts +2 -2
  43. package/app/auth/sso/[provider]/callback/route.ts +10 -0
  44. package/app/feed.xml/route.ts +2 -2
  45. package/app/offline/route.ts +98 -0
  46. package/next.config.mjs +1 -0
  47. package/package.json +49 -48
  48. package/public/sw.js +42 -2
  49. package/src/components/account/draft-forms.tsx +39 -0
  50. package/src/components/account/feed-token.tsx +95 -0
  51. package/src/components/account/notification-forms.tsx +37 -2
  52. package/src/components/account/relation-forms.tsx +3 -2
  53. package/src/components/account/subscription-forms.tsx +20 -6
  54. package/src/components/account/usercp-forms.tsx +44 -0
  55. package/src/components/admin/api-token-forms.tsx +11 -7
  56. package/src/components/admin/ban-filter-forms.tsx +15 -11
  57. package/src/components/admin/content-forms.tsx +31 -14
  58. package/src/components/admin/forum-forms.tsx +241 -129
  59. package/src/components/admin/forum-tree.tsx +5 -7
  60. package/src/components/admin/navigation-forms.tsx +43 -26
  61. package/src/components/admin/navigation-tree.tsx +8 -13
  62. package/src/components/admin/oklch-picker.tsx +2 -1
  63. package/src/components/admin/theme-changes.tsx +3 -2
  64. package/src/components/admin/theme-forms.tsx +3 -2
  65. package/src/components/admin/webhook-forms.tsx +137 -0
  66. package/src/components/auth/register-form.tsx +4 -7
  67. package/src/components/board/live-region.tsx +3 -1
  68. package/src/components/content/attachment-field.tsx +93 -22
  69. package/src/components/content/combobox-keys.ts +38 -0
  70. package/src/components/content/edit-post-form.tsx +75 -1
  71. package/src/components/content/markdown-editor.tsx +29 -86
  72. package/src/components/content/mention-combobox.tsx +137 -0
  73. package/src/components/content/multiquote-button.tsx +15 -12
  74. package/src/components/content/new-thread-form.tsx +95 -17
  75. package/src/components/content/poll-edit-form.tsx +61 -39
  76. package/src/components/content/poll-vote-form.tsx +91 -0
  77. package/src/components/content/poll.tsx +12 -48
  78. package/src/components/content/progressive-marker.tsx +13 -0
  79. package/src/components/content/recipient-segment.ts +39 -0
  80. package/src/components/content/reply-form.tsx +9 -1
  81. package/src/components/content/thanks-button.tsx +12 -7
  82. package/src/components/messages/message-forms.tsx +53 -43
  83. package/src/components/messages/recipient-field.tsx +88 -0
  84. package/src/components/moderation/inline-moderation-form.tsx +2 -0
  85. package/src/components/moderation/modcp-shell.tsx +17 -3
  86. package/src/components/moderation/queue-form.tsx +3 -6
  87. package/src/components/moderation/report-forms.tsx +24 -5
  88. package/src/components/moderation/thread-tools-form.tsx +2 -0
  89. package/src/components/shell/board-notice.tsx +22 -1
  90. package/src/components/shell/confirm-dialog.tsx +93 -0
  91. package/src/components/shell/nav-disclosure-enhancer.tsx +36 -0
  92. package/src/components/shell/notice-toast.tsx +40 -0
  93. package/src/components/shell/notification-menu.tsx +5 -3
  94. package/src/components/shell/page-shell.tsx +3 -0
  95. package/src/components/shell/scheme-toggle.tsx +72 -0
  96. package/src/components/shell/theme-switcher.tsx +16 -36
  97. package/src/server/admin-settings-actions.ts +5 -0
  98. package/src/server/api/search.ts +8 -2
  99. package/src/server/api-token-actions.ts +4 -0
  100. package/src/server/appearance-actions.ts +21 -0
  101. package/src/server/attachments.ts +35 -0
  102. package/src/server/auth-actions.ts +2 -0
  103. package/src/server/auth-form-state.ts +28 -0
  104. package/src/server/auto-watch.ts +31 -0
  105. package/src/server/ban-filter-admin-actions.ts +5 -0
  106. package/src/server/confirm.ts +19 -0
  107. package/src/server/content-actions.ts +129 -19
  108. package/src/server/content-admin-actions.ts +31 -0
  109. package/src/server/discovery.ts +11 -1
  110. package/src/server/feed-routes.ts +13 -4
  111. package/src/server/feed-token-actions.ts +61 -0
  112. package/src/server/feed-token.ts +78 -0
  113. package/src/server/fixture-post-repo.ts +18 -0
  114. package/src/server/forum-admin-actions.ts +23 -16
  115. package/src/server/forum-admin.ts +10 -1
  116. package/src/server/inline-moderation-actions.ts +6 -0
  117. package/src/server/message-actions.ts +9 -0
  118. package/src/server/navigation-admin-actions.ts +6 -0
  119. package/src/server/plugin-admin.ts +7 -2
  120. package/src/server/plugin-pages.ts +50 -0
  121. package/src/server/plugin-panel.ts +34 -1
  122. package/src/server/plugin-routes.ts +8 -0
  123. package/src/server/plugin-view.tsx +46 -7
  124. package/src/server/poll-actions.ts +13 -1
  125. package/src/server/presence.ts +23 -0
  126. package/src/server/reply-core.ts +8 -1
  127. package/src/server/report-actions.ts +13 -2
  128. package/src/server/reputation-actions.ts +12 -1
  129. package/src/server/search-page.ts +8 -2
  130. package/src/server/search.ts +18 -5
  131. package/src/server/subscription-actions.ts +9 -0
  132. package/src/server/thread-core.ts +10 -2
  133. package/src/server/thread-tool-actions.ts +6 -0
  134. package/src/server/unread-goto.ts +28 -0
  135. package/src/server/upgrade-notice.ts +1 -1
  136. package/src/server/usercp-actions.ts +31 -1
  137. package/src/server/viewer-preferences.ts +5 -0
  138. package/src/server/webhook-actions.ts +98 -0
  139. package/src/server/webhooks-admin.ts +122 -0
  140. package/src/theme/contract.fixture.ts +36 -5
  141. package/src/view/account-copy.ts +39 -0
  142. package/src/view/admin-copy.ts +4 -9
  143. package/src/view/admin-nav.ts +6 -0
  144. package/src/view/admin-panel-copy.ts +23 -0
  145. package/src/view/attachments.ts +33 -1
  146. package/src/view/board-index.ts +31 -2
  147. package/src/view/board-title.ts +1 -0
  148. package/src/view/content-copy.ts +22 -12
  149. package/src/view/copy.ts +17 -0
  150. package/src/view/discovery-view.ts +2 -1
  151. package/src/view/drafts.ts +45 -0
  152. package/src/view/editor-toolbar.ts +14 -0
  153. package/src/view/forum-display.ts +39 -7
  154. package/src/view/modcp-nav.ts +32 -0
  155. package/src/view/moderation-copy.ts +20 -0
  156. package/src/view/notifications.ts +1 -0
  157. package/src/view/plugin-panel.ts +13 -0
  158. package/src/view/poll-vote.ts +46 -0
  159. package/src/view/post-link.ts +8 -1
  160. package/src/view/progressive-enhancement.ts +5 -0
  161. package/src/view/shell.ts +2 -1
  162. package/src/view/subscriptions.ts +1 -0
  163. package/src/view/thread-view.ts +41 -2
  164. package/src/view/usercp-nav.ts +6 -0
  165. 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 { votePollAction } from '@/server/poll-actions'
5
+ import { patternCopy } from '@/view/copy'
6
+ import { pollVoteView } from '@/view/poll-vote'
5
7
 
6
- import { PendingButton } from '../auth/form-controls'
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 closed = poll.closesAt !== null && poll.closesAt <= new Date()
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
- <form action={votePollAction} className="mt-3 flex flex-col gap-2">
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
- <a href={editHref}>{t.t('pollForm.edit')}</a>
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 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']}