@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,646 @@
1
+ 'use client'
2
+
3
+ import { useActionState, useState } from 'react'
4
+
5
+ import { EMPTY_STATE } from '@/server/auth-form-state'
6
+ import {
7
+ applyPromotionsAction,
8
+ createGroupAction,
9
+ createPromotionRuleAction,
10
+ deleteGroupAction,
11
+ deletePromotionRuleAction,
12
+ moveMembersAction,
13
+ saveGroupIdentityAction,
14
+ saveGroupPermissionsAction,
15
+ setPromotionRuleEnabledAction,
16
+ updatePromotionRuleAction,
17
+ } from '@/server/group-admin-actions'
18
+
19
+ import { FormError, SubmitButton } from '../auth/form-controls'
20
+ import { type Copy, formatFromCopy, fromCopy } from '../shell/copy'
21
+ import { INPUT, Saved } from './form-bits'
22
+ import { OklchPicker } from './oklch-picker'
23
+
24
+ export interface GroupOption {
25
+ readonly id: number
26
+ readonly title: string
27
+ readonly memberCount: number
28
+ }
29
+
30
+ function GroupSelect({
31
+ name,
32
+ groups,
33
+ copy,
34
+ defaultValue,
35
+ exclude,
36
+ required = true,
37
+ placeholder,
38
+ }: {
39
+ name: string
40
+ groups: readonly GroupOption[]
41
+ copy: Copy
42
+ defaultValue?: string | undefined
43
+ exclude?: number | undefined
44
+ required?: boolean
45
+ placeholder?: string
46
+ }) {
47
+ return (
48
+ <select name={name} defaultValue={defaultValue ?? ''} className={INPUT} required={required}>
49
+ <option value="">{placeholder ?? fromCopy(copy, 'adminGroup.select.placeholder')}</option>
50
+ {groups
51
+ .filter((group) => group.id !== exclude)
52
+ .map((group) => (
53
+ <option key={group.id} value={group.id}>
54
+ {group.title} ({group.memberCount})
55
+ </option>
56
+ ))}
57
+ </select>
58
+ )
59
+ }
60
+
61
+ export interface GroupIdentityValues {
62
+ readonly id: number
63
+ readonly title: string
64
+ readonly description: string
65
+ readonly displayOrder: number
66
+ readonly isStaffGroup: boolean
67
+ readonly pluginGrantable: boolean
68
+ readonly badgeToken: string
69
+ readonly nameColorLight: string
70
+ readonly nameColorDark: string
71
+ }
72
+
73
+ export interface SampleSurface {
74
+ readonly background: string
75
+ readonly foreground: string
76
+ }
77
+
78
+ export function GroupIdentityForm({
79
+ group,
80
+ surfaces,
81
+ copy,
82
+ }: {
83
+ group: GroupIdentityValues
84
+ surfaces: { readonly light: SampleSurface; readonly dark: SampleSurface }
85
+ copy: Copy
86
+ }) {
87
+ const [state, action] = useActionState(saveGroupIdentityAction, EMPTY_STATE)
88
+ const [light, setLight] = useState(group.nameColorLight)
89
+ const [dark, setDark] = useState(group.nameColorDark)
90
+
91
+ return (
92
+ <form action={action} className="flex flex-col gap-4" noValidate>
93
+ <FormError message={state.error} />
94
+ <Saved when={state.notice === 'saved'}>{fromCopy(copy, 'admin.saved')}</Saved>
95
+ <input type="hidden" name="groupId" value={group.id} />
96
+
97
+ <label className="flex flex-col gap-1 text-sm">
98
+ <span className="font-medium">{fromCopy(copy, 'adminGroup.title')}</span>
99
+ <input name="title" defaultValue={group.title} className={INPUT} required />
100
+ </label>
101
+
102
+ <label className="flex flex-col gap-1 text-sm">
103
+ <span className="font-medium">{fromCopy(copy, 'adminGroup.description')}</span>
104
+ <textarea name="description" rows={2} defaultValue={group.description} className={INPUT} />
105
+ </label>
106
+
107
+ <label className="flex flex-col gap-1 text-sm">
108
+ <span className="font-medium">{fromCopy(copy, 'adminGroup.displayOrder')}</span>
109
+ <input
110
+ type="number"
111
+ name="displayOrder"
112
+ min={0}
113
+ defaultValue={group.displayOrder}
114
+ className={INPUT}
115
+ />
116
+ </label>
117
+
118
+ <fieldset className="flex flex-col gap-3">
119
+ <legend className="text-sm font-medium">{fromCopy(copy, 'adminGroup.nameColour')}</legend>
120
+ <p className="text-xs text-muted-foreground">
121
+ {fromCopy(copy, 'adminGroup.nameColourHint')}
122
+ </p>
123
+
124
+ <div className="grid gap-3 sm:grid-cols-2">
125
+ {[
126
+ [
127
+ 'light',
128
+ fromCopy(copy, 'adminGroup.light'),
129
+ light,
130
+ setLight,
131
+ surfaces.light,
132
+ fromCopy(copy, 'adminGroup.nameColour.describesLight'),
133
+ ] as const,
134
+ [
135
+ 'dark',
136
+ fromCopy(copy, 'adminGroup.dark'),
137
+ dark,
138
+ setDark,
139
+ surfaces.dark,
140
+ fromCopy(copy, 'adminGroup.nameColour.describesDark'),
141
+ ] as const,
142
+ ].map(([scheme, label, value, set, surface, describes]) => (
143
+ <div key={scheme} className="flex flex-col gap-2">
144
+ <span className="text-xs text-muted-foreground">{label}</span>
145
+ <OklchPicker
146
+ name={scheme === 'dark' ? 'nameColorDark' : 'nameColorLight'}
147
+ describes={describes}
148
+ copy={copy}
149
+ value={value}
150
+ onChange={set}
151
+ />
152
+ <p
153
+ className="rounded-md border border-border px-3 py-2 text-sm"
154
+ style={{ backgroundColor: surface.background, color: surface.foreground }}
155
+ >
156
+ <span style={value === '' ? undefined : { color: value }}>
157
+ {group.title || fromCopy(copy, 'adminGroup.sampleMember')}
158
+ </span>
159
+ </p>
160
+ </div>
161
+ ))}
162
+ </div>
163
+ </fieldset>
164
+
165
+ <input type="hidden" name="badgeToken" value={group.badgeToken} />
166
+
167
+ <label className="flex items-center gap-2 text-sm">
168
+ <input
169
+ type="checkbox"
170
+ name="isStaffGroup"
171
+ value="1"
172
+ defaultChecked={group.isStaffGroup}
173
+ className="size-4"
174
+ />
175
+ <span>{fromCopy(copy, 'adminGroup.staffGroup')}</span>
176
+ </label>
177
+
178
+ <label className="flex flex-col gap-1 text-sm">
179
+ <span className="flex items-center gap-2">
180
+ <input
181
+ type="checkbox"
182
+ name="pluginGrantable"
183
+ value="1"
184
+ defaultChecked={group.pluginGrantable}
185
+ className="size-4"
186
+ />
187
+ <span>{fromCopy(copy, 'adminGroup.pluginGrantable')}</span>
188
+ </span>
189
+ <span className="text-xs text-muted-foreground">
190
+ {fromCopy(copy, 'adminGroup.pluginGrantableHint')}
191
+ </span>
192
+ </label>
193
+
194
+ <div>
195
+ <SubmitButton>{fromCopy(copy, 'adminGroup.saveGroup')}</SubmitButton>
196
+ </div>
197
+ </form>
198
+ )
199
+ }
200
+
201
+ export interface PermissionCellValues {
202
+ readonly key: string
203
+ readonly description: string
204
+ readonly kind: 'boolean' | 'numeric' | 'negative'
205
+ readonly scope: 'global' | 'forum'
206
+ readonly value: boolean | number
207
+ }
208
+
209
+ function PermissionControl({ cell }: { cell: PermissionCellValues }) {
210
+ if (cell.kind === 'numeric') {
211
+ return (
212
+ <input
213
+ type="number"
214
+ name={cell.key}
215
+ min={0}
216
+ defaultValue={String(cell.value)}
217
+ className={INPUT}
218
+ />
219
+ )
220
+ }
221
+
222
+ return (
223
+ <input
224
+ type="checkbox"
225
+ name={cell.key}
226
+ value="1"
227
+ defaultChecked={cell.value === true}
228
+ className="mt-1 size-4 shrink-0"
229
+ />
230
+ )
231
+ }
232
+
233
+ export function GroupPermissionsForm({
234
+ groupId,
235
+ cells,
236
+ copy,
237
+ }: {
238
+ groupId: number
239
+ cells: readonly PermissionCellValues[]
240
+ copy: Copy
241
+ }) {
242
+ const [state, action] = useActionState(saveGroupPermissionsAction, EMPTY_STATE)
243
+
244
+ return (
245
+ <form action={action} className="flex flex-col gap-4" noValidate>
246
+ <FormError message={state.error} />
247
+ <Saved when={state.notice === 'saved'}>{fromCopy(copy, 'admin.saved')}</Saved>
248
+ <input type="hidden" name="groupId" value={groupId} />
249
+
250
+ <div className="flex flex-col divide-y divide-border">
251
+ {cells.map((cell) => (
252
+ // biome-ignore lint/a11y/noLabelWithoutControl: PermissionControl is the control, rendered behind the numeric-cell branch
253
+ <label key={cell.key} className="flex items-start gap-3 py-3 text-sm">
254
+ {cell.kind === 'numeric' ? null : <PermissionControl cell={cell} />}
255
+ <span className="flex min-w-0 flex-col gap-1">
256
+ <span className="font-medium">
257
+ <code className="text-xs">{cell.key}</code>
258
+ {cell.kind === 'negative' && (
259
+ <span className="ml-2 text-xs font-normal text-muted-foreground">
260
+ {fromCopy(copy, 'adminGroup.perm.tickedRestricted')}
261
+ </span>
262
+ )}
263
+ {cell.scope === 'forum' && (
264
+ <span className="ml-2 text-xs font-normal text-muted-foreground">
265
+ {fromCopy(copy, 'adminGroup.perm.forumDefault')}
266
+ </span>
267
+ )}
268
+ </span>
269
+ <span className="text-xs text-muted-foreground">{cell.description}</span>
270
+ {cell.kind === 'numeric' && (
271
+ <span className="max-w-xs">
272
+ <PermissionControl cell={cell} />
273
+ </span>
274
+ )}
275
+ </span>
276
+ </label>
277
+ ))}
278
+ </div>
279
+
280
+ <div>
281
+ <SubmitButton>{fromCopy(copy, 'adminGroup.savePermissions')}</SubmitButton>
282
+ </div>
283
+ </form>
284
+ )
285
+ }
286
+
287
+ export function CreateGroupForm({ groups, copy }: { groups: readonly GroupOption[]; copy: Copy }) {
288
+ const [state, action] = useActionState(createGroupAction, EMPTY_STATE)
289
+
290
+ return (
291
+ <form action={action} className="flex flex-col gap-3" noValidate>
292
+ <FormError message={state.error} />
293
+ <Saved when={state.notice === 'created'}>{fromCopy(copy, 'admin.created')}</Saved>
294
+
295
+ <div className="grid gap-3 sm:grid-cols-2">
296
+ <label className="flex flex-col gap-1 text-sm">
297
+ <span className="font-medium">{fromCopy(copy, 'adminGroup.title')}</span>
298
+ <input name="title" className={INPUT} required />
299
+ </label>
300
+
301
+ <label className="flex flex-col gap-1 text-sm">
302
+ <span className="font-medium">{fromCopy(copy, 'adminGroup.key')}</span>
303
+ <input name="key" className={INPUT} required />
304
+ <span className="text-xs text-muted-foreground">
305
+ {fromCopy(copy, 'adminGroup.keyHint')}
306
+ </span>
307
+ </label>
308
+
309
+ <label className="flex flex-col gap-1 text-sm sm:col-span-2">
310
+ <span className="font-medium">{fromCopy(copy, 'adminGroup.copyPermissionsFrom')}</span>
311
+ <GroupSelect name="copyFromGroupId" groups={groups} copy={copy} />
312
+ <span className="text-xs text-muted-foreground">
313
+ {fromCopy(copy, 'adminGroup.copyPermissionsFromHint')}
314
+ </span>
315
+ </label>
316
+ </div>
317
+
318
+ <div>
319
+ <SubmitButton>{fromCopy(copy, 'adminGroup.createGroup')}</SubmitButton>
320
+ </div>
321
+ </form>
322
+ )
323
+ }
324
+
325
+ export function DeleteGroupForm({
326
+ groupId,
327
+ groups,
328
+ copy,
329
+ }: {
330
+ groupId: number
331
+ groups: readonly GroupOption[]
332
+ copy: Copy
333
+ }) {
334
+ const [state, action] = useActionState(deleteGroupAction, EMPTY_STATE)
335
+
336
+ return (
337
+ <form action={action} className="flex flex-col gap-3" noValidate>
338
+ <FormError message={state.error} />
339
+ <Saved when={state.notice === 'deleted'}>{fromCopy(copy, 'adminGroup.deleted')}</Saved>
340
+ <input type="hidden" name="groupId" value={groupId} />
341
+
342
+ <label className="flex flex-col gap-1 text-sm">
343
+ <span className="font-medium">{fromCopy(copy, 'adminGroup.moveMembersTo')}</span>
344
+ <GroupSelect name="moveMembersTo" groups={groups} copy={copy} exclude={groupId} />
345
+ <span className="text-xs text-muted-foreground">
346
+ {fromCopy(copy, 'adminGroup.moveMembersToHint')}
347
+ </span>
348
+ </label>
349
+
350
+ <div>
351
+ <SubmitButton>{fromCopy(copy, 'adminGroup.deleteGroup')}</SubmitButton>
352
+ </div>
353
+ <p className="text-xs text-muted-foreground">{fromCopy(copy, 'adminGroup.deleteNote')}</p>
354
+ </form>
355
+ )
356
+ }
357
+
358
+ export function MoveMembersForm({ groups, copy }: { groups: readonly GroupOption[]; copy: Copy }) {
359
+ const [state, action] = useActionState(moveMembersAction, EMPTY_STATE)
360
+
361
+ const cursor = state.values?.afterUserId ?? '0'
362
+ const movedSoFar = state.values?.movedSoFar ?? '0'
363
+ const running = state.notice === 'more'
364
+
365
+ return (
366
+ <form action={action} className="flex flex-col gap-3" noValidate>
367
+ <FormError message={state.error} />
368
+
369
+ {state.notice === 'finished' && (
370
+ <Saved when>
371
+ {formatFromCopy(copy, 'adminGroup.move.finished', { count: Number(movedSoFar) })}
372
+ </Saved>
373
+ )}
374
+ {running && (
375
+ <Saved when>
376
+ {formatFromCopy(copy, 'adminGroup.move.moreSoFar', { count: Number(movedSoFar) })}
377
+ </Saved>
378
+ )}
379
+
380
+ <input type="hidden" name="afterUserId" value={running ? cursor : '0'} />
381
+ <input type="hidden" name="movedSoFar" value={running ? movedSoFar : '0'} />
382
+
383
+ <div className="grid gap-3 sm:grid-cols-2">
384
+ <label className="flex flex-col gap-1 text-sm">
385
+ <span className="font-medium">{fromCopy(copy, 'adminGroup.move.from')}</span>
386
+ <GroupSelect
387
+ name="fromGroupId"
388
+ groups={groups}
389
+ copy={copy}
390
+ defaultValue={state.values?.fromGroupId}
391
+ />
392
+ </label>
393
+
394
+ <label className="flex flex-col gap-1 text-sm">
395
+ <span className="font-medium">{fromCopy(copy, 'adminGroup.move.to')}</span>
396
+ <GroupSelect
397
+ name="toGroupId"
398
+ groups={groups}
399
+ copy={copy}
400
+ defaultValue={state.values?.toGroupId}
401
+ />
402
+ </label>
403
+ </div>
404
+
405
+ <div>
406
+ <SubmitButton>
407
+ {running
408
+ ? fromCopy(copy, 'adminGroup.move.nextBatch')
409
+ : fromCopy(copy, 'adminGroup.move.start')}
410
+ </SubmitButton>
411
+ </div>
412
+ <p className="text-xs text-muted-foreground">{fromCopy(copy, 'adminGroup.move.note')}</p>
413
+ </form>
414
+ )
415
+ }
416
+
417
+ export interface PromotionRuleValues {
418
+ readonly id: number
419
+ readonly title: string
420
+ readonly enabled: boolean
421
+ readonly displayOrder: string
422
+ readonly minPostCount: string
423
+ readonly minReputation: string
424
+ readonly minDaysRegistered: string
425
+ readonly fromPrimaryGroupId: string
426
+ readonly toPrimaryGroupId: string
427
+ }
428
+
429
+ function PromotionRuleFields({
430
+ groups,
431
+ rule,
432
+ copy,
433
+ }: {
434
+ groups: readonly GroupOption[]
435
+ rule?: PromotionRuleValues
436
+ copy: Copy
437
+ }) {
438
+ return (
439
+ <>
440
+ <div className="grid gap-3 sm:grid-cols-2">
441
+ <label className="flex flex-col gap-1 text-sm">
442
+ <span className="font-medium">{fromCopy(copy, 'adminGroup.title')}</span>
443
+ <input
444
+ name="title"
445
+ defaultValue={rule?.title ?? ''}
446
+ className={INPUT}
447
+ placeholder={fromCopy(copy, 'adminGroup.rule.titlePlaceholder')}
448
+ required
449
+ />
450
+ <span className="text-xs text-muted-foreground">
451
+ {fromCopy(copy, 'adminGroup.rule.titleHint')}
452
+ </span>
453
+ </label>
454
+
455
+ <label className="flex flex-col gap-1 text-sm">
456
+ <span className="font-medium">{fromCopy(copy, 'adminGroup.displayOrder')}</span>
457
+ <input
458
+ type="number"
459
+ name="displayOrder"
460
+ min={0}
461
+ defaultValue={rule?.displayOrder ?? '0'}
462
+ className={INPUT}
463
+ />
464
+ <span className="text-xs text-muted-foreground">
465
+ {fromCopy(copy, 'adminGroup.rule.displayOrderHint')}
466
+ </span>
467
+ </label>
468
+
469
+ <label className="flex flex-col gap-1 text-sm">
470
+ <span className="font-medium">{fromCopy(copy, 'adminGroup.rule.promoteFrom')}</span>
471
+ <GroupSelect
472
+ name="fromPrimaryGroupId"
473
+ groups={groups}
474
+ copy={copy}
475
+ defaultValue={rule?.fromPrimaryGroupId}
476
+ required={false}
477
+ placeholder={fromCopy(copy, 'adminGroup.rule.anyGroup')}
478
+ />
479
+ <span className="text-xs text-muted-foreground">
480
+ {fromCopy(copy, 'adminGroup.rule.promoteFromHint')}
481
+ </span>
482
+ </label>
483
+
484
+ <label className="flex flex-col gap-1 text-sm">
485
+ <span className="font-medium">{fromCopy(copy, 'adminGroup.rule.promoteInto')}</span>
486
+ <GroupSelect
487
+ name="toPrimaryGroupId"
488
+ groups={groups}
489
+ copy={copy}
490
+ defaultValue={rule?.toPrimaryGroupId}
491
+ />
492
+ <span className="text-xs text-muted-foreground">
493
+ {fromCopy(copy, 'adminGroup.rule.promoteIntoHint')}
494
+ </span>
495
+ </label>
496
+ </div>
497
+
498
+ <fieldset className="flex flex-col gap-3">
499
+ <legend className="text-sm font-medium">
500
+ {fromCopy(copy, 'adminGroup.rule.thresholds')}
501
+ </legend>
502
+ <p className="text-xs text-muted-foreground">
503
+ {fromCopy(copy, 'adminGroup.rule.thresholdsHint')}
504
+ </p>
505
+
506
+ <div className="grid gap-3 sm:grid-cols-3">
507
+ <label className="flex flex-col gap-1 text-sm">
508
+ <span className="font-medium">{fromCopy(copy, 'adminGroup.rule.posts')}</span>
509
+ <input
510
+ type="number"
511
+ name="minPostCount"
512
+ min={0}
513
+ defaultValue={rule?.minPostCount ?? ''}
514
+ className={INPUT}
515
+ />
516
+ </label>
517
+
518
+ <label className="flex flex-col gap-1 text-sm">
519
+ <span className="font-medium">{fromCopy(copy, 'adminGroup.rule.reputation')}</span>
520
+ <input
521
+ type="number"
522
+ name="minReputation"
523
+ min={0}
524
+ defaultValue={rule?.minReputation ?? ''}
525
+ className={INPUT}
526
+ />
527
+ </label>
528
+
529
+ <label className="flex flex-col gap-1 text-sm">
530
+ <span className="font-medium">{fromCopy(copy, 'adminGroup.rule.daysRegistered')}</span>
531
+ <input
532
+ type="number"
533
+ name="minDaysRegistered"
534
+ min={0}
535
+ defaultValue={rule?.minDaysRegistered ?? ''}
536
+ className={INPUT}
537
+ />
538
+ </label>
539
+ </div>
540
+ </fieldset>
541
+ </>
542
+ )
543
+ }
544
+
545
+ export function PromotionRuleRowForm({
546
+ rule,
547
+ groups,
548
+ copy,
549
+ }: {
550
+ rule: PromotionRuleValues
551
+ groups: readonly GroupOption[]
552
+ copy: Copy
553
+ }) {
554
+ const [state, action] = useActionState(updatePromotionRuleAction, EMPTY_STATE)
555
+ const [toggleState, toggleAction] = useActionState(setPromotionRuleEnabledAction, EMPTY_STATE)
556
+ const [removeState, removeAction] = useActionState(deletePromotionRuleAction, EMPTY_STATE)
557
+
558
+ return (
559
+ <div className="flex flex-col gap-3 py-4">
560
+ <FormError message={state.error ?? toggleState.error ?? removeState.error} />
561
+ <Saved when={state.notice === 'saved'}>{fromCopy(copy, 'admin.saved')}</Saved>
562
+ <Saved when={toggleState.notice === 'enabled'}>
563
+ {fromCopy(copy, 'adminGroup.rule.enabledNotice')}
564
+ </Saved>
565
+ <Saved when={toggleState.notice === 'disabled'}>
566
+ {fromCopy(copy, 'adminGroup.rule.disabledNotice')}
567
+ </Saved>
568
+
569
+ <form action={action} className="flex flex-col gap-3" noValidate>
570
+ <input type="hidden" name="id" value={rule.id} />
571
+ <PromotionRuleFields groups={groups} rule={rule} copy={copy} />
572
+
573
+ <div>
574
+ <SubmitButton>{fromCopy(copy, 'adminGroup.rule.save')}</SubmitButton>
575
+ </div>
576
+ </form>
577
+
578
+ <div className="flex flex-wrap items-center gap-4">
579
+ <form action={toggleAction} className="flex items-center">
580
+ <input type="hidden" name="id" value={rule.id} />
581
+ {!rule.enabled && <input type="hidden" name="enabled" value="1" />}
582
+ <button type="submit" className="text-xs text-muted-foreground hover:underline">
583
+ {rule.enabled
584
+ ? fromCopy(copy, 'adminGroup.rule.disable')
585
+ : fromCopy(copy, 'adminGroup.rule.enable')}
586
+ </button>
587
+ </form>
588
+
589
+ <form action={removeAction} className="flex items-center">
590
+ <input type="hidden" name="id" value={rule.id} />
591
+ <button type="submit" className="text-xs text-destructive hover:underline">
592
+ {fromCopy(copy, 'admin.remove')}
593
+ </button>
594
+ </form>
595
+ </div>
596
+ <p className="text-xs text-muted-foreground">
597
+ {fromCopy(copy, 'adminGroup.rule.removeNote')}
598
+ </p>
599
+ </div>
600
+ )
601
+ }
602
+
603
+ export function NewPromotionRuleForm({
604
+ groups,
605
+ copy,
606
+ }: {
607
+ groups: readonly GroupOption[]
608
+ copy: Copy
609
+ }) {
610
+ const [state, action] = useActionState(createPromotionRuleAction, EMPTY_STATE)
611
+
612
+ return (
613
+ <form action={action} className="flex flex-col gap-3" noValidate>
614
+ <FormError message={state.error} />
615
+ <Saved when={state.notice === 'created'}>{fromCopy(copy, 'adminGroup.rule.added')}</Saved>
616
+
617
+ <PromotionRuleFields groups={groups} copy={copy} />
618
+
619
+ <div>
620
+ <SubmitButton>{fromCopy(copy, 'adminGroup.rule.add')}</SubmitButton>
621
+ </div>
622
+ </form>
623
+ )
624
+ }
625
+
626
+ export function ApplyPromotionsForm({ count, copy }: { count: number; copy: Copy }) {
627
+ const [state, action] = useActionState(applyPromotionsAction, EMPTY_STATE)
628
+ const promoted =
629
+ state.notice?.startsWith('promoted:') === true ? state.notice.slice('promoted:'.length) : null
630
+
631
+ return (
632
+ <form action={action} className="flex flex-col gap-3">
633
+ <FormError message={state.error} />
634
+ {promoted !== null && (
635
+ <Saved when>
636
+ {formatFromCopy(copy, 'adminGroup.apply.done', { count: Number(promoted) })}
637
+ </Saved>
638
+ )}
639
+
640
+ <div>
641
+ <SubmitButton>{formatFromCopy(copy, 'adminGroup.apply.promote', { count })}</SubmitButton>
642
+ </div>
643
+ <p className="text-xs text-muted-foreground">{fromCopy(copy, 'adminGroup.apply.note')}</p>
644
+ </form>
645
+ )
646
+ }