@meith/web 0.16.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 (511) hide show
  1. package/LICENSE.md +165 -0
  2. package/app/(auth)/layout.tsx +6 -0
  3. package/app/(auth)/login/page.tsx +106 -0
  4. package/app/(auth)/login/verify/page.tsx +55 -0
  5. package/app/(auth)/register/page.tsx +83 -0
  6. package/app/(auth)/reset/confirm/page.tsx +45 -0
  7. package/app/(auth)/reset/page.tsx +22 -0
  8. package/app/(auth)/verify/page.tsx +44 -0
  9. package/app/(auth)/verify/resend/page.tsx +51 -0
  10. package/app/(board)/[slug]/feed.xml/route.ts +48 -0
  11. package/app/(board)/[slug]/new/page.tsx +112 -0
  12. package/app/(board)/[slug]/page.tsx +403 -0
  13. package/app/(board)/discover/[view]/page.tsx +115 -0
  14. package/app/(board)/discover/page.tsx +5 -0
  15. package/app/(board)/layout.tsx +22 -0
  16. package/app/(board)/member/[id]/page.tsx +187 -0
  17. package/app/(board)/member/[id]/reputation/page.tsx +195 -0
  18. package/app/(board)/member/by-name/[name]/page.tsx +32 -0
  19. package/app/(board)/messages/[id]/page.tsx +103 -0
  20. package/app/(board)/messages/compose/page.tsx +60 -0
  21. package/app/(board)/messages/layout.tsx +5 -0
  22. package/app/(board)/messages/page.tsx +179 -0
  23. package/app/(board)/modcp/forums/page.tsx +59 -0
  24. package/app/(board)/modcp/ip/page.tsx +158 -0
  25. package/app/(board)/modcp/layout.tsx +5 -0
  26. package/app/(board)/modcp/log/page.tsx +98 -0
  27. package/app/(board)/modcp/page.tsx +112 -0
  28. package/app/(board)/moderation/layout.tsx +5 -0
  29. package/app/(board)/moderation/page.tsx +111 -0
  30. package/app/(board)/moderation/reports/page.tsx +193 -0
  31. package/app/(board)/moderation/warn/page.tsx +158 -0
  32. package/app/(board)/notifications/layout.tsx +5 -0
  33. package/app/(board)/notifications/page.tsx +144 -0
  34. package/app/(board)/notifications/preferences/page.tsx +65 -0
  35. package/app/(board)/online/page.tsx +119 -0
  36. package/app/(board)/page.tsx +195 -0
  37. package/app/(board)/plugins/[key]/[[...path]]/page.tsx +72 -0
  38. package/app/(board)/privacy/page.tsx +12 -0
  39. package/app/(board)/report/page.tsx +71 -0
  40. package/app/(board)/rules/page.tsx +12 -0
  41. package/app/(board)/search/[token]/page.tsx +130 -0
  42. package/app/(board)/search/page.tsx +289 -0
  43. package/app/(board)/stats/page.tsx +202 -0
  44. package/app/(board)/subscriptions/layout.tsx +5 -0
  45. package/app/(board)/subscriptions/page.tsx +142 -0
  46. package/app/(board)/terms/page.tsx +12 -0
  47. package/app/(board)/thread/[slug]/edit/page.tsx +107 -0
  48. package/app/(board)/thread/[slug]/feed.xml/route.ts +42 -0
  49. package/app/(board)/thread/[slug]/page.tsx +707 -0
  50. package/app/(board)/thread/[slug]/poll/page.tsx +41 -0
  51. package/app/(board)/thread/[slug]/post/[postId]/history/page.tsx +160 -0
  52. package/app/(board)/thread/[slug]/reply/page.tsx +136 -0
  53. package/app/(board)/unsubscribe/page.tsx +73 -0
  54. package/app/(board)/usercp/avatar/page.tsx +56 -0
  55. package/app/(board)/usercp/contacts/page.tsx +141 -0
  56. package/app/(board)/usercp/email/confirm/page.tsx +52 -0
  57. package/app/(board)/usercp/layout.tsx +23 -0
  58. package/app/(board)/usercp/options/page.tsx +56 -0
  59. package/app/(board)/usercp/page.tsx +71 -0
  60. package/app/(board)/usercp/profile/page.tsx +81 -0
  61. package/app/(board)/usercp/security/page.tsx +238 -0
  62. package/app/(board)/usercp/security/verify/page.tsx +92 -0
  63. package/app/(board)/usercp/signature/page.tsx +67 -0
  64. package/app/admin/antispam/page.tsx +155 -0
  65. package/app/admin/api/marketplace/screenshot/route.ts +68 -0
  66. package/app/admin/api/plugins/[key]/[...path]/route.ts +27 -0
  67. package/app/admin/api-tokens/page.tsx +129 -0
  68. package/app/admin/content/announcements/page.tsx +110 -0
  69. package/app/admin/content/attachments/page.tsx +199 -0
  70. package/app/admin/content/navigation/page.tsx +123 -0
  71. package/app/admin/content/page.tsx +170 -0
  72. package/app/admin/forums/[id]/page.tsx +96 -0
  73. package/app/admin/forums/[id]/permissions/page.tsx +108 -0
  74. package/app/admin/forums/page.tsx +45 -0
  75. package/app/admin/groups/[id]/page.tsx +133 -0
  76. package/app/admin/groups/memberships/page.tsx +56 -0
  77. package/app/admin/groups/page.tsx +104 -0
  78. package/app/admin/groups/promotions/page.tsx +143 -0
  79. package/app/admin/layout.tsx +86 -0
  80. package/app/admin/log/page.tsx +116 -0
  81. package/app/admin/page.tsx +206 -0
  82. package/app/admin/plugins/[key]/[[...path]]/page.tsx +276 -0
  83. package/app/admin/plugins/browse/page.tsx +70 -0
  84. package/app/admin/plugins/page.tsx +165 -0
  85. package/app/admin/security/page.tsx +124 -0
  86. package/app/admin/settings/page.tsx +190 -0
  87. package/app/admin/system/page.tsx +303 -0
  88. package/app/admin/themes/[key]/page.tsx +110 -0
  89. package/app/admin/themes/browse/page.tsx +70 -0
  90. package/app/admin/themes/page.tsx +163 -0
  91. package/app/admin/users/[id]/merge/page.tsx +136 -0
  92. package/app/admin/users/[id]/page.tsx +208 -0
  93. package/app/admin/users/mail/page.tsx +68 -0
  94. package/app/admin/users/page.tsx +260 -0
  95. package/app/admin/users/prune/page.tsx +186 -0
  96. package/app/api/health/route.ts +11 -0
  97. package/app/api/metrics/route.ts +66 -0
  98. package/app/api/plugins/[key]/[...path]/route.ts +27 -0
  99. package/app/api/read/all/route.ts +15 -0
  100. package/app/api/read/forum/[id]/route.ts +29 -0
  101. package/app/api/read/thread/[id]/route.ts +40 -0
  102. package/app/api/ready/route.ts +63 -0
  103. package/app/api/system/tick/route.ts +77 -0
  104. package/app/api/v1/[...path]/route.ts +208 -0
  105. package/app/api/v1/openapi.json/route.ts +12 -0
  106. package/app/apple-icon.ts +11 -0
  107. package/app/atom.xml/route.ts +7 -0
  108. package/app/attachment/[id]/route.ts +10 -0
  109. package/app/attachment/[id]/thumb/route.ts +10 -0
  110. package/app/auth/passkey/options/route.ts +145 -0
  111. package/app/auth/passkey/verify/route.ts +241 -0
  112. package/app/auth/resume/route.ts +55 -0
  113. package/app/auth/sso/[provider]/callback/route.ts +173 -0
  114. package/app/auth/sso/[provider]/go/route.ts +57 -0
  115. package/app/auth/sso/[provider]/route.ts +105 -0
  116. package/app/avatar/[id]/route.ts +31 -0
  117. package/app/brand/icon-192.png/route.ts +7 -0
  118. package/app/brand/icon-512.png/route.ts +7 -0
  119. package/app/brand/icon-maskable.png/route.ts +7 -0
  120. package/app/demo/stripe/[[...path]]/route.ts +27 -0
  121. package/app/error.tsx +15 -0
  122. package/app/feed.xml/route.ts +7 -0
  123. package/app/forumdisplay.php/page.tsx +11 -0
  124. package/app/group/[id]/badge/[scheme]/route.ts +26 -0
  125. package/app/icon.ts +13 -0
  126. package/app/install/page.tsx +221 -0
  127. package/app/jump/page.tsx +28 -0
  128. package/app/layout.tsx +124 -0
  129. package/app/logo/[scheme]/route.ts +24 -0
  130. package/app/manifest.ts +43 -0
  131. package/app/member.php/page.tsx +11 -0
  132. package/app/memberlist.php/page.tsx +11 -0
  133. package/app/not-found.tsx +14 -0
  134. package/app/opengraph-image.ts +11 -0
  135. package/app/redirect/page.tsx +44 -0
  136. package/app/robots.txt/route.ts +35 -0
  137. package/app/showthread.php/page.tsx +11 -0
  138. package/app/sitemap/[page]/route.ts +64 -0
  139. package/app/sitemap.xml/route.ts +31 -0
  140. package/app/twitter-image.ts +11 -0
  141. package/app/viewforum.php/page.tsx +11 -0
  142. package/app/viewtopic.php/page.tsx +11 -0
  143. package/bin/forum-web.mjs +241 -0
  144. package/components.json +21 -0
  145. package/instrumentation.ts +18 -0
  146. package/next.config.mjs +182 -0
  147. package/package.json +99 -0
  148. package/postcss.config.mjs +7 -0
  149. package/proxy.ts +160 -0
  150. package/src/components/account/active-sessions.tsx +87 -0
  151. package/src/components/account/avatar-form.tsx +119 -0
  152. package/src/components/account/credential-proof-form.tsx +76 -0
  153. package/src/components/account/logout-form.tsx +14 -0
  154. package/src/components/account/notification-forms.tsx +157 -0
  155. package/src/components/account/passkey-enrol.tsx +81 -0
  156. package/src/components/account/passkey-proof-button.tsx +47 -0
  157. package/src/components/account/push-device-form.tsx +193 -0
  158. package/src/components/account/relation-forms.tsx +69 -0
  159. package/src/components/account/reputation-forms.tsx +124 -0
  160. package/src/components/account/security-activity.tsx +46 -0
  161. package/src/components/account/sign-in-methods.tsx +166 -0
  162. package/src/components/account/subscription-forms.tsx +151 -0
  163. package/src/components/account/two-factor-forms.tsx +226 -0
  164. package/src/components/account/usercp-forms.tsx +387 -0
  165. package/src/components/account/usercp-nav.tsx +14 -0
  166. package/src/components/account/usercp-shell.tsx +34 -0
  167. package/src/components/admin/admin-forms.tsx +92 -0
  168. package/src/components/admin/admin-nav.tsx +52 -0
  169. package/src/components/admin/admin-undo.tsx +32 -0
  170. package/src/components/admin/api-token-forms.tsx +89 -0
  171. package/src/components/admin/badge-forms.tsx +97 -0
  172. package/src/components/admin/branding-forms.tsx +102 -0
  173. package/src/components/admin/content-forms.tsx +712 -0
  174. package/src/components/admin/form-bits.tsx +11 -0
  175. package/src/components/admin/forum-forms.tsx +524 -0
  176. package/src/components/admin/forum-tree.tsx +454 -0
  177. package/src/components/admin/group-forms.tsx +646 -0
  178. package/src/components/admin/mail-test-card.tsx +90 -0
  179. package/src/components/admin/marketplace-forms.tsx +30 -0
  180. package/src/components/admin/marketplace-listing.tsx +123 -0
  181. package/src/components/admin/navigation-forms.tsx +229 -0
  182. package/src/components/admin/navigation-tree.tsx +442 -0
  183. package/src/components/admin/oklch-picker.tsx +180 -0
  184. package/src/components/admin/plugin-forms.tsx +225 -0
  185. package/src/components/admin/settings-form.tsx +148 -0
  186. package/src/components/admin/system-forms.tsx +169 -0
  187. package/src/components/admin/theme-changes.tsx +354 -0
  188. package/src/components/admin/theme-forms.tsx +636 -0
  189. package/src/components/admin/theme-palette.tsx +119 -0
  190. package/src/components/admin/theme-preview.tsx +461 -0
  191. package/src/components/admin/user-bulk-toolbar.tsx +99 -0
  192. package/src/components/admin/user-forms.tsx +488 -0
  193. package/src/components/auth/auth-page.tsx +45 -0
  194. package/src/components/auth/form-controls.tsx +117 -0
  195. package/src/components/auth/login-form.tsx +47 -0
  196. package/src/components/auth/passkey-client.ts +171 -0
  197. package/src/components/auth/passkey-second-factor.tsx +69 -0
  198. package/src/components/auth/passkey-sign-in.tsx +61 -0
  199. package/src/components/auth/register-form.tsx +136 -0
  200. package/src/components/auth/resend-verification-form.tsx +34 -0
  201. package/src/components/auth/reset-confirm-form.tsx +43 -0
  202. package/src/components/auth/reset-request-form.tsx +35 -0
  203. package/src/components/auth/second-factor-form.tsx +51 -0
  204. package/src/components/auth/sso-buttons.tsx +47 -0
  205. package/src/components/board/live-region.tsx +74 -0
  206. package/src/components/board/search-off-notice.tsx +18 -0
  207. package/src/components/board/section-page.tsx +114 -0
  208. package/src/components/content/attachment-field.tsx +51 -0
  209. package/src/components/content/composer-field.ts +31 -0
  210. package/src/components/content/composer-ids.ts +6 -0
  211. package/src/components/content/composer-intents.tsx +27 -0
  212. package/src/components/content/composer-recovery.tsx +215 -0
  213. package/src/components/content/edit-post-form.tsx +121 -0
  214. package/src/components/content/markdown-editor.tsx +590 -0
  215. package/src/components/content/markdown-syntax.ts +53 -0
  216. package/src/components/content/mention-token.ts +23 -0
  217. package/src/components/content/multiquote-button.tsx +32 -0
  218. package/src/components/content/multiquote-selection.tsx +105 -0
  219. package/src/components/content/multiquote.ts +125 -0
  220. package/src/components/content/new-thread-form.tsx +169 -0
  221. package/src/components/content/poll-edit-form.tsx +96 -0
  222. package/src/components/content/poll.tsx +89 -0
  223. package/src/components/content/quote-in-place.tsx +51 -0
  224. package/src/components/content/reply-form.tsx +113 -0
  225. package/src/components/content/thanks-button.tsx +65 -0
  226. package/src/components/content/thread-rating.tsx +143 -0
  227. package/src/components/install/install-form.tsx +497 -0
  228. package/src/components/legal/legal-document.tsx +22 -0
  229. package/src/components/messages/message-forms.tsx +152 -0
  230. package/src/components/moderation/inline-moderation-form.tsx +158 -0
  231. package/src/components/moderation/modcp-nav.tsx +15 -0
  232. package/src/components/moderation/modcp-shell.tsx +41 -0
  233. package/src/components/moderation/queue-form.tsx +96 -0
  234. package/src/components/moderation/report-forms.tsx +120 -0
  235. package/src/components/moderation/signature-lock-form.tsx +83 -0
  236. package/src/components/moderation/thread-surgery-form.tsx +96 -0
  237. package/src/components/moderation/thread-tools-form.tsx +137 -0
  238. package/src/components/moderation/warning-forms.tsx +125 -0
  239. package/src/components/profile/custom-field.tsx +79 -0
  240. package/src/components/shell/appearance-icons.tsx +35 -0
  241. package/src/components/shell/board-notice.tsx +21 -0
  242. package/src/components/shell/copy-record.ts +20 -0
  243. package/src/components/shell/copy.tsx +17 -0
  244. package/src/components/shell/crash-notice.tsx +86 -0
  245. package/src/components/shell/demo-banner.tsx +49 -0
  246. package/src/components/shell/group-name-style.tsx +6 -0
  247. package/src/components/shell/measure.ts +1 -0
  248. package/src/components/shell/notification-menu.tsx +361 -0
  249. package/src/components/shell/offline-notice.tsx +29 -0
  250. package/src/components/shell/onboarding-banner.tsx +39 -0
  251. package/src/components/shell/page-shell.tsx +167 -0
  252. package/src/components/shell/panel-list.tsx +95 -0
  253. package/src/components/shell/panel-nav.tsx +40 -0
  254. package/src/components/shell/panel-overview.tsx +81 -0
  255. package/src/components/shell/panel-page.tsx +96 -0
  256. package/src/components/shell/panel-pagination.tsx +28 -0
  257. package/src/components/shell/panel-shell.tsx +31 -0
  258. package/src/components/shell/theme-runtime-style.tsx +6 -0
  259. package/src/components/shell/theme-switcher.tsx +97 -0
  260. package/src/components/shell/timezone-probe.tsx +45 -0
  261. package/src/components/shell/view-tabs.tsx +60 -0
  262. package/src/config.ts +13 -0
  263. package/src/server/absent-services.ts +29 -0
  264. package/src/server/admin-actions.ts +171 -0
  265. package/src/server/admin-settings-actions.ts +83 -0
  266. package/src/server/admin-undo-actions.ts +70 -0
  267. package/src/server/admin-undo.ts +75 -0
  268. package/src/server/admin.ts +112 -0
  269. package/src/server/announcements.ts +82 -0
  270. package/src/server/antispam.ts +310 -0
  271. package/src/server/api/content.ts +486 -0
  272. package/src/server/api/http.ts +119 -0
  273. package/src/server/api/members.ts +109 -0
  274. package/src/server/api/messages.ts +113 -0
  275. package/src/server/api/registry.ts +30 -0
  276. package/src/server/api/search.ts +98 -0
  277. package/src/server/api/subscriptions.ts +106 -0
  278. package/src/server/api-auth.ts +66 -0
  279. package/src/server/api-token-actions.ts +85 -0
  280. package/src/server/api-tokens-admin.ts +95 -0
  281. package/src/server/appearance-actions.ts +39 -0
  282. package/src/server/attachment-download.ts +43 -0
  283. package/src/server/attachment-embed.ts +63 -0
  284. package/src/server/attachment-upload-actions.ts +92 -0
  285. package/src/server/attachments.ts +257 -0
  286. package/src/server/auth-actions.ts +479 -0
  287. package/src/server/auth-config.ts +61 -0
  288. package/src/server/auth-events.ts +92 -0
  289. package/src/server/auth-form-state.ts +14 -0
  290. package/src/server/auth-mail.ts +104 -0
  291. package/src/server/avatars.ts +83 -0
  292. package/src/server/board-latest-actions.ts +7 -0
  293. package/src/server/board-latest.tsx +103 -0
  294. package/src/server/board-offline.ts +23 -0
  295. package/src/server/board-url.ts +14 -0
  296. package/src/server/brand-assets.ts +151 -0
  297. package/src/server/brand-mark.tsx +110 -0
  298. package/src/server/branding-actions.ts +53 -0
  299. package/src/server/branding.ts +101 -0
  300. package/src/server/cache-targets.ts +13 -0
  301. package/src/server/code-highlighting.ts +33 -0
  302. package/src/server/container.ts +402 -0
  303. package/src/server/content-actions.ts +459 -0
  304. package/src/server/content-admin-actions.ts +492 -0
  305. package/src/server/content-admin.ts +95 -0
  306. package/src/server/content-security-policy.ts +35 -0
  307. package/src/server/context.ts +32 -0
  308. package/src/server/cookies.ts +139 -0
  309. package/src/server/credential-proof-actions.ts +42 -0
  310. package/src/server/credential-proof.ts +35 -0
  311. package/src/server/current-location.ts +19 -0
  312. package/src/server/demo-stripe.ts +104 -0
  313. package/src/server/demo-uploads.ts +28 -0
  314. package/src/server/demo.ts +80 -0
  315. package/src/server/discovery.ts +104 -0
  316. package/src/server/embeds.ts +113 -0
  317. package/src/server/error-notice.tsx +41 -0
  318. package/src/server/federation-actions.ts +113 -0
  319. package/src/server/federation.ts +138 -0
  320. package/src/server/feed-builder.ts +72 -0
  321. package/src/server/feed-routes.ts +122 -0
  322. package/src/server/fixture-actor-source.ts +62 -0
  323. package/src/server/fixture-forum-repo.ts +59 -0
  324. package/src/server/fixture-member-profile-repo.ts +30 -0
  325. package/src/server/fixture-post-repo.ts +108 -0
  326. package/src/server/fixture-thread-repo.ts +98 -0
  327. package/src/server/form-state-reporter.ts +34 -0
  328. package/src/server/form-values.ts +23 -0
  329. package/src/server/forum-admin-actions.ts +353 -0
  330. package/src/server/forum-admin.ts +98 -0
  331. package/src/server/group-admin-actions.ts +341 -0
  332. package/src/server/group-admin.ts +98 -0
  333. package/src/server/group-badge-actions.ts +61 -0
  334. package/src/server/group-badge.ts +62 -0
  335. package/src/server/group-identity.ts +104 -0
  336. package/src/server/i18n-catalogs.ts +42 -0
  337. package/src/server/i18n.ts +69 -0
  338. package/src/server/image-upload.ts +109 -0
  339. package/src/server/inline-moderation-actions.ts +129 -0
  340. package/src/server/install-actions.ts +124 -0
  341. package/src/server/install.ts +281 -0
  342. package/src/server/legacy-redirect.ts +83 -0
  343. package/src/server/legal.ts +43 -0
  344. package/src/server/location-header.ts +37 -0
  345. package/src/server/mail-brand.ts +43 -0
  346. package/src/server/mail-health.ts +41 -0
  347. package/src/server/mail-send.ts +44 -0
  348. package/src/server/mail-test-actions.ts +67 -0
  349. package/src/server/mail-test.ts +56 -0
  350. package/src/server/markdown-pipeline.ts +31 -0
  351. package/src/server/marketplace-actions.ts +47 -0
  352. package/src/server/marketplace-admin.ts +260 -0
  353. package/src/server/mention-actions.ts +21 -0
  354. package/src/server/mention-search.ts +35 -0
  355. package/src/server/message-actions.ts +110 -0
  356. package/src/server/messages.ts +141 -0
  357. package/src/server/modcp.ts +125 -0
  358. package/src/server/moderation-actions.ts +165 -0
  359. package/src/server/navigation-admin-actions.ts +217 -0
  360. package/src/server/navigation.ts +112 -0
  361. package/src/server/nonce.ts +13 -0
  362. package/src/server/notification-actions.ts +84 -0
  363. package/src/server/notification-audience.ts +10 -0
  364. package/src/server/notification-menu-actions.ts +82 -0
  365. package/src/server/notification-menu.ts +96 -0
  366. package/src/server/notifications.ts +93 -0
  367. package/src/server/onboarding-actions.ts +27 -0
  368. package/src/server/onboarding.ts +89 -0
  369. package/src/server/page-metadata.ts +14 -0
  370. package/src/server/passkey-challenge.ts +42 -0
  371. package/src/server/plugin-admin-actions.ts +232 -0
  372. package/src/server/plugin-admin.ts +276 -0
  373. package/src/server/plugin-host.ts +143 -0
  374. package/src/server/plugin-pages.ts +192 -0
  375. package/src/server/plugin-panel.ts +49 -0
  376. package/src/server/plugin-routes.ts +298 -0
  377. package/src/server/plugin-view.tsx +58 -0
  378. package/src/server/poll-actions.ts +104 -0
  379. package/src/server/poll-scope.ts +52 -0
  380. package/src/server/post-notifications.ts +71 -0
  381. package/src/server/post-scope.ts +86 -0
  382. package/src/server/presence.ts +177 -0
  383. package/src/server/profile-fields.ts +112 -0
  384. package/src/server/push-actions.ts +113 -0
  385. package/src/server/push.ts +40 -0
  386. package/src/server/redirect-back.ts +15 -0
  387. package/src/server/registration.ts +7 -0
  388. package/src/server/relation-actions.ts +89 -0
  389. package/src/server/relations.ts +45 -0
  390. package/src/server/reply-core.ts +173 -0
  391. package/src/server/report-actions.ts +165 -0
  392. package/src/server/report-scope.ts +21 -0
  393. package/src/server/reputation-actions.ts +146 -0
  394. package/src/server/reputation-target.ts +49 -0
  395. package/src/server/reputation.ts +70 -0
  396. package/src/server/request-fingerprint.ts +65 -0
  397. package/src/server/safe-path.ts +16 -0
  398. package/src/server/same-origin.ts +45 -0
  399. package/src/server/search-page.ts +313 -0
  400. package/src/server/search.ts +45 -0
  401. package/src/server/secret-auth.ts +20 -0
  402. package/src/server/see-other.ts +11 -0
  403. package/src/server/seed-board.ts +417 -0
  404. package/src/server/session-actions.ts +78 -0
  405. package/src/server/session-cookies.ts +124 -0
  406. package/src/server/session-key.ts +17 -0
  407. package/src/server/settings.ts +47 -0
  408. package/src/server/signatures.ts +62 -0
  409. package/src/server/sso-handshake.ts +79 -0
  410. package/src/server/stats.ts +63 -0
  411. package/src/server/subscription-actions.ts +161 -0
  412. package/src/server/surgery-actions.ts +180 -0
  413. package/src/server/syndication.ts +77 -0
  414. package/src/server/system-admin-actions.ts +134 -0
  415. package/src/server/system-admin.ts +90 -0
  416. package/src/server/test-container.ts +114 -0
  417. package/src/server/theme-admin-actions.ts +242 -0
  418. package/src/server/theme-admin.ts +203 -0
  419. package/src/server/theme-runtime.ts +89 -0
  420. package/src/server/theme-style.ts +279 -0
  421. package/src/server/theme.ts +52 -0
  422. package/src/server/thread-core.ts +207 -0
  423. package/src/server/thread-rating-actions.ts +55 -0
  424. package/src/server/thread-tool-actions.ts +154 -0
  425. package/src/server/two-factor-actions.ts +178 -0
  426. package/src/server/two-factor.ts +86 -0
  427. package/src/server/upgrade-notice.ts +41 -0
  428. package/src/server/user-admin-actions.ts +572 -0
  429. package/src/server/user-admin.ts +165 -0
  430. package/src/server/usercp-actions.ts +263 -0
  431. package/src/server/usercp-mail.ts +120 -0
  432. package/src/server/viewer-preferences.ts +64 -0
  433. package/src/server/warning-actions.ts +122 -0
  434. package/src/styles/globals.css +969 -0
  435. package/src/theme/contract.fixture.ts +793 -0
  436. package/src/view/account-copy.ts +351 -0
  437. package/src/view/admin-content-copy.ts +66 -0
  438. package/src/view/admin-copy.ts +106 -0
  439. package/src/view/admin-forum-tree-copy.ts +40 -0
  440. package/src/view/admin-group-copy.ts +80 -0
  441. package/src/view/admin-log.ts +58 -0
  442. package/src/view/admin-nav.ts +164 -0
  443. package/src/view/admin-navigation-copy.ts +58 -0
  444. package/src/view/admin-panel-copy.ts +194 -0
  445. package/src/view/admin-settings.ts +173 -0
  446. package/src/view/admin-theme-copy.ts +229 -0
  447. package/src/view/admin-user-copy.ts +96 -0
  448. package/src/view/attachments.ts +51 -0
  449. package/src/view/auth-copy.ts +147 -0
  450. package/src/view/board-index.ts +167 -0
  451. package/src/view/board-latest.ts +101 -0
  452. package/src/view/breadcrumb.ts +50 -0
  453. package/src/view/contacts.ts +85 -0
  454. package/src/view/content-copy.ts +118 -0
  455. package/src/view/contrast.ts +286 -0
  456. package/src/view/copy.ts +101 -0
  457. package/src/view/count.ts +8 -0
  458. package/src/view/discovery-view.ts +74 -0
  459. package/src/view/editor-toolbar.ts +84 -0
  460. package/src/view/error-notice.ts +52 -0
  461. package/src/view/feed.ts +130 -0
  462. package/src/view/forum-cursor.ts +54 -0
  463. package/src/view/forum-display.ts +152 -0
  464. package/src/view/forum-jump.ts +60 -0
  465. package/src/view/forum-notice.ts +24 -0
  466. package/src/view/inline-moderation.ts +87 -0
  467. package/src/view/install-copy.ts +95 -0
  468. package/src/view/legal.ts +58 -0
  469. package/src/view/locale.ts +40 -0
  470. package/src/view/marketplace-panel.ts +51 -0
  471. package/src/view/member-identity.ts +23 -0
  472. package/src/view/member-profile.ts +65 -0
  473. package/src/view/messages.ts +222 -0
  474. package/src/view/metadata.ts +75 -0
  475. package/src/view/modcp-nav.ts +76 -0
  476. package/src/view/moderation-copy.ts +84 -0
  477. package/src/view/moderation-queue.ts +67 -0
  478. package/src/view/navigation-arrange.ts +188 -0
  479. package/src/view/navigation.ts +173 -0
  480. package/src/view/notification-menu.ts +202 -0
  481. package/src/view/notifications.ts +84 -0
  482. package/src/view/onboarding.ts +26 -0
  483. package/src/view/pager.ts +191 -0
  484. package/src/view/panel-nav.ts +203 -0
  485. package/src/view/plugin-panel.ts +42 -0
  486. package/src/view/post-form.ts +61 -0
  487. package/src/view/post-link.ts +29 -0
  488. package/src/view/presence.ts +99 -0
  489. package/src/view/promotion-rules.ts +52 -0
  490. package/src/view/redirect-notice.ts +21 -0
  491. package/src/view/reputation.ts +107 -0
  492. package/src/view/search-controls.ts +103 -0
  493. package/src/view/search-results.ts +291 -0
  494. package/src/view/security-activity.ts +73 -0
  495. package/src/view/setting-groups.ts +43 -0
  496. package/src/view/shell.ts +164 -0
  497. package/src/view/slug-id.ts +12 -0
  498. package/src/view/sso-hand-off.ts +44 -0
  499. package/src/view/sso-notices.ts +48 -0
  500. package/src/view/subscriptions.ts +76 -0
  501. package/src/view/theme-draft.ts +167 -0
  502. package/src/view/theme-preference.ts +30 -0
  503. package/src/view/theme-tokens.ts +333 -0
  504. package/src/view/thread-view.ts +280 -0
  505. package/src/view/time.ts +31 -0
  506. package/src/view/timezone.ts +35 -0
  507. package/src/view/two-factor.ts +6 -0
  508. package/src/view/usercp-nav.ts +103 -0
  509. package/src/view/usercp.ts +196 -0
  510. package/src/view/warnings.ts +128 -0
  511. package/src/view/word-filter.ts +5 -0
@@ -0,0 +1,98 @@
1
+ import { msg } from '@meith/i18n'
2
+ import 'server-only'
3
+
4
+ import {
5
+ buildPermissionMatrix,
6
+ type CopyPlan,
7
+ type ForumOverride,
8
+ type MatrixRow,
9
+ planCopyToDescendants,
10
+ } from '@meith/authorization'
11
+ import { ForbiddenError, type PermissionSet } from '@meith/core'
12
+ import { getDb, PostgresForumAdminRepository } from '@meith/db'
13
+ import type { ForumRow } from '@meith/forums'
14
+
15
+ import { getContainer } from './container'
16
+
17
+ export function forumAdminRepository(): PostgresForumAdminRepository | null {
18
+ return getContainer().dataSource === 'postgres' ? new PostgresForumAdminRepository(getDb()) : null
19
+ }
20
+
21
+ export function requireForumAdmin(): PostgresForumAdminRepository {
22
+ const repository = forumAdminRepository()
23
+ if (repository === null) {
24
+ throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-12'))
25
+ }
26
+ return repository
27
+ }
28
+
29
+ export function ancestorChain(forum: ForumRow): readonly number[] {
30
+ return forum.path
31
+ .split('.')
32
+ .map((segment) => Number(segment))
33
+ .filter((id) => Number.isSafeInteger(id) && id > 0)
34
+ .reverse()
35
+ }
36
+
37
+ export interface ForumMatrixView {
38
+ readonly forum: ForumRow
39
+ readonly rows: readonly MatrixRow[]
40
+ readonly chain: readonly number[]
41
+ readonly overrides: readonly ForumOverride[]
42
+ readonly groups: readonly { readonly groupId: number; readonly title: string }[]
43
+ readonly descendants: readonly ForumRow[]
44
+ }
45
+
46
+ interface GroupRow {
47
+ readonly groupId: number
48
+ readonly title: string
49
+ readonly permissions: PermissionSet
50
+ }
51
+
52
+ async function allGroups(repository: PostgresForumAdminRepository): Promise<readonly GroupRow[]> {
53
+ const groups = await repository.listGroups()
54
+ const defaults = await getContainer().authorizationSource.groupDefaults(
55
+ groups.map((group) => group.id),
56
+ )
57
+ const byId = new Map(defaults.map((entry) => [entry.groupId, entry.permissions]))
58
+
59
+ return groups.map((group) => ({
60
+ groupId: group.id,
61
+ title: group.title,
62
+ permissions: byId.get(group.id) as PermissionSet,
63
+ }))
64
+ }
65
+
66
+ export async function buildForumMatrixView(forumId: number): Promise<ForumMatrixView | null> {
67
+ const repository = forumAdminRepository()
68
+ const { forums } = getContainer()
69
+ if (repository === null) return null
70
+
71
+ const all = await forums.listAll()
72
+ const forum = all.find((row) => row.id === forumId)
73
+ if (forum === undefined) return null
74
+
75
+ const chain = ancestorChain(forum)
76
+ const groups = await allGroups(repository)
77
+ const descendants = all.filter((row) => row.path.startsWith(`${forum.path}.`))
78
+
79
+ const overrides = await repository.readOverrides([...chain, ...descendants.map((row) => row.id)])
80
+
81
+ return {
82
+ forum,
83
+ chain,
84
+ overrides,
85
+ descendants,
86
+ groups: groups.map(({ groupId, title }) => ({ groupId, title })),
87
+ rows: buildPermissionMatrix({ chain, groups, overrides }),
88
+ }
89
+ }
90
+
91
+ export function previewCopy(view: ForumMatrixView): CopyPlan {
92
+ return planCopyToDescendants({
93
+ sourceForumId: view.forum.id,
94
+ descendantIds: view.descendants.map((row) => row.id),
95
+ groupIds: view.groups.map((group) => group.groupId),
96
+ overrides: view.overrides,
97
+ })
98
+ }
@@ -0,0 +1,341 @@
1
+ 'use server'
2
+
3
+ import { revalidatePath } from 'next/cache'
4
+
5
+ import { PERMISSION_FIELDS, ValidationError } from '@meith/core'
6
+ import { permissionsCarryPower } from '@meith/db'
7
+ import type { PromotionRuleInput } from '@meith/groups'
8
+ import { msg } from '@meith/i18n'
9
+
10
+ import { recordAdminAction, requireAdmin, requireFreshAdmin } from './admin'
11
+ import type { FormState } from './auth-form-state'
12
+ import { formStateReporter } from './form-state-reporter'
13
+ import { checkbox, trimmedText } from './form-values'
14
+ import { promotionService, requireGroupAdmin, requirePromotionRules } from './group-admin'
15
+ import { assertSafeCssValue } from './theme-style'
16
+
17
+ const CHUNK = 500
18
+
19
+ function groupId(form: FormData, name = 'groupId'): number {
20
+ const id = Number(trimmedText(form, name))
21
+ if (!Number.isSafeInteger(id) || id <= 0) throw new ValidationError(msg('error.app.such-group'))
22
+ return id
23
+ }
24
+
25
+ const toFormState = formStateReporter('group-admin', 'group administration write failed')
26
+
27
+ function refreshGroupScreens(): void {
28
+ revalidatePath('/admin/groups')
29
+ revalidatePath('/admin/groups/[id]', 'page')
30
+ }
31
+
32
+ function refreshPromotionScreen(): void {
33
+ revalidatePath('/admin/groups/promotions')
34
+ }
35
+
36
+ function ruleId(form: FormData): number {
37
+ const id = Number(trimmedText(form, 'id'))
38
+ if (!Number.isSafeInteger(id) || id <= 0)
39
+ throw new ValidationError(msg('error.app.such-promotion-rule'))
40
+ return id
41
+ }
42
+
43
+ function blankOr(form: FormData, name: string): number | null {
44
+ const raw = trimmedText(form, name)
45
+ return raw === '' ? null : Number(raw)
46
+ }
47
+
48
+ function promotionRuleInput(form: FormData): PromotionRuleInput {
49
+ return {
50
+ title: trimmedText(form, 'title'),
51
+ displayOrder: blankOr(form, 'displayOrder') ?? 0,
52
+ minPostCount: blankOr(form, 'minPostCount'),
53
+ minReputation: blankOr(form, 'minReputation'),
54
+ minDaysRegistered: blankOr(form, 'minDaysRegistered'),
55
+ fromPrimaryGroupId: blankOr(form, 'fromPrimaryGroupId'),
56
+ toPrimaryGroupId: Number(trimmedText(form, 'toPrimaryGroupId')),
57
+ }
58
+ }
59
+
60
+ function groupColour(form: FormData, field: string): string | null {
61
+ const value = trimmedText(form, field)
62
+ if (value === '') return null
63
+ assertSafeCssValue(`group colour "${field}"`, value)
64
+ return value
65
+ }
66
+
67
+ export async function saveGroupIdentityAction(
68
+ _prev: FormState,
69
+ form: FormData,
70
+ ): Promise<FormState> {
71
+ try {
72
+ await requireAdmin()
73
+ const id = groupId(form)
74
+
75
+ const title = trimmedText(form, 'title')
76
+ if (title === '') throw new ValidationError(msg('error.app.group-needs-title'))
77
+
78
+ const order = Number(trimmedText(form, 'displayOrder'))
79
+ if (!Number.isSafeInteger(order) || order < 0) {
80
+ throw new ValidationError(msg('error.app.display-order-must-whole-number'))
81
+ }
82
+
83
+ const isStaffGroup = checkbox(form, 'isStaffGroup')
84
+ const pluginGrantable = checkbox(form, 'pluginGrantable')
85
+
86
+ if (pluginGrantable) {
87
+ if (isStaffGroup) {
88
+ throw new ValidationError(msg('error.app.staff-group-granted-by-plugins'))
89
+ }
90
+ const repository = requireGroupAdmin()
91
+ const summary = (await repository.list()).find((group) => group.id === id)
92
+ if (summary?.isSystem === true) {
93
+ throw new ValidationError(msg('error.app.system-group-granted-by-plugins'))
94
+ }
95
+ const permissions = await repository.readPermissions(id)
96
+ if (permissions !== null && permissionsCarryPower(permissions)) {
97
+ throw new ValidationError(msg('error.app.group-carries-administrative-moderation-power'))
98
+ }
99
+ }
100
+
101
+ await requireGroupAdmin().updateIdentity(id, {
102
+ title,
103
+ description: trimmedText(form, 'description') || null,
104
+ displayOrder: order,
105
+ isStaffGroup,
106
+ pluginGrantable,
107
+ badgeToken: trimmedText(form, 'badgeToken') === '' ? null : trimmedText(form, 'badgeToken'),
108
+ nameColorLight: groupColour(form, 'nameColorLight'),
109
+ nameColorDark: groupColour(form, 'nameColorDark'),
110
+ })
111
+
112
+ refreshGroupScreens()
113
+ await recordAdminAction({ action: 'group.updated', detail: { groupId: id } })
114
+
115
+ return { notice: 'saved' }
116
+ } catch (err) {
117
+ return toFormState(err)
118
+ }
119
+ }
120
+
121
+ export async function saveGroupPermissionsAction(
122
+ _prev: FormState,
123
+ form: FormData,
124
+ ): Promise<FormState> {
125
+ try {
126
+ await requireAdmin()
127
+ const id = groupId(form)
128
+
129
+ const values: Record<string, boolean | number> = {}
130
+ for (const field of PERMISSION_FIELDS) {
131
+ if (field.kind === 'numeric') {
132
+ const raw = trimmedText(form, field.key)
133
+ const parsed = Number(raw)
134
+ if (raw !== '' && (!Number.isSafeInteger(parsed) || parsed < 0)) {
135
+ throw new ValidationError(`“${field.key}” must be a whole number, or blank for none.`)
136
+ }
137
+ values[field.key] = raw === '' ? field.fallback : parsed
138
+ } else {
139
+ values[field.key] = checkbox(form, field.key)
140
+ }
141
+ }
142
+
143
+ await requireGroupAdmin().savePermissions(id, values)
144
+
145
+ refreshGroupScreens()
146
+ await recordAdminAction({ action: 'group.permissions_changed', detail: { groupId: id } })
147
+
148
+ return { notice: 'saved' }
149
+ } catch (err) {
150
+ return toFormState(err)
151
+ }
152
+ }
153
+
154
+ export async function createGroupAction(_prev: FormState, form: FormData): Promise<FormState> {
155
+ try {
156
+ await requireAdmin()
157
+
158
+ const key = trimmedText(form, 'key')
159
+ if (!/^[a-z][a-z0-9_]*$/.test(key)) {
160
+ throw new ValidationError(msg('error.app.key-contain-lower-case-letters-numbers'))
161
+ }
162
+
163
+ const title = trimmedText(form, 'title')
164
+ if (title === '') throw new ValidationError(msg('error.app.group-needs-title'))
165
+
166
+ if (trimmedText(form, 'copyFromGroupId') === '') {
167
+ throw new ValidationError(msg('error.app.choose-group-copy-permissions-from'))
168
+ }
169
+
170
+ const id = await requireGroupAdmin().create({
171
+ key,
172
+ title,
173
+ copyFromGroupId: groupId(form, 'copyFromGroupId'),
174
+ })
175
+
176
+ refreshGroupScreens()
177
+ await recordAdminAction({ action: 'group.created', detail: { groupId: id, key } })
178
+
179
+ return { notice: 'created' }
180
+ } catch (err) {
181
+ return toFormState(err)
182
+ }
183
+ }
184
+
185
+ export async function deleteGroupAction(_prev: FormState, form: FormData): Promise<FormState> {
186
+ try {
187
+ await requireFreshAdmin()
188
+ const id = groupId(form)
189
+ const moveTo = groupId(form, 'moveMembersTo')
190
+
191
+ await requireGroupAdmin().remove(id, moveTo)
192
+
193
+ refreshGroupScreens()
194
+ await recordAdminAction({
195
+ action: 'group.deleted',
196
+ detail: { groupId: id, movedTo: moveTo },
197
+ })
198
+
199
+ return { notice: 'deleted' }
200
+ } catch (err) {
201
+ return toFormState(err)
202
+ }
203
+ }
204
+
205
+ export async function moveMembersAction(_prev: FormState, form: FormData): Promise<FormState> {
206
+ try {
207
+ await requireFreshAdmin()
208
+ const from = groupId(form, 'fromGroupId')
209
+ const to = groupId(form, 'toGroupId')
210
+
211
+ const after = Number(trimmedText(form, 'afterUserId') || '0')
212
+ if (!Number.isSafeInteger(after) || after < 0) {
213
+ throw new ValidationError(msg('error.app.lost-track-where-run-had'))
214
+ }
215
+
216
+ const chunk = await requireGroupAdmin().moveMembersChunk({
217
+ fromGroupId: from,
218
+ toGroupId: to,
219
+ afterUserId: after,
220
+ limit: CHUNK,
221
+ })
222
+
223
+ refreshGroupScreens()
224
+ await recordAdminAction({
225
+ action: 'group.members_moved',
226
+ detail: { fromGroupId: from, toGroupId: to, moved: chunk.moved },
227
+ })
228
+
229
+ const total = Number(trimmedText(form, 'movedSoFar') || '0') + chunk.moved
230
+
231
+ return {
232
+ notice: chunk.nextCursor === null ? 'finished' : 'more',
233
+ values: {
234
+ fromGroupId: String(from),
235
+ toGroupId: String(to),
236
+ afterUserId: String(chunk.nextCursor ?? 0),
237
+ movedSoFar: String(total),
238
+ },
239
+ }
240
+ } catch (err) {
241
+ return toFormState(err)
242
+ }
243
+ }
244
+
245
+ export async function createPromotionRuleAction(
246
+ _prev: FormState,
247
+ form: FormData,
248
+ ): Promise<FormState> {
249
+ try {
250
+ await requireAdmin()
251
+
252
+ const id = await requirePromotionRules().createRule(promotionRuleInput(form))
253
+
254
+ refreshPromotionScreen()
255
+ await recordAdminAction({ action: 'group.promotion_rule_added', detail: { ruleId: id } })
256
+
257
+ return { notice: 'created' }
258
+ } catch (err) {
259
+ return toFormState(err)
260
+ }
261
+ }
262
+
263
+ export async function updatePromotionRuleAction(
264
+ _prev: FormState,
265
+ form: FormData,
266
+ ): Promise<FormState> {
267
+ try {
268
+ await requireAdmin()
269
+ const id = ruleId(form)
270
+
271
+ await requirePromotionRules().updateRule(id, promotionRuleInput(form))
272
+
273
+ refreshPromotionScreen()
274
+ await recordAdminAction({ action: 'group.promotion_rule_changed', detail: { ruleId: id } })
275
+
276
+ return { notice: 'saved' }
277
+ } catch (err) {
278
+ return toFormState(err)
279
+ }
280
+ }
281
+
282
+ export async function setPromotionRuleEnabledAction(
283
+ _prev: FormState,
284
+ form: FormData,
285
+ ): Promise<FormState> {
286
+ try {
287
+ await requireAdmin()
288
+ const id = ruleId(form)
289
+ const enabled = checkbox(form, 'enabled')
290
+
291
+ await requirePromotionRules().setRuleEnabled(id, enabled)
292
+
293
+ refreshPromotionScreen()
294
+ await recordAdminAction({
295
+ action: 'group.promotion_rule_toggled',
296
+ detail: { ruleId: id, enabled },
297
+ })
298
+
299
+ return { notice: enabled ? 'enabled' : 'disabled' }
300
+ } catch (err) {
301
+ return toFormState(err)
302
+ }
303
+ }
304
+
305
+ export async function deletePromotionRuleAction(
306
+ _prev: FormState,
307
+ form: FormData,
308
+ ): Promise<FormState> {
309
+ try {
310
+ await requireFreshAdmin()
311
+ const id = ruleId(form)
312
+
313
+ await requirePromotionRules().deleteRule(id)
314
+
315
+ refreshPromotionScreen()
316
+ await recordAdminAction({ action: 'group.promotion_rule_removed', detail: { ruleId: id } })
317
+
318
+ return { notice: 'deleted' }
319
+ } catch (err) {
320
+ return toFormState(err)
321
+ }
322
+ }
323
+
324
+ export async function applyPromotionsAction(_prev: FormState, _form: FormData): Promise<FormState> {
325
+ try {
326
+ await requireFreshAdmin()
327
+
328
+ const result = await promotionService().apply()
329
+
330
+ refreshGroupScreens()
331
+ refreshPromotionScreen()
332
+ await recordAdminAction({
333
+ action: 'group.promotions_applied',
334
+ detail: { promoted: result.outcomes.length, examined: result.examined },
335
+ })
336
+
337
+ return { notice: `promoted:${result.outcomes.length}` }
338
+ } catch (err) {
339
+ return toFormState(err)
340
+ }
341
+ }
@@ -0,0 +1,98 @@
1
+ import { msg } from '@meith/i18n'
2
+ import 'server-only'
3
+
4
+ import type { PermissionField, PermissionSet } from '@meith/core'
5
+ import { ForbiddenError, PERMISSION_FIELDS } from '@meith/core'
6
+ import {
7
+ type GroupSummaryRow,
8
+ getDb,
9
+ PostgresGroupAdminRepository,
10
+ PostgresPromotionRepository,
11
+ } from '@meith/db'
12
+ import { type PromotionRunResult, PromotionService } from '@meith/groups'
13
+ import { defaultPromotionGuards } from '@meith/runtime'
14
+
15
+ import { getContainer } from './container'
16
+
17
+ export function groupAdminRepository(): PostgresGroupAdminRepository | null {
18
+ return getContainer().dataSource === 'postgres' ? new PostgresGroupAdminRepository(getDb()) : null
19
+ }
20
+
21
+ export function requireGroupAdmin(): PostgresGroupAdminRepository {
22
+ const repository = groupAdminRepository()
23
+ if (repository === null) {
24
+ throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-18'))
25
+ }
26
+ return repository
27
+ }
28
+
29
+ export interface GroupPermissionCell {
30
+ readonly key: string
31
+ readonly description: string
32
+ readonly kind: PermissionField['kind']
33
+ readonly scope: PermissionField['scope']
34
+ readonly value: boolean | number
35
+ }
36
+
37
+ export interface GroupPermissionView {
38
+ readonly group: GroupSummaryRow
39
+ readonly cells: readonly GroupPermissionCell[]
40
+ }
41
+
42
+ export async function buildGroupPermissionView(
43
+ groupId: number,
44
+ ): Promise<GroupPermissionView | null> {
45
+ const repository = groupAdminRepository()
46
+ if (repository === null) return null
47
+
48
+ const group = (await repository.list()).find((row) => row.id === groupId)
49
+ if (group === undefined) return null
50
+
51
+ const permissions = await repository.readPermissions(groupId)
52
+ if (permissions === null) return null
53
+
54
+ return { group, cells: permissionCells(permissions) }
55
+ }
56
+
57
+ function permissionCells(permissions: PermissionSet): readonly GroupPermissionCell[] {
58
+ return PERMISSION_FIELDS.map((field) => ({
59
+ key: field.key,
60
+ description: field.description,
61
+ kind: field.kind,
62
+ scope: field.scope,
63
+ value:
64
+ (permissions as unknown as Record<string, boolean | number>)[field.key] ?? field.fallback,
65
+ }))
66
+ }
67
+
68
+ export function promotionRuleRepository(): PostgresPromotionRepository | null {
69
+ return getContainer().dataSource === 'postgres' ? new PostgresPromotionRepository(getDb()) : null
70
+ }
71
+
72
+ export function requirePromotionRules(): PostgresPromotionRepository {
73
+ const repository = promotionRuleRepository()
74
+ if (repository === null) {
75
+ throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-19'))
76
+ }
77
+ return repository
78
+ }
79
+
80
+ export async function previewPromotions(limit = 500): Promise<PromotionRunResult | null> {
81
+ if (getContainer().dataSource !== 'postgres') return null
82
+
83
+ const service = new PromotionService({
84
+ promotions: new PostgresPromotionRepository(getDb()),
85
+ guards: defaultPromotionGuards(),
86
+ })
87
+ return service.preview(limit)
88
+ }
89
+
90
+ export function promotionService(): PromotionService {
91
+ if (getContainer().dataSource !== 'postgres') {
92
+ throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-20'))
93
+ }
94
+ return new PromotionService({
95
+ promotions: new PostgresPromotionRepository(getDb()),
96
+ guards: defaultPromotionGuards(),
97
+ })
98
+ }
@@ -0,0 +1,61 @@
1
+ 'use server'
2
+
3
+ import { ValidationError } from '@meith/core'
4
+ import { msg } from '@meith/i18n'
5
+
6
+ import { recordAdminAction, requireAdmin } from './admin'
7
+ import type { FormState } from './auth-form-state'
8
+ import { formStateReporter } from './form-state-reporter'
9
+ import { BADGE_FIELD, removeBadge, saveBadge } from './group-badge'
10
+ import { type ImageScheme, isImageScheme } from './image-upload'
11
+
12
+ const toFormState = formStateReporter('group-badge', 'badge write failed')
13
+
14
+ function target(form: FormData): { groupId: number; scheme: ImageScheme } {
15
+ const raw = form.get('groupId')
16
+ const groupId = typeof raw === 'string' ? Number(raw) : Number.NaN
17
+ if (!Number.isSafeInteger(groupId) || groupId <= 0) {
18
+ throw new ValidationError(msg('error.app.such-group'))
19
+ }
20
+
21
+ const scheme = form.get('scheme')
22
+ if (!isImageScheme(scheme)) throw new ValidationError(msg('error.app.such-badge'))
23
+
24
+ return { groupId, scheme }
25
+ }
26
+
27
+ export async function saveBadgeAction(_prev: FormState, form: FormData): Promise<FormState> {
28
+ try {
29
+ await requireAdmin()
30
+ const { groupId, scheme } = target(form)
31
+
32
+ const file = form.get(BADGE_FIELD)
33
+ if (!(file instanceof File) || file.size === 0) {
34
+ throw new ValidationError(msg('error.app.choose-image-first'))
35
+ }
36
+
37
+ await saveBadge(groupId, scheme, file)
38
+ await recordAdminAction({
39
+ action: 'group.badge_saved',
40
+ detail: { groupId, scheme, bytes: file.size },
41
+ })
42
+
43
+ return { notice: 'saved' }
44
+ } catch (err) {
45
+ return toFormState(err)
46
+ }
47
+ }
48
+
49
+ export async function removeBadgeAction(_prev: FormState, form: FormData): Promise<FormState> {
50
+ try {
51
+ await requireAdmin()
52
+ const { groupId, scheme } = target(form)
53
+
54
+ await removeBadge(groupId, scheme)
55
+ await recordAdminAction({ action: 'group.badge_removed', detail: { groupId, scheme } })
56
+
57
+ return { notice: 'removed' }
58
+ } catch (err) {
59
+ return toFormState(err)
60
+ }
61
+ }
@@ -0,0 +1,62 @@
1
+ import 'server-only'
2
+
3
+ import { cache } from 'react'
4
+
5
+ import { CacheTags } from '@meith/core'
6
+ import { getDb, PostgresGroupAdminRepository } from '@meith/db'
7
+ import { drivers } from '@meith/drivers'
8
+
9
+ import { getContainer } from './container'
10
+ import { forgetImage, type ImageScheme, storeImage } from './image-upload'
11
+
12
+ export const BADGE_FIELD = 'badge'
13
+
14
+ const KEY_SHAPE = /^group\/(\d+)\/badge-(light|dark)-[a-f0-9-]{36}\.(png|jpg|webp|svg)$/
15
+
16
+ function repository(): PostgresGroupAdminRepository | null {
17
+ return getContainer().dataSource === 'postgres' ? new PostgresGroupAdminRepository(getDb()) : null
18
+ }
19
+
20
+ export function badgeSrc(groupId: number, scheme: ImageScheme, key: string): string {
21
+ return `/group/${groupId}/badge/${scheme}?v=${key.slice(-16, -4)}`
22
+ }
23
+
24
+ export const badgeKey = cache(
25
+ async (groupId: number, scheme: ImageScheme): Promise<string | null> => {
26
+ const repo = repository()
27
+ if (repo === null) return null
28
+
29
+ const group = (await repo.list().catch(() => [])).find((row) => row.id === groupId)
30
+ const key = scheme === 'dark' ? group?.badgeImageDark : group?.badgeImageLight
31
+ if (typeof key !== 'string' || !KEY_SHAPE.test(key)) return null
32
+
33
+ return KEY_SHAPE.exec(key)?.[1] === String(groupId) ? key : null
34
+ },
35
+ )
36
+
37
+ export async function saveBadge(groupId: number, scheme: ImageScheme, file: File): Promise<void> {
38
+ const repo = requireGroups()
39
+ const key = await storeImage(`group/${groupId}`, `badge-${scheme}`, file)
40
+
41
+ const previous = await repo.setBadge(groupId, scheme, key)
42
+ await invalidate()
43
+ if (previous !== key) await forgetImage(previous)
44
+ }
45
+
46
+ export async function removeBadge(groupId: number, scheme: ImageScheme): Promise<void> {
47
+ const previous = await requireGroups().setBadge(groupId, scheme, null)
48
+ await invalidate()
49
+ await forgetImage(previous)
50
+ }
51
+
52
+ function requireGroups(): PostgresGroupAdminRepository {
53
+ const repo = repository()
54
+ if (repo === null) {
55
+ throw new Error('This board is running on in-memory sample data, so groups cannot be edited.')
56
+ }
57
+ return repo
58
+ }
59
+
60
+ async function invalidate(): Promise<void> {
61
+ await drivers().cache.invalidateTags([CacheTags.groups()])
62
+ }