@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,134 @@
1
+ 'use server'
2
+
3
+ import { revalidatePath } from 'next/cache'
4
+
5
+ import { NotFoundError, ValidationError } from '@meith/core'
6
+ import { drivers } from '@meith/drivers'
7
+ import { msg } from '@meith/i18n'
8
+
9
+ import { recordAdminAction, requireAdmin } from './admin'
10
+ import type { FormState } from './auth-form-state'
11
+ import { clearableTag } from './cache-targets'
12
+ import { formStateReporter } from './form-state-reporter'
13
+ import { requireSearch } from './search'
14
+ import { requireMaintenance, requireRecount } from './system-admin'
15
+
16
+ const SWEEP_LIMIT = 5_000
17
+
18
+ const RECOUNT_BATCH = 500
19
+
20
+ const REINDEX_BATCH = 2_000
21
+
22
+ const toFormState = formStateReporter('system-admin', 'maintenance action failed')
23
+
24
+ function refreshSystemScreen(): void {
25
+ revalidatePath('/admin/system')
26
+ }
27
+
28
+ export async function pruneSessionsAction(): Promise<FormState> {
29
+ try {
30
+ await requireAdmin()
31
+
32
+ const removed = await requireMaintenance().pruneSessions(new Date(), SWEEP_LIMIT)
33
+
34
+ refreshSystemScreen()
35
+ await recordAdminAction({ action: 'system.sessions_pruned', detail: { removed } })
36
+ return { notice: 'pruned', values: { removed: String(removed) } }
37
+ } catch (err) {
38
+ return toFormState(err)
39
+ }
40
+ }
41
+
42
+ export async function pruneTokensAction(): Promise<FormState> {
43
+ try {
44
+ await requireAdmin()
45
+
46
+ const removed = await requireMaintenance().pruneExpiredTokens(new Date(), SWEEP_LIMIT)
47
+
48
+ refreshSystemScreen()
49
+ await recordAdminAction({ action: 'system.tokens_pruned', detail: { removed } })
50
+ return { notice: 'pruned', values: { removed: String(removed) } }
51
+ } catch (err) {
52
+ return toFormState(err)
53
+ }
54
+ }
55
+
56
+ export async function recountAction(): Promise<FormState> {
57
+ try {
58
+ await requireAdmin()
59
+
60
+ const { corrected } = await requireRecount().run(RECOUNT_BATCH)
61
+
62
+ refreshSystemScreen()
63
+ await recordAdminAction({ action: 'system.recount_ran', detail: { corrected } })
64
+ return { notice: 'ran', values: { corrected: String(corrected) } }
65
+ } catch (err) {
66
+ return toFormState(err)
67
+ }
68
+ }
69
+
70
+ export async function reindexSearchAction(): Promise<FormState> {
71
+ try {
72
+ await requireAdmin()
73
+
74
+ const result = await requireSearch().reindexChunk(0, REINDEX_BATCH)
75
+ const progress = await requireSearch().indexProgress()
76
+
77
+ refreshSystemScreen()
78
+ await recordAdminAction({
79
+ action: 'system.search_reindexed',
80
+ detail: { indexed: result.indexed, pending: progress.pending },
81
+ })
82
+
83
+ return {
84
+ notice: progress.pending > 0 ? 'more' : 'finished',
85
+ values: { indexed: String(result.indexed), pending: String(progress.pending) },
86
+ }
87
+ } catch (err) {
88
+ return toFormState(err)
89
+ }
90
+ }
91
+
92
+ export async function clearCacheAction(_prev: FormState, form: FormData): Promise<FormState> {
93
+ try {
94
+ await requireAdmin()
95
+
96
+ const tag = clearableTag(form.get('what'))
97
+
98
+ if (tag === null) throw new ValidationError(msg('error.app.choose-what-clear'))
99
+
100
+ await drivers().cache.invalidateTags([tag])
101
+
102
+ refreshSystemScreen()
103
+ await recordAdminAction({ action: 'system.cache_cleared', detail: { tag } })
104
+ return { notice: 'cleared', values: { tag } }
105
+ } catch (err) {
106
+ return toFormState(err)
107
+ }
108
+ }
109
+
110
+ export async function retryJobAction(_prev: FormState, form: FormData): Promise<FormState> {
111
+ try {
112
+ await requireAdmin()
113
+
114
+ const jobId = form.get('jobId')
115
+ if (typeof jobId !== 'string' || jobId.trim() === '') {
116
+ throw new ValidationError(msg('error.app.such-job'))
117
+ }
118
+
119
+ const id = jobId.trim()
120
+ const requeued = await drivers().queue.retry(id)
121
+
122
+ if (!requeued) {
123
+ throw new NotFoundError(
124
+ `No dead-lettered job ${id}. Only a job that has exhausted its attempts can be retried.`,
125
+ )
126
+ }
127
+
128
+ refreshSystemScreen()
129
+ await recordAdminAction({ action: 'system.job_retried', detail: { jobId: id } })
130
+ return { notice: 'retried' }
131
+ } catch (err) {
132
+ return toFormState(err)
133
+ }
134
+ }
@@ -0,0 +1,90 @@
1
+ import { msg } from '@meith/i18n'
2
+ import 'server-only'
3
+
4
+ import { ForbiddenError } from '@meith/core'
5
+ import {
6
+ type BoardVolumes,
7
+ getDb,
8
+ PostgresCounterRecount,
9
+ PostgresMaintenanceRepository,
10
+ PostgresSearchRepository,
11
+ PostgresSystemHealthRepository,
12
+ type RecountStateRow,
13
+ type TaskRunRow,
14
+ } from '@meith/db'
15
+ import { assessScheduler, type SchedulerHealth, type TaskHealth } from '@meith/tasks'
16
+
17
+ import { getContainer } from './container'
18
+ import { assessMailReadiness, type MailReadiness } from './mail-health'
19
+
20
+ export function systemHealthRepository(): PostgresSystemHealthRepository | null {
21
+ return getContainer().dataSource === 'postgres'
22
+ ? new PostgresSystemHealthRepository(getDb())
23
+ : null
24
+ }
25
+
26
+ export function requireMaintenance(): PostgresMaintenanceRepository {
27
+ if (getContainer().dataSource !== 'postgres') {
28
+ throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-6'))
29
+ }
30
+ return new PostgresMaintenanceRepository(getDb())
31
+ }
32
+
33
+ export function requireRecount(): PostgresCounterRecount {
34
+ if (getContainer().dataSource !== 'postgres') {
35
+ throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-7'))
36
+ }
37
+ return new PostgresCounterRecount(getDb())
38
+ }
39
+
40
+ export interface SystemHealthView {
41
+ readonly searchIndex: { readonly indexed: number; readonly pending: number }
42
+ readonly scheduler: Omit<SchedulerHealth, 'tasks'> & {
43
+ readonly tasks: readonly (TaskHealth & {
44
+ readonly titleKey?: string
45
+ readonly descriptionKey?: string
46
+ })[]
47
+ }
48
+ readonly mail: MailReadiness
49
+ readonly runs: readonly TaskRunRow[]
50
+ readonly recount: readonly RecountStateRow[]
51
+ readonly volumes: BoardVolumes
52
+ readonly prunableSessions: number
53
+ }
54
+
55
+ export async function buildSystemHealthView(now: Date): Promise<SystemHealthView | null> {
56
+ const repository = systemHealthRepository()
57
+ if (repository === null) return null
58
+
59
+ const maintenance = new PostgresMaintenanceRepository(getDb())
60
+ const [tasks, runs, recount, volumes, prunableSessions, searchIndex, mail] = await Promise.all([
61
+ repository.taskHealth(),
62
+ repository.recentRuns(20),
63
+ repository.recountState(),
64
+ repository.volumes(),
65
+ maintenance.countPrunableSessions(now),
66
+ new PostgresSearchRepository(getDb()).indexProgress(),
67
+ assessMailReadiness(),
68
+ ])
69
+
70
+ const scheduler = assessScheduler(tasks, now)
71
+ const taskMessages = new Map(
72
+ (getContainer().scheduler?.tasks ?? []).map((task) => [
73
+ task.id,
74
+ { titleKey: task.titleKey, descriptionKey: task.descriptionKey },
75
+ ]),
76
+ )
77
+
78
+ return {
79
+ scheduler: {
80
+ ...scheduler,
81
+ tasks: scheduler.tasks.map((task) => ({ ...task, ...taskMessages.get(task.key) })),
82
+ },
83
+ searchIndex,
84
+ mail,
85
+ runs,
86
+ recount,
87
+ volumes,
88
+ prunableSessions,
89
+ }
90
+ }
@@ -0,0 +1,114 @@
1
+ import { createMemoryStore, IdentityService, SessionService } from '@meith/accounts'
2
+ import type { MemoryAppointment, MemoryBoard } from '@meith/authorization'
3
+ import { Authorizer, InMemoryAuthorizationSource } from '@meith/authorization'
4
+
5
+ import { ABSENT_SERVICES } from './absent-services'
6
+ import { FixtureActorSource } from './fixture-actor-source'
7
+ import { FIXTURE_DATA_VERSION, SEED_BOARD } from './seed-board'
8
+
9
+ export const CONTAINER_KEY = Symbol.for('@meith/forum.container')
10
+
11
+ export type TestContainerOverrides = Record<string, unknown>
12
+
13
+ export interface TestContainerOptions {
14
+ readonly moderators?: readonly MemoryAppointment[]
15
+ readonly overrides?: MemoryBoard['overrides']
16
+ readonly board?: MemoryBoard
17
+ readonly container?: TestContainerOverrides
18
+ }
19
+
20
+ export function installTestContainer(options: TestContainerOptions = {}): Record<string, unknown> {
21
+ const store = createMemoryStore()
22
+ const board: MemoryBoard = options.board ?? {
23
+ ...SEED_BOARD,
24
+ moderators: options.moderators ?? [],
25
+ overrides: [...SEED_BOARD.overrides, ...(options.overrides ?? [])],
26
+ }
27
+
28
+ const container = {
29
+ authorizer: new Authorizer(new InMemoryAuthorizationSource(board), {}),
30
+
31
+ ...ABSENT_SERVICES,
32
+ ...identityOver(store),
33
+
34
+ threads: {
35
+ locate: async () => null,
36
+ findById: async () => null,
37
+ listForum: async () => ({ rows: [], nextCursor: null }),
38
+ },
39
+ posts: {
40
+ findRatingTarget: async () => null,
41
+ findVisibleById: async () => null,
42
+ listThread: async () => ({ rows: [], nextAfterId: null }),
43
+ },
44
+ memberProfiles: { findPublicById: async () => null },
45
+ forums: {
46
+ listAll: async () => [],
47
+ listListing: async () => [],
48
+ findById: async () => null,
49
+ },
50
+
51
+ fixtureDataVersion: FIXTURE_DATA_VERSION,
52
+ dataSource: 'fixture' as const,
53
+
54
+ ...options.container,
55
+ }
56
+
57
+ ;(globalThis as Record<symbol, unknown>)[CONTAINER_KEY] = container
58
+ return container
59
+ }
60
+
61
+ function identityOver(store: ReturnType<typeof createMemoryStore>) {
62
+ return {
63
+ accountStore: store,
64
+ actorSource: new FixtureActorSource(store),
65
+ identity: new IdentityService({
66
+ store,
67
+ config: {
68
+ registrationEnabled: true,
69
+ minPasswordLength: 8,
70
+ usernameMin: 3,
71
+ usernameMax: 30,
72
+ activationMethod: 'none',
73
+ maxLoginAttempts: 5,
74
+ maxAccountLoginAttempts: 50,
75
+ lockoutMinutes: 15,
76
+ sessionLifetimeDays: 30,
77
+ resetTokenTtlMinutes: 60,
78
+ reservedUsernames: [],
79
+ defaultMemberGroupId: 2,
80
+ },
81
+ }),
82
+ sessions: new SessionService({
83
+ store,
84
+ rememberDays: 30,
85
+ sessionLifetimeDays: 30,
86
+ }),
87
+ }
88
+ }
89
+
90
+ export function clearTestContainer(): void {
91
+ delete (globalThis as Record<symbol, unknown>)[CONTAINER_KEY]
92
+ }
93
+
94
+ export function appointment(
95
+ forumId: number,
96
+ rights: Partial<MemoryAppointment> = {},
97
+ userId = 3,
98
+ ): MemoryAppointment {
99
+ return {
100
+ userId,
101
+ forumId,
102
+ cascadeToSubforums: false,
103
+ canApproveContent: false,
104
+ canEditPosts: false,
105
+ canSoftDeletePosts: false,
106
+ canRestorePosts: false,
107
+ canOpenCloseThreads: false,
108
+ canStickThreads: false,
109
+ canMoveThreads: false,
110
+ canMergeThreads: false,
111
+ canSplitThreads: false,
112
+ ...rights,
113
+ }
114
+ }
@@ -0,0 +1,242 @@
1
+ 'use server'
2
+
3
+ import { revalidatePath } from 'next/cache'
4
+
5
+ import { CacheTags, isAppError, ValidationError } from '@meith/core'
6
+ import { parseThemeExport } from '@meith/db'
7
+ import { drivers } from '@meith/drivers'
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 { trimmedText } from './form-values'
14
+ import {
15
+ isBuildTheme,
16
+ requireThemeAdmin,
17
+ themeListing,
18
+ themeTitle,
19
+ themeTokens,
20
+ } from './theme-admin'
21
+ import { validateCustomCss, validateTokenOverrides } from './theme-style'
22
+
23
+ const reportFailure = formStateReporter('theme-admin', 'theme write failed')
24
+
25
+ async function toFormState(err: unknown): Promise<FormState> {
26
+ if (!isAppError(err) && err instanceof Error && err.message.startsWith('Theme ')) {
27
+ return { error: err.message }
28
+ }
29
+ return reportFailure(err)
30
+ }
31
+
32
+ function submittedTokens(form: FormData): {
33
+ light: Record<string, string>
34
+ dark: Record<string, string>
35
+ } {
36
+ const light: Record<string, string> = {}
37
+ const dark: Record<string, string> = {}
38
+
39
+ for (const [field, value] of form.entries()) {
40
+ if (typeof value !== 'string') continue
41
+ const match = /^token\.(light|dark|both)\.(.+)$/.exec(field)
42
+ if (match === null) continue
43
+
44
+ const trimmed = value.trim()
45
+ if (trimmed === '') continue
46
+
47
+ const [, scheme, name] = match as unknown as [string, string, string]
48
+ if (scheme !== 'dark') light[name] = trimmed
49
+ if (scheme !== 'light') dark[name] = trimmed
50
+ }
51
+
52
+ return { light, dark }
53
+ }
54
+
55
+ function themeKey(form: FormData): string {
56
+ const key = trimmedText(form, 'key')
57
+ if (themeTitle(key) === null) throw new ValidationError(msg('error.app.such-theme'))
58
+ return key
59
+ }
60
+
61
+ async function invalidateTheme(key: string): Promise<void> {
62
+ await drivers().cache.invalidateTags([CacheTags.theme(key)])
63
+ revalidatePath('/admin/themes')
64
+ revalidatePath('/admin/themes/[key]', 'page')
65
+ }
66
+
67
+ export async function saveThemeAction(_prev: FormState, form: FormData): Promise<FormState> {
68
+ try {
69
+ await requireAdmin()
70
+ const key = themeKey(form)
71
+
72
+ const tokens = themeTokens(key)
73
+ if (tokens === null) throw new ValidationError(msg('error.app.such-theme'))
74
+
75
+ const validated = validateTokenOverrides(tokens, submittedTokens(form))
76
+ const customCss = trimmedText(form, 'customCss')
77
+ const css = validateCustomCss(customCss === '' ? null : customCss)
78
+
79
+ await requireThemeAdmin().save({
80
+ key,
81
+ title: themeTitle(key) ?? key,
82
+ tokenOverrides: validated,
83
+ customCss: css,
84
+ })
85
+
86
+ await invalidateTheme(key)
87
+ await recordAdminAction({
88
+ action: 'theme.saved',
89
+ detail: {
90
+ key,
91
+ tokens: new Set([...Object.keys(validated.light), ...Object.keys(validated.dark)]).size,
92
+ customCss: css !== null,
93
+ },
94
+ })
95
+
96
+ return { notice: 'saved' }
97
+ } catch (err) {
98
+ return toFormState(err)
99
+ }
100
+ }
101
+
102
+ export async function previewThemeAction(_prev: FormState, form: FormData): Promise<FormState> {
103
+ try {
104
+ await requireAdmin()
105
+ const key = themeKey(form)
106
+
107
+ const tokens = themeTokens(key)
108
+ if (tokens === null) throw new ValidationError(msg('error.app.such-theme'))
109
+
110
+ const submitted: Record<string, string> = {}
111
+ for (const [field, value] of form.entries()) {
112
+ if (field.startsWith('token.') && typeof value === 'string') submitted[field] = value
113
+ }
114
+
115
+ const validated = validateTokenOverrides(tokens, submittedTokens(form))
116
+ const customCss = trimmedText(form, 'customCss')
117
+ const css = validateCustomCss(customCss === '' ? null : customCss)
118
+
119
+ return {
120
+ notice: 'previewed',
121
+ values: { ...submitted, customCss },
122
+ preview: declarationBlock(validated, css),
123
+ }
124
+ } catch (err) {
125
+ return toFormState(err)
126
+ }
127
+ }
128
+
129
+ export async function themeEditorAction(prev: FormState, form: FormData): Promise<FormState> {
130
+ return trimmedText(form, 'intent') === 'preview'
131
+ ? previewThemeAction(prev, form)
132
+ : saveThemeAction(prev, form)
133
+ }
134
+
135
+ function declarationBlock(
136
+ overrides: { light: Readonly<Record<string, string>>; dark: Readonly<Record<string, string>> },
137
+ customCss: string | null,
138
+ ): string {
139
+ const declarations = (values: Readonly<Record<string, string>>): string =>
140
+ Object.entries(values)
141
+ .map(([name, value]) => `--${name}:${value};`)
142
+ .join('')
143
+
144
+ return (
145
+ `[data-theme-preview]{${declarations(overrides.light)}}` +
146
+ `[data-theme-preview].dark{${declarations(overrides.dark)}}` +
147
+ (customCss === null ? '' : `[data-theme-preview]{${customCss}}`)
148
+ )
149
+ }
150
+
151
+ export async function resetThemeAction(_prev: FormState, form: FormData): Promise<FormState> {
152
+ try {
153
+ await requireFreshAdmin()
154
+ const key = themeKey(form)
155
+
156
+ await requireThemeAdmin().reset(key)
157
+
158
+ await invalidateTheme(key)
159
+ await recordAdminAction({ action: 'theme.reset', detail: { key } })
160
+
161
+ return { notice: 'reset' }
162
+ } catch (err) {
163
+ return toFormState(err)
164
+ }
165
+ }
166
+
167
+ export async function importThemeAction(_prev: FormState, form: FormData): Promise<FormState> {
168
+ try {
169
+ await requireFreshAdmin()
170
+ const key = themeKey(form)
171
+
172
+ const tokens = themeTokens(key)
173
+ if (tokens === null) throw new ValidationError(msg('error.app.such-theme'))
174
+
175
+ const document = parseThemeExport(trimmedText(form, 'document'))
176
+ const validated = validateTokenOverrides(tokens, document.tokenOverrides)
177
+ const css = validateCustomCss(document.customCss)
178
+
179
+ await requireThemeAdmin().save({
180
+ key,
181
+ title: themeTitle(key) ?? key,
182
+ tokenOverrides: validated,
183
+ customCss: css,
184
+ })
185
+
186
+ await invalidateTheme(key)
187
+ await recordAdminAction({
188
+ action: 'theme.imported',
189
+ detail: {
190
+ key,
191
+ tokens: new Set([...Object.keys(validated.light), ...Object.keys(validated.dark)]).size,
192
+ },
193
+ })
194
+
195
+ return { notice: 'imported' }
196
+ } catch (err) {
197
+ return toFormState(err)
198
+ }
199
+ }
200
+
201
+ export async function setThemeEnabledAction(_prev: FormState, form: FormData): Promise<FormState> {
202
+ try {
203
+ await requireAdmin()
204
+ const key = themeKey(form)
205
+ const enabled = trimmedText(form, 'enabled') === 'true'
206
+
207
+ if (!enabled) {
208
+ if (isBuildTheme(key)) {
209
+ throw new ValidationError(msg('error.app.theme-board-built-with-so'))
210
+ }
211
+ const listing = (await themeListing()).find((entry) => entry.key === key)
212
+ if (listing?.isDefault === true) {
213
+ throw new ValidationError(msg('error.app.default-theme-make-another-theme'))
214
+ }
215
+ }
216
+
217
+ await requireThemeAdmin().setEnabled(key, enabled, themeTitle(key) ?? key)
218
+
219
+ await invalidateTheme(key)
220
+ await recordAdminAction({ action: 'theme.enabled', detail: { key, enabled } })
221
+
222
+ return { notice: enabled ? 'enabled' : 'disabled' }
223
+ } catch (err) {
224
+ return toFormState(err)
225
+ }
226
+ }
227
+
228
+ export async function setDefaultThemeAction(_prev: FormState, form: FormData): Promise<FormState> {
229
+ try {
230
+ await requireAdmin()
231
+ const key = themeKey(form)
232
+
233
+ await requireThemeAdmin().setDefault(key, themeTitle(key) ?? key)
234
+
235
+ await invalidateTheme(key)
236
+ await recordAdminAction({ action: 'theme.default', detail: { key } })
237
+
238
+ return { notice: 'default' }
239
+ } catch (err) {
240
+ return toFormState(err)
241
+ }
242
+ }