@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
@@ -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
+ }
@@ -0,0 +1,36 @@
1
+ 'use client'
2
+
3
+ import { useEffect } from 'react'
4
+
5
+ const OPEN_DISCLOSURES = '[data-nav-disclosure][open]'
6
+
7
+ export function NavDisclosureEnhancer() {
8
+ useEffect(() => {
9
+ function onPointerDown(event: PointerEvent) {
10
+ const target = event.target
11
+ if (!(target instanceof Node)) return
12
+
13
+ for (const details of document.querySelectorAll<HTMLDetailsElement>(OPEN_DISCLOSURES)) {
14
+ if (!details.contains(target)) details.open = false
15
+ }
16
+ }
17
+
18
+ function onKeyDown(event: KeyboardEvent) {
19
+ if (event.key !== 'Escape') return
20
+
21
+ for (const details of document.querySelectorAll<HTMLDetailsElement>(OPEN_DISCLOSURES)) {
22
+ details.open = false
23
+ }
24
+ }
25
+
26
+ document.addEventListener('pointerdown', onPointerDown)
27
+ document.addEventListener('keydown', onKeyDown)
28
+
29
+ return () => {
30
+ document.removeEventListener('pointerdown', onPointerDown)
31
+ document.removeEventListener('keydown', onKeyDown)
32
+ }
33
+ }, [])
34
+
35
+ return null
36
+ }
@@ -0,0 +1,40 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useState } from 'react'
4
+
5
+ import { Toast, type ToastTone } from '@meith/ui/toast'
6
+
7
+ import { fromCopy, useCopy } from './copy'
8
+
9
+ export function NoticeToast({
10
+ kind,
11
+ message,
12
+ dismissHref,
13
+ }: {
14
+ kind: ToastTone
15
+ message: string
16
+ dismissHref: string | null
17
+ }) {
18
+ const copy = useCopy()
19
+ const [mounted, setMounted] = useState(false)
20
+
21
+ useEffect(() => setMounted(true), [])
22
+
23
+ const clearNotice = useCallback(() => {
24
+ if (dismissHref === null || typeof window === 'undefined') return
25
+ window.history.replaceState(window.history.state, '', dismissHref)
26
+ }, [dismissHref])
27
+
28
+ if (!mounted) return null
29
+
30
+ return (
31
+ <div
32
+ data-notice="toast"
33
+ className="pointer-events-none fixed inset-x-0 bottom-4 z-50 flex justify-center px-4"
34
+ >
35
+ <Toast tone={kind} dismissLabel={fromCopy(copy, 'notice.dismiss')} onDismiss={clearNotice}>
36
+ {message}
37
+ </Toast>
38
+ </div>
39
+ )
40
+ }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useActionState } from 'react'
4
4
 
5
+ import { TextLink } from '@meith/ui'
5
6
  import { Popover } from '@meith/ui/popover'
6
7
  import { Tabs } from '@meith/ui/tabs'
7
8
 
@@ -223,12 +224,13 @@ function Panel({ tab, copy }: { tab: NotificationMenuTab; copy: Copy }) {
223
224
  )}
224
225
 
225
226
  <div className="flex items-center justify-between gap-2 border-t border-border px-2 pt-2">
226
- <a
227
+ <TextLink
227
228
  href={tab.allHref}
228
- className="rounded-md px-2 py-1 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"
229
+ size="xs"
230
+ className="rounded-md px-2 py-1 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
229
231
  >
230
232
  {fromCopy(copy, 'board.notificationMenu.viewAll')}
231
- </a>
233
+ </TextLink>
232
234
 
233
235
  {unreadRows.length > 0 && (
234
236
  <SeenForm
@@ -4,6 +4,7 @@ import { requireSlot, slotCopy } from '@meith/theme-kit'
4
4
 
5
5
  import { LogoutForm } from '@/components/account/logout-form'
6
6
  import { InstallBanner } from '@/components/shell/install-banner'
7
+ import { NavDisclosureEnhancer } from '@/components/shell/nav-disclosure-enhancer'
7
8
  import { NotificationMenu } from '@/components/shell/notification-menu'
8
9
  import { OnboardingBanner } from '@/components/shell/onboarding-banner'
9
10
  import { ThemeSwitcher } from '@/components/shell/theme-switcher'
@@ -152,6 +153,8 @@ export async function PageShell({ actor, children }: { actor: Actor; children: R
152
153
  </UserPanel>
153
154
  </Header>
154
155
 
156
+ <NavDisclosureEnhancer />
157
+
155
158
  {await boardRegion('header.notice', actor)}
156
159
 
157
160
  {install !== null && <InstallBanner {...install} />}
@@ -0,0 +1,72 @@
1
+ 'use client'
2
+
3
+ import { useActionState } from 'react'
4
+
5
+ import { setSchemeAction } from '@/server/appearance-actions'
6
+ import {
7
+ COLOUR_SCHEMES,
8
+ type ColourSchemePreference,
9
+ colorSchemeProperty,
10
+ schemeClass,
11
+ } from '@/view/theme-preference'
12
+
13
+ import { PendingButton } from '../auth/form-controls'
14
+ import { ProgressiveMarker } from '../content/progressive-marker'
15
+
16
+ const EMPTY: { scheme?: ColourSchemePreference } = {}
17
+
18
+ function applySchemeImmediately(scheme: ColourSchemePreference): void {
19
+ const root = document.documentElement
20
+ root.classList.remove('light', 'dark')
21
+ const applied = schemeClass(scheme)
22
+ if (applied !== '') root.classList.add(applied)
23
+ root.style.colorScheme = colorSchemeProperty(scheme)
24
+ }
25
+
26
+ export function SchemeToggle({
27
+ scheme,
28
+ groupLabel,
29
+ labels,
30
+ icons,
31
+ }: {
32
+ scheme: ColourSchemePreference
33
+ groupLabel: string
34
+ labels: Readonly<Record<ColourSchemePreference, string>>
35
+ icons: Readonly<Record<ColourSchemePreference, React.ReactNode>>
36
+ }) {
37
+ const [state, action] = useActionState(setSchemeAction, EMPTY)
38
+ const current = state.scheme ?? scheme
39
+
40
+ return (
41
+ <form action={action}>
42
+ <ProgressiveMarker />
43
+ <span
44
+ role="group"
45
+ aria-label={groupLabel}
46
+ className="inline-flex items-center overflow-hidden rounded-md border border-border text-muted-foreground"
47
+ >
48
+ {COLOUR_SCHEMES.map((option) => {
49
+ const selected = current === option
50
+ return (
51
+ <PendingButton
52
+ key={option}
53
+ name="scheme"
54
+ value={option}
55
+ title={labels[option]}
56
+ aria-pressed={selected}
57
+ onClick={() => applySchemeImmediately(option)}
58
+ className={`inline-flex h-8 items-center justify-center border-border px-2.5 transition-colors [&:not(:first-child)]:border-l focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring ${
59
+ selected
60
+ ? 'bg-primary text-primary-foreground'
61
+ : 'bg-background hover:bg-accent hover:text-accent-foreground'
62
+ }`}
63
+ >
64
+ {icons[option]}
65
+ <span className="sr-only">{labels[option]}</span>
66
+ </PendingButton>
67
+ )
68
+ })}
69
+ </span>
70
+ </form>
71
+ )
72
+ }
@@ -1,17 +1,12 @@
1
1
  import { PendingButton } from '@/components/auth/form-controls'
2
2
  import { MonitorIcon, MoonIcon, SunIcon } from '@/components/shell/appearance-icons'
3
+ import { SchemeToggle } from '@/components/shell/scheme-toggle'
3
4
  import { setAppearanceAction } from '@/server/appearance-actions'
4
5
  import { getTranslator } from '@/server/i18n'
5
6
  import { currentColourScheme, currentThemeKey } from '@/server/theme'
6
7
  import { getBoardThemeStyle } from '@/server/theme-runtime'
7
8
  import { COLOUR_SCHEMES, type ColourSchemePreference } from '@/view/theme-preference'
8
9
 
9
- const SCHEME_ICON: Record<ColourSchemePreference, () => React.ReactNode> = {
10
- light: SunIcon,
11
- system: MonitorIcon,
12
- dark: MoonIcon,
13
- }
14
-
15
10
  const SCHEME_LABEL_KEY: Record<ColourSchemePreference, string> = {
16
11
  system: 'appearance.scheme.system',
17
12
  light: 'appearance.scheme.light',
@@ -29,6 +24,15 @@ export async function ThemeSwitcher() {
29
24
  getTranslator(),
30
25
  ])
31
26
 
27
+ const icons: Record<ColourSchemePreference, React.ReactNode> = {
28
+ light: <SunIcon />,
29
+ system: <MonitorIcon />,
30
+ dark: <MoonIcon />,
31
+ }
32
+ const labels = Object.fromEntries(
33
+ COLOUR_SCHEMES.map((option) => [option, t.t(SCHEME_LABEL_KEY[option])]),
34
+ ) as Record<ColourSchemePreference, string>
35
+
32
36
  return (
33
37
  <section
34
38
  aria-label={t.t('appearance.section')}
@@ -55,36 +59,12 @@ export async function ThemeSwitcher() {
55
59
  </form>
56
60
  )}
57
61
 
58
- <form action={setAppearanceAction}>
59
- <span
60
- role="group"
61
- aria-label={t.t('appearance.schemeGroup')}
62
- className="inline-flex items-center overflow-hidden rounded-md border border-border text-muted-foreground"
63
- >
64
- {COLOUR_SCHEMES.map((option) => {
65
- const Icon = SCHEME_ICON[option]
66
- const label = t.t(SCHEME_LABEL_KEY[option])
67
- const selected = scheme === option
68
- return (
69
- <PendingButton
70
- key={option}
71
- name="scheme"
72
- value={option}
73
- title={label}
74
- aria-pressed={selected}
75
- className={`inline-flex h-8 items-center justify-center border-border px-2.5 transition-colors [&:not(:first-child)]:border-l focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring ${
76
- selected
77
- ? 'bg-primary text-primary-foreground'
78
- : 'bg-background hover:bg-accent hover:text-accent-foreground'
79
- }`}
80
- >
81
- <Icon />
82
- <span className="sr-only">{label}</span>
83
- </PendingButton>
84
- )
85
- })}
86
- </span>
87
- </form>
62
+ <SchemeToggle
63
+ scheme={scheme}
64
+ groupLabel={t.t('appearance.schemeGroup')}
65
+ labels={labels}
66
+ icons={icons}
67
+ />
88
68
  </section>
89
69
  )
90
70
  }
@@ -18,6 +18,7 @@ import { recordAdminAction, requireAdmin } from './admin'
18
18
  import type { FormState } from './auth-form-state'
19
19
  import { tr } from './i18n'
20
20
  import { emitEvent } from './plugin-view'
21
+ import { searchProvider } from './search'
21
22
  import { getSettings } from './settings'
22
23
 
23
24
  function submittedKeys(form: FormData): readonly SettingDefinition[] {
@@ -59,6 +60,10 @@ export async function saveAdminSettingsAction(
59
60
  await getSettings(),
60
61
  )
61
62
 
63
+ if (result.changed.includes('search.language')) {
64
+ await searchProvider()?.markForReindex()
65
+ }
66
+
62
67
  if (result.changed.length > 0) {
63
68
  const tags = [CacheTags.settings(), ...result.invalidates]
64
69
  await drivers().cache.invalidateTags(tags)
@@ -13,7 +13,13 @@ import {
13
13
 
14
14
  import { filterWords } from '../../view/word-filter'
15
15
  import { activeWordFilter } from '../content-admin'
16
- import { requireSearch, requireSearchEnabled, searchMinWordLength, searchScopeFor } from '../search'
16
+ import {
17
+ requireSearch,
18
+ requireSearchEnabled,
19
+ searchLanguage,
20
+ searchMinWordLength,
21
+ searchScopeFor,
22
+ } from '../search'
17
23
  import {
18
24
  type ApiResult,
19
25
  type ApiRoutes,
@@ -55,7 +61,7 @@ export const SEARCH_HANDLERS: ApiRoutes = [
55
61
  const forumIds = ids('forum')
56
62
  const authorUserIds = ids('by')
57
63
 
58
- const results = await requireSearch().search(
64
+ const results = await requireSearch(await searchLanguage()).search(
59
65
  searchQueryFrom(
60
66
  parsed.terms,
61
67
  {
@@ -8,6 +8,7 @@ import { msg } from '@meith/i18n'
8
8
  import { recordAdminAction, requireAdmin, requireFreshAdmin } from './admin'
9
9
  import { apiTokenStore, issueApiToken } from './api-tokens-admin'
10
10
  import type { FormState } from './auth-form-state'
11
+ import { requireConfirmation } from './confirm'
11
12
  import { getMessageResolver, tr } from './i18n'
12
13
 
13
14
  function field(form: FormData, name: string): string {
@@ -69,6 +70,9 @@ export async function revokeApiTokenAction(_prev: FormState, form: FormData): Pr
69
70
  const id = Number(field(form, 'tokenId'))
70
71
  if (!Number.isSafeInteger(id) || id <= 0) return { error: await tr('notice.app.such-token') }
71
72
 
73
+ const confirm = requireConfirmation(form, await tr('adminPanel.token.confirmRevoke'))
74
+ if (confirm !== null) return confirm
75
+
72
76
  const store = apiTokenStore()
73
77
  if (store === null) return { error: await tr('notice.app.board-database-api') }
74
78
 
@@ -2,7 +2,9 @@
2
2
 
3
3
  import { cookies } from 'next/headers'
4
4
 
5
+ import { isEnhancedSubmit } from '@/view/progressive-enhancement'
5
6
  import {
7
+ type ColourSchemePreference,
6
8
  isColourScheme,
7
9
  PREFERENCE_COOKIE_MAX_AGE,
8
10
  SCHEME_COOKIE,
@@ -37,3 +39,22 @@ export async function setAppearanceAction(form: FormData): Promise<void> {
37
39
 
38
40
  await redirectToCurrentPath()
39
41
  }
42
+
43
+ export interface SchemeState {
44
+ readonly scheme?: ColourSchemePreference | undefined
45
+ }
46
+
47
+ export async function setSchemeAction(_prev: SchemeState, form: FormData): Promise<SchemeState> {
48
+ const raw = form.get('scheme')
49
+ const scheme = typeof raw === 'string' && isColourScheme(raw) ? raw : null
50
+
51
+ if (scheme !== null) {
52
+ const jar = await cookies()
53
+ jar.set(SCHEME_COOKIE, scheme, COOKIE_OPTIONS)
54
+ }
55
+
56
+ if (isEnhancedSubmit(form)) return { scheme: scheme ?? undefined }
57
+
58
+ await redirectToCurrentPath()
59
+ return {}
60
+ }
@@ -129,6 +129,41 @@ export async function stageAttachments(
129
129
  return service.stage(accepted)
130
130
  }
131
131
 
132
+ export async function resolveEditAttachmentScope(
133
+ actor: Actor,
134
+ forumId: number,
135
+ ): Promise<AttachmentScope> {
136
+ const { authorizer, threadWrites } = getContainer()
137
+ const rules = threadWrites === null ? null : await threadWrites.postingRules(forumId)
138
+
139
+ return {
140
+ forumId,
141
+ forum: await authorizer.forumMatrix(actor, forumId),
142
+ allowsAttachments: rules?.allowAttachments === true,
143
+ }
144
+ }
145
+
146
+ export async function removeAttachmentsFromPost(
147
+ ids: readonly number[],
148
+ post: { readonly postId: number; readonly userId: number },
149
+ ): Promise<readonly AttachmentRecord[]> {
150
+ const service = attachmentService()
151
+ if (service === null || ids.length === 0) return []
152
+
153
+ const removed: AttachmentRecord[] = []
154
+ for (const id of ids) {
155
+ const record = await service.removeFromPost(id, post.postId)
156
+ if (record === null) continue
157
+ removed.push(record)
158
+ await emitEvent(
159
+ 'attachment.deleted',
160
+ { attachmentId: record.id },
161
+ { userId: post.userId, isGuest: false },
162
+ )
163
+ }
164
+ return removed
165
+ }
166
+
132
167
  export async function attachStaged(
133
168
  staged: readonly StagedUpload[],
134
169
  post: { readonly postId: number; readonly forumId: number; readonly userId: number },
@@ -20,6 +20,7 @@ import { recordAuthEvent } from './auth-events'
20
20
  import type { FormState } from './auth-form-state'
21
21
  import { sendPasswordResetEmail, sendVerificationEmail } from './auth-mail'
22
22
  import { configuredIdentity, configuredSessions, getContainer } from './container'
23
+ import { revokeFeedToken } from './feed-token'
23
24
  import { formStateReporter } from './form-state-reporter'
24
25
  import { getTranslator, tr } from './i18n'
25
26
  import { termsAcceptance } from './legal'
@@ -477,6 +478,7 @@ export async function confirmResetAction(_prev: FormState, form: FormData): Prom
477
478
  const identity = await configuredIdentity()
478
479
  try {
479
480
  const { userId } = await identity.redeemPasswordReset(token, password)
481
+ await revokeFeedToken(userId)
480
482
  await recordAuthEvent({ userId, kind: 'password_reset' })
481
483
  } catch (err) {
482
484
  return toFormState(err, { token })
@@ -3,12 +3,40 @@ export interface UndoState {
3
3
  readonly expiresAt: string
4
4
  }
5
5
 
6
+ export interface ConfirmField {
7
+ readonly name: string
8
+ readonly value: string
9
+ }
10
+
11
+ export interface ConfirmRequest {
12
+ readonly message: string
13
+ readonly fields: readonly ConfirmField[]
14
+ }
15
+
16
+ export interface PollDraftValues {
17
+ readonly question: string
18
+ readonly options: readonly string[]
19
+ readonly closesAt: string
20
+ readonly maxOptions: string
21
+ readonly allowRevote: boolean
22
+ readonly publicVotes: boolean
23
+ }
24
+
25
+ export interface ThanksResult {
26
+ readonly thanked: boolean
27
+ readonly count: number
28
+ }
29
+
6
30
  export interface FormState {
7
31
  readonly error?: string | undefined
8
32
  readonly notice?: string | undefined
9
33
  readonly values?: Record<string, string> | undefined
34
+ readonly poll?: PollDraftValues | undefined
10
35
  readonly preview?: string | undefined
11
36
  readonly undo?: UndoState | undefined
37
+ readonly confirm?: ConfirmRequest | undefined
38
+ readonly thanks?: ThanksResult | undefined
39
+ readonly subscribed?: boolean | undefined
12
40
  }
13
41
 
14
42
  export const EMPTY_STATE: FormState = {}