@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,7 +2,7 @@
2
2
 
3
3
  import { revalidatePath } from 'next/cache'
4
4
 
5
- import { readMatrixCell } from '@meith/authorization'
5
+ import { diffMatrixSubmission, matrixCellName, readMatrixCell } from '@meith/authorization'
6
6
  import { CacheTags, FORUM_PERMISSION_FIELDS, ValidationError } from '@meith/core'
7
7
  import { MODERATOR_RIGHTS } from '@meith/db'
8
8
  import { drivers } from '@meith/drivers'
@@ -86,31 +86,38 @@ export async function saveForumOptionsAction(_prev: FormState, form: FormData):
86
86
  }
87
87
  }
88
88
 
89
- export async function saveForumPermissionsAction(
89
+ export async function saveForumPermissionMatrixAction(
90
90
  _prev: FormState,
91
91
  form: FormData,
92
92
  ): Promise<FormState> {
93
93
  try {
94
94
  await requireAdmin()
95
95
  const id = forumId(form)
96
- const groupId = Number(trimmedText(form, 'groupId'))
97
- if (!Number.isSafeInteger(groupId) || groupId <= 0) {
98
- throw new ValidationError(msg('error.app.such-group'))
99
- }
96
+ const repository = requireForumAdmin()
100
97
 
101
- const values: Record<string, boolean | number | null> = {}
102
- for (const field of FORUM_PERMISSION_FIELDS) {
103
- const raw = form.get(field.key)
104
- values[field.key] = readMatrixCell(field, typeof raw === 'string' ? raw : undefined)
105
- }
98
+ const groups = await repository.listGroups()
99
+ const current = await repository.readOverrides([id])
100
+
101
+ const submissions = groups.map((group) => ({
102
+ groupId: group.id,
103
+ values: Object.fromEntries(
104
+ FORUM_PERMISSION_FIELDS.map((field) => {
105
+ const raw = form.get(matrixCellName(group.id, field.key))
106
+ return [field.key, readMatrixCell(field, typeof raw === 'string' ? raw : undefined)]
107
+ }),
108
+ ),
109
+ }))
106
110
 
107
- await requireForumAdmin().saveOverrides(id, groupId, values)
111
+ const changes = diffMatrixSubmission(current, id, submissions)
112
+ await repository.saveOverridesForGroups(id, changes)
108
113
 
109
114
  refreshForumPermissionScreens()
110
- await recordAdminAction({
111
- action: 'forum.permissions_changed',
112
- detail: { forumId: id, groupId },
113
- })
115
+ for (const change of changes) {
116
+ await recordAdminAction({
117
+ action: 'forum.permissions_changed',
118
+ detail: { forumId: id, groupId: change.groupId },
119
+ })
120
+ }
114
121
 
115
122
  return { notice: 'saved' }
116
123
  } catch (err) {
@@ -2,10 +2,14 @@ import { msg } from '@meith/i18n'
2
2
  import 'server-only'
3
3
 
4
4
  import {
5
+ buildFieldMatrix,
5
6
  buildPermissionMatrix,
6
7
  type CopyPlan,
8
+ type FieldMatrixRow,
7
9
  type ForumOverride,
10
+ type MatrixColumn,
8
11
  type MatrixRow,
12
+ matrixColumns,
9
13
  planCopyToDescendants,
10
14
  } from '@meith/authorization'
11
15
  import { ForbiddenError, type PermissionSet } from '@meith/core'
@@ -37,6 +41,8 @@ export function ancestorChain(forum: ForumRow): readonly number[] {
37
41
  export interface ForumMatrixView {
38
42
  readonly forum: ForumRow
39
43
  readonly rows: readonly MatrixRow[]
44
+ readonly columns: readonly MatrixColumn[]
45
+ readonly fields: readonly FieldMatrixRow[]
40
46
  readonly chain: readonly number[]
41
47
  readonly overrides: readonly ForumOverride[]
42
48
  readonly groups: readonly { readonly groupId: number; readonly title: string }[]
@@ -77,6 +83,7 @@ export async function buildForumMatrixView(forumId: number): Promise<ForumMatrix
77
83
  const descendants = all.filter((row) => row.path.startsWith(`${forum.path}.`))
78
84
 
79
85
  const overrides = await repository.readOverrides([...chain, ...descendants.map((row) => row.id)])
86
+ const rows = buildPermissionMatrix({ chain, groups, overrides })
80
87
 
81
88
  return {
82
89
  forum,
@@ -84,7 +91,9 @@ export async function buildForumMatrixView(forumId: number): Promise<ForumMatrix
84
91
  overrides,
85
92
  descendants,
86
93
  groups: groups.map(({ groupId, title }) => ({ groupId, title })),
87
- rows: buildPermissionMatrix({ chain, groups, overrides }),
94
+ rows,
95
+ columns: matrixColumns(rows),
96
+ fields: buildFieldMatrix(rows),
88
97
  }
89
98
  }
90
99
 
@@ -16,6 +16,7 @@ import {
16
16
  } from '@meith/moderation'
17
17
 
18
18
  import type { FormState } from './auth-form-state'
19
+ import { requireConfirmation } from './confirm'
19
20
  import { getContainer } from './container'
20
21
  import { getActor } from './context'
21
22
  import { formStateReporter } from './form-state-reporter'
@@ -71,6 +72,11 @@ export async function inlineModerateAction(_prev: FormState, form: FormData): Pr
71
72
  throw new ForbiddenError(msg('error.app.moderate-anything-here'))
72
73
  }
73
74
 
75
+ if (tool === 'delete') {
76
+ const confirm = requireConfirmation(form, await tr('moderationForm.confirm.delete'))
77
+ if (confirm !== null) return confirm
78
+ }
79
+
74
80
  outcome = await new InlineModeration({ inline: inlineModeration }).apply({
75
81
  selection,
76
82
  tool,
@@ -9,9 +9,11 @@ import { parseFolder } from '@meith/messages'
9
9
  import { folderHref } from '../view/messages'
10
10
  import { dailyLimitMessage, limitMessage, spendDailyLimit, spendLimit } from './antispam'
11
11
  import type { FormState } from './auth-form-state'
12
+ import { requireConfirmation } from './confirm'
12
13
  import { getActor } from './context'
13
14
  import { formStateReporter } from './form-state-reporter'
14
15
  import { text } from './form-values'
16
+ import { tr } from './i18n'
15
17
  import { requireMessaging } from './messages'
16
18
 
17
19
  const toFormState = formStateReporter('message-actions', 'unexpected error in a message action')
@@ -65,12 +67,19 @@ export async function messageBulkAction(_prev: FormState, form: FormData): Promi
65
67
  const { service, userId } = await requireMessaging(await getActor())
66
68
 
67
69
  if (command === 'empty') {
70
+ const confirm = requireConfirmation(form, await tr('messageForm.confirm.emptyTrash'))
71
+ if (confirm !== null) return confirm
68
72
  query = `emptied=${await service.emptyTrash(userId)}`
69
73
  } else {
70
74
  const copyIds = selectedIds(form)
71
75
  if (copyIds.length === 0)
72
76
  throw new ValidationError(msg('error.app.select-at-least-one-message'))
73
77
 
78
+ if (command === 'delete') {
79
+ const confirm = requireConfirmation(form, await tr('messageForm.confirm.deleteForever'))
80
+ if (confirm !== null) return confirm
81
+ }
82
+
74
83
  switch (command) {
75
84
  case 'read':
76
85
  query = `marked=${await service.markRead(userId, copyIds)}`
@@ -16,8 +16,10 @@ import { NAVIGATION_AUDIENCE_VALUES, outlineOf } from '../view/navigation'
16
16
  import { isNavigationNudge, isWhereItIs, nudgeTarget } from '../view/navigation-arrange'
17
17
  import { recordAdminAction, requireAdmin } from './admin'
18
18
  import type { FormState } from './auth-form-state'
19
+ import { requireConfirmation } from './confirm'
19
20
  import { formStateReporter } from './form-state-reporter'
20
21
  import { checkbox, trimmedText } from './form-values'
22
+ import { tr } from './i18n'
21
23
  import { navigationRepository } from './navigation'
22
24
  import { isSafeLocalPath } from './safe-path'
23
25
 
@@ -150,6 +152,10 @@ export async function deleteNavigationItemAction(
150
152
  await requireAdmin()
151
153
 
152
154
  const navigationItemId = itemId(form)
155
+
156
+ const confirm = requireConfirmation(form, await tr('adminNavigation.confirm.delete'))
157
+ if (confirm !== null) return confirm
158
+
153
159
  await requireNavigation().delete(navigationItemId)
154
160
 
155
161
  await refresh()
@@ -2,7 +2,7 @@ import 'server-only'
2
2
 
3
3
  import forumConfig from '@board/config'
4
4
 
5
- import { env, logger, readPluginEnv } from '@meith/core'
5
+ import { cronCadenceSeconds, env, logger, parseCron, readPluginEnv } from '@meith/core'
6
6
  import {
7
7
  appliedPluginMigrations,
8
8
  getDb,
@@ -58,6 +58,7 @@ export interface PluginTaskRow {
58
58
  readonly id: string
59
59
  readonly registeredId: string
60
60
  readonly intervalSeconds: number
61
+ readonly schedule: string | null
61
62
  }
62
63
 
63
64
  export interface PluginPageRow {
@@ -228,7 +229,11 @@ export async function pluginInventory(t?: Translator): Promise<PluginInventory>
228
229
  (task): PluginTaskRow => ({
229
230
  id: task.id,
230
231
  registeredId: pluginTaskId(entry.key, task.id),
231
- intervalSeconds: task.intervalSeconds,
232
+ intervalSeconds:
233
+ task.schedule === undefined
234
+ ? task.intervalSeconds
235
+ : cronCadenceSeconds(parseCron(task.schedule)),
236
+ schedule: task.schedule ?? null,
232
237
  }),
233
238
  ),
234
239
 
@@ -7,6 +7,7 @@ import { logger } from '@meith/core'
7
7
  import type { PluginDefinition } from '@meith/plugin-kit'
8
8
 
9
9
  import { getTranslator } from './i18n'
10
+ import { resolveModCpAccess } from './modcp'
10
11
  import { runtimeContextFor } from './plugin-runtime'
11
12
  import { getSettingOverrides } from './settings'
12
13
 
@@ -26,6 +27,11 @@ export type BoardPageResult =
26
27
  | { readonly outcome: 'missing' }
27
28
  | { readonly outcome: 'sign-in-first'; readonly title: string }
28
29
 
30
+ export type StaffPageResult =
31
+ | { readonly outcome: 'rendered'; readonly title: string; readonly node: ReactNode | null }
32
+ | { readonly outcome: 'missing' }
33
+ | { readonly outcome: 'forbidden' }
34
+
29
35
  function translated(
30
36
  t: Awaited<ReturnType<typeof getTranslator>> | undefined,
31
37
  key: string | undefined,
@@ -59,6 +65,7 @@ export async function renderPluginBoardPage(
59
65
 
60
66
  const page = (definition.pages ?? []).find((candidate) => candidate.path === path)
61
67
  if (page === undefined) return { outcome: 'missing' }
68
+ if (page.access === 'staff') return { outcome: 'missing' }
62
69
 
63
70
  const overrides = await getSettingOverrides()
64
71
  if (overrides.get(`plugin.${pluginKey}._enabled`) === '0') return { outcome: 'missing' }
@@ -90,6 +97,49 @@ export async function renderPluginBoardPage(
90
97
  }
91
98
  }
92
99
 
100
+ export async function renderPluginStaffPage(
101
+ pluginKey: string,
102
+ path: string,
103
+ request: BoardPageRequest,
104
+ ): Promise<StaffPageResult> {
105
+ const entry = (forumConfig.plugins ?? []).find((candidate) => candidate.key === pluginKey)
106
+ const definition = entry?.plugin as PluginDefinition | undefined
107
+
108
+ if (entry === undefined || definition === undefined) return { outcome: 'missing' }
109
+ if (entry.enabled === false) return { outcome: 'missing' }
110
+
111
+ const page = (definition.pages ?? []).find((candidate) => candidate.path === path)
112
+ if (page === undefined || page.access !== 'staff') return { outcome: 'missing' }
113
+
114
+ const overrides = await getSettingOverrides()
115
+ if (overrides.get(`plugin.${pluginKey}._enabled`) === '0') return { outcome: 'missing' }
116
+
117
+ const access = await resolveModCpAccess()
118
+ if (access === null || !access.hasGroupAccess) return { outcome: 'forbidden' }
119
+
120
+ const [log, t] = [logger({ component: 'plugin-page', plugin: pluginKey }), await getTranslator()]
121
+ const title = translated(t, page.titleKey, page.title, page.titleArgs)
122
+
123
+ try {
124
+ return {
125
+ outcome: 'rendered',
126
+ title,
127
+ node: await page.render({
128
+ ...runtimeContextFor(pluginKey, definition, overrides, log),
129
+ viewer: request.viewer,
130
+ path,
131
+ query: request.query,
132
+ boardUrl: request.boardUrl,
133
+ locale: t.locale,
134
+ t,
135
+ }),
136
+ }
137
+ } catch (error) {
138
+ log.error({ err: error, path }, 'plugin staff page failed to render')
139
+ return { outcome: 'rendered', title, node: null }
140
+ }
141
+ }
142
+
93
143
  export async function renderPluginAdminPage(
94
144
  pluginKey: string,
95
145
  path: string,
@@ -3,7 +3,7 @@ import 'server-only'
3
3
  import forumConfig from '@board/config'
4
4
 
5
5
  import type { Translator } from '@meith/i18n'
6
- import { type PluginDefinition, pluginAdminPath } from '@meith/plugin-kit'
6
+ import { type PluginDefinition, pluginAdminPath, pluginStaffPagePath } from '@meith/plugin-kit'
7
7
 
8
8
  import type { PluginPage } from '@/view/plugin-panel'
9
9
 
@@ -47,3 +47,36 @@ export async function pluginPanelSection(
47
47
  })),
48
48
  }
49
49
  }
50
+
51
+ export async function pluginStaffPanelSection(
52
+ pluginKey: string,
53
+ t?: Translator,
54
+ ): Promise<PluginPanelSection | null> {
55
+ const entry = (forumConfig.plugins ?? []).find((candidate) => candidate.key === pluginKey)
56
+ const definition = entry?.plugin as PluginDefinition | undefined
57
+
58
+ if (entry === undefined || definition === undefined) return null
59
+ if (entry.enabled === false) return null
60
+
61
+ const pages = (definition.pages ?? []).filter((page) => page.access === 'staff')
62
+ if (pages.length === 0) return null
63
+
64
+ const overrides = await getSettingOverrides()
65
+ if (overrides.get(`plugin.${pluginKey}._enabled`) === '0') return null
66
+
67
+ return {
68
+ key: pluginKey,
69
+ name:
70
+ t !== undefined && definition.nameKey !== undefined && t.has(definition.nameKey)
71
+ ? t.t(definition.nameKey)
72
+ : definition.name,
73
+ pages: pages.map((page) => ({
74
+ path: page.path,
75
+ title:
76
+ t !== undefined && page.titleKey !== undefined && t.has(page.titleKey)
77
+ ? t.t(page.titleKey, page.titleArgs)
78
+ : page.title,
79
+ href: pluginStaffPagePath(pluginKey, page.path),
80
+ })),
81
+ }
82
+ }
@@ -16,6 +16,7 @@ import {
16
16
  import { recordAdminAction, requireAdmin } from './admin'
17
17
  import { boardUrl } from './board-url'
18
18
  import { getActor } from './context'
19
+ import { resolveModCpAccess } from './modcp'
19
20
  import { activeDefinitions, pluginHost, syncPluginEnablement } from './plugin-host'
20
21
  import { runtimeContextFor } from './plugin-runtime'
21
22
  import { viewerRef } from './plugin-view'
@@ -213,6 +214,13 @@ export async function dispatchPluginRoute(
213
214
  return fail(401, 'unauthenticated', 'Sign in to use this.')
214
215
  }
215
216
 
217
+ if (route.access === 'staff') {
218
+ const access = await resolveModCpAccess()
219
+ if (access === null || !access.hasGroupAccess) {
220
+ return fail(403, 'staff_only', await tr('notice.app.moderators-only-endpoint'))
221
+ }
222
+ }
223
+
216
224
  if (route.access === 'admin') {
217
225
  try {
218
226
  await requireAdmin()
@@ -9,8 +9,10 @@ import type {
9
9
  HookValue,
10
10
  PluginRegion,
11
11
  PluginRegionContext,
12
+ ThreadRowBadgeSubject,
12
13
  } from '@meith/plugin-kit'
13
14
 
15
+ import { getTranslator } from './i18n'
14
16
  import { pluginHost, syncPluginEnablement } from './plugin-host'
15
17
 
16
18
  export function viewerRef(actor: Actor): { userId: number | null; isGuest: boolean } {
@@ -37,22 +39,59 @@ export async function emitEvent<K extends HookName>(
37
39
 
38
40
  export async function pluginRegion(
39
41
  region: PluginRegion,
40
- context: Omit<PluginRegionContext, 'region' | 'runtime'>,
42
+ context: Omit<PluginRegionContext, 'region' | 'runtime' | 'locale' | 't'>,
43
+ card?: string,
41
44
  ): Promise<React.ReactNode> {
42
45
  await syncPluginEnablement()
43
46
 
44
- const nodes = await pluginHost.renderRegion(region, { ...context, region })
47
+ const t = await getTranslator()
48
+ const nodes = await pluginHost.renderRegion(region, { ...context, region, locale: t.locale, t })
45
49
  if (nodes.length === 0) return null
46
50
 
47
51
  return (
48
52
  <>
49
- {nodes.map((entry) => (
50
- <Fragment key={entry.key}>{entry.node}</Fragment>
51
- ))}
53
+ {nodes.map((entry) =>
54
+ card === undefined ? (
55
+ <Fragment key={entry.key}>{entry.node}</Fragment>
56
+ ) : (
57
+ <section key={entry.key} className={card}>
58
+ {entry.node}
59
+ </section>
60
+ ),
61
+ )}
52
62
  </>
53
63
  )
54
64
  }
55
65
 
56
- export async function boardRegion(region: PluginRegion, actor: Actor): Promise<React.ReactNode> {
57
- return pluginRegion(region, { viewer: viewerRef(actor), subjectId: null, authorId: null })
66
+ export async function boardRegion(
67
+ region: PluginRegion,
68
+ actor: Actor,
69
+ card?: string,
70
+ ): Promise<React.ReactNode> {
71
+ return pluginRegion(region, { viewer: viewerRef(actor), subjectId: null, authorId: null }, card)
72
+ }
73
+
74
+ export async function threadRowBadges(
75
+ actor: Actor,
76
+ threads: readonly ThreadRowBadgeSubject[],
77
+ ): Promise<ReadonlyMap<number, React.ReactNode>> {
78
+ await syncPluginEnablement()
79
+ if (threads.length === 0) return new Map()
80
+
81
+ const t = await getTranslator()
82
+ const byThread = await pluginHost.renderThreadRowBadges({
83
+ viewer: viewerRef(actor),
84
+ threads,
85
+ locale: t.locale,
86
+ t,
87
+ })
88
+
89
+ const out = new Map<number, React.ReactNode>()
90
+ for (const [threadId, nodes] of byThread) {
91
+ out.set(
92
+ threadId,
93
+ nodes.map((entry) => <Fragment key={entry.key}>{entry.node}</Fragment>),
94
+ )
95
+ }
96
+ return out
58
97
  }
@@ -6,6 +6,9 @@ import { ForbiddenError, ValidationError } from '@meith/core'
6
6
  import { msg } from '@meith/i18n'
7
7
  import { type PollEditOption, PollService } from '@meith/polls'
8
8
 
9
+ import { type PollVoteView, pollVoteView } from '@/view/poll-vote'
10
+ import { isEnhancedSubmit } from '@/view/progressive-enhancement'
11
+
9
12
  import { getContainer } from './container'
10
13
  import { getActor } from './context'
11
14
  import { emitEvent, viewerRef } from './plugin-view'
@@ -50,7 +53,10 @@ function editedOptions(form: FormData): readonly PollEditOption[] {
50
53
  })
51
54
  }
52
55
 
53
- export async function votePollAction(form: FormData): Promise<void> {
56
+ export async function votePollAction(
57
+ _prev: PollVoteView | null,
58
+ form: FormData,
59
+ ): Promise<PollVoteView | null> {
54
60
  const threadId = id(form, 'threadId')
55
61
  const pollId = id(form, 'pollId')
56
62
  const actor = await getActor()
@@ -72,6 +78,12 @@ export async function votePollAction(form: FormData): Promise<void> {
72
78
  for (const optionId of chosen) {
73
79
  await emitEvent('poll.voted', { pollId, optionId }, viewerRef(actor))
74
80
  }
81
+
82
+ if (isEnhancedSubmit(form)) {
83
+ const fresh = await polls.find(threadId, actor.userId)
84
+ return fresh === null ? null : pollVoteView(fresh, scope.mayVote, new Date())
85
+ }
86
+
75
87
  redirect(`/thread/${threadId}`)
76
88
  }
77
89
 
@@ -134,6 +134,29 @@ export async function onlineScopeFor(actor: Actor): Promise<OnlineScope> {
134
134
  }
135
135
  }
136
136
 
137
+ export interface AuthorPresence {
138
+ readonly lastActiveAt: Date | null
139
+ readonly invisible: boolean
140
+ }
141
+
142
+ export async function presenceFor(
143
+ userIds: readonly number[],
144
+ ): Promise<ReadonlyMap<number, AuthorPresence>> {
145
+ const repo = presenceRepository()
146
+ if (repo === null || userIds.length === 0) return new Map()
147
+
148
+ try {
149
+ const rows = await repo.lastActiveFor(userIds)
150
+ const out = new Map<number, AuthorPresence>()
151
+ for (const row of rows) {
152
+ out.set(row.userId, { lastActiveAt: row.lastActiveAt, invisible: row.invisible })
153
+ }
154
+ return out
155
+ } catch {
156
+ return new Map()
157
+ }
158
+ }
159
+
137
160
  export async function readOnline(actor: Actor, now: Date): Promise<OnlineSnapshot | null> {
138
161
  const repo = presenceRepository()
139
162
  if (repo === null) return null
@@ -4,7 +4,12 @@ import 'server-only'
4
4
  import type { Actor } from '@meith/authorization'
5
5
  import { ForbiddenError, ValidationError } from '@meith/core'
6
6
  import { restrictsPosting } from '@meith/moderation'
7
- import { type AuthorRestriction, ReplyComposer, type ReplyTarget } from '@meith/threads'
7
+ import {
8
+ type AuthorRestriction,
9
+ ReplyComposer,
10
+ type ReplyTarget,
11
+ type SubscriptionCadence,
12
+ } from '@meith/threads'
8
13
 
9
14
  import {
10
15
  dailyLimitMessage,
@@ -65,6 +70,7 @@ export async function resolveReplyTarget(
65
70
  export interface SubmitReplyInput {
66
71
  readonly message: string
67
72
  readonly subscribe?: boolean
73
+ readonly subscribeMode?: SubscriptionCadence
68
74
  readonly seenLastPostId?: number | null
69
75
  }
70
76
 
@@ -125,6 +131,7 @@ export async function submitReply(
125
131
  {
126
132
  message: draft.body,
127
133
  subscribe: input.subscribe ?? false,
134
+ ...(input.subscribeMode === undefined ? {} : { subscribeMode: input.subscribeMode }),
128
135
  seenLastPostId: input.seenLastPostId ?? null,
129
136
  bypassesModeration: authorizer.can(actor, 'content.viewUnapproved', scope),
130
137
  bypassesFlood: authorizer.can(actor, 'flood.bypass'),
@@ -5,7 +5,7 @@ import { redirect } from 'next/navigation'
5
5
  import { ForbiddenError, ValidationError } from '@meith/core'
6
6
  import { currentRequestId } from '@meith/core/logger'
7
7
  import { msg } from '@meith/i18n'
8
- import { parseTargetKind, ReportService } from '@meith/moderation'
8
+ import { parseReportCategory, parseTargetKind, ReportService } from '@meith/moderation'
9
9
 
10
10
  import { limitMessage, spendLimit } from './antispam'
11
11
  import type { FormState } from './auth-form-state'
@@ -17,6 +17,7 @@ import { tr } from './i18n'
17
17
  import { reportNotifier } from './notifications'
18
18
  import { emitEvent } from './plugin-view'
19
19
  import { resolveReportScope } from './report-scope'
20
+ import { getSettings } from './settings'
20
21
 
21
22
  function field(form: FormData, name: string): string {
22
23
  const value = form.get(name)
@@ -29,12 +30,17 @@ export async function fileReportAction(_prev: FormState, form: FormData): Promis
29
30
  const kind = parseTargetKind(field(form, 'kind'))
30
31
  const targetId = positiveIntIn(field(form, 'targetId'))
31
32
  const reason = field(form, 'reason')
32
- const values = { reason }
33
+ const category = parseReportCategory(field(form, 'category'))
34
+ const values = { reason, ...(category === null ? {} : { category }) }
33
35
 
34
36
  if (kind === null || targetId === null) {
35
37
  return { error: await tr('notice.app.exist'), values }
36
38
  }
37
39
 
40
+ if (category === null) {
41
+ return { error: await tr('notice.app.choose-report-category'), values }
42
+ }
43
+
38
44
  const { authorizer, reports } = getContainer()
39
45
  if (reports === null) {
40
46
  return {
@@ -72,11 +78,16 @@ export async function fileReportAction(_prev: FormState, form: FormData): Promis
72
78
  return { error: limitMessage(limited), values }
73
79
  }
74
80
 
81
+ const settings = await getSettings()
82
+ const flagThreshold = Number(settings.get('moderation.flag_threshold') ?? 0)
83
+
75
84
  const outcome = await new ReportService({ reports }).file({
76
85
  kind,
77
86
  targetId,
87
+ category,
78
88
  reason,
79
89
  reporterUserId: actor.userId,
90
+ flagThreshold,
80
91
  })
81
92
 
82
93
  if (!outcome.duplicate) {
@@ -7,13 +7,19 @@ import { msg } from '@meith/i18n'
7
7
  import { parseRating } from '@meith/reputation'
8
8
 
9
9
  import { postLink } from '@/view/post-link'
10
+ import { isEnhancedSubmit } from '@/view/progressive-enhancement'
10
11
 
11
12
  import type { FormState } from './auth-form-state'
12
13
  import { getActor } from './context'
13
14
  import { formStateReporter } from './form-state-reporter'
14
15
  import { trimmedText } from './form-values'
15
16
  import { emitEvent, viewerRef } from './plugin-view'
16
- import { reputationService, reputationSettings, viewerRaterLimits } from './reputation'
17
+ import {
18
+ reputationService,
19
+ reputationSettings,
20
+ thanksForPosts,
21
+ viewerRaterLimits,
22
+ } from './reputation'
17
23
  import { requireRateablePost } from './reputation-target'
18
24
  import { isSafeLocalPath } from './safe-path'
19
25
 
@@ -138,6 +144,11 @@ export async function thankForPostAction(_prev: FormState, form: FormData): Prom
138
144
  })
139
145
  await announceReputation(service, userId, 1)
140
146
  }
147
+
148
+ if (isEnhancedSubmit(form)) {
149
+ const thanks = (await thanksForPosts([postId])).get(postId)
150
+ return { thanks: thanks ?? { thanked: false, count: 0 } }
151
+ }
141
152
  } catch (err) {
142
153
  return toFormState(err)
143
154
  }
@@ -30,7 +30,13 @@ import { REFINE_FIELDS } from '@/view/search-controls'
30
30
  import { limitMessage, spendLimit } from './antispam'
31
31
  import { getContainer } from './container'
32
32
  import { filterView, viewerRef } from './plugin-view'
33
- import { requireSearch, requireSearchEnabled, searchMinWordLength, searchScopeFor } from './search'
33
+ import {
34
+ requireSearch,
35
+ requireSearchEnabled,
36
+ searchLanguage,
37
+ searchMinWordLength,
38
+ searchScopeFor,
39
+ } from './search'
34
40
  import { getSettings } from './settings'
35
41
 
36
42
  export const SEARCH_PAGE = 20
@@ -149,7 +155,7 @@ export async function openSearch(input: {
149
155
  const refine = trimRefinement(input.refine, filters)
150
156
  const effective = narrowFilters(filters, refine)
151
157
  const scope = await searchScopeFor(input.actor)
152
- const provider = requireSearch()
158
+ const provider = requireSearch(await searchLanguage())
153
159
 
154
160
  const [results, summary] = await Promise.all([
155
161
  provider.search(