@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,203 @@
1
+ import { msg } from '@meith/i18n'
2
+ import 'server-only'
3
+
4
+ import forumConfig from '@board/config'
5
+
6
+ import { ForbiddenError } from '@meith/core'
7
+ import { getDb, PostgresThemeAdminRepository, type ThemeRecord } from '@meith/db'
8
+
9
+ import type { EditableToken } from '@/view/theme-draft'
10
+ import { tokenCopy } from '@/view/theme-tokens'
11
+
12
+ import { getContainer } from './container'
13
+ import { getTranslator } from './i18n'
14
+ import { type TokenOverrides, validateTokenOverrides } from './theme-style'
15
+
16
+ export function themeAdminRepository(): PostgresThemeAdminRepository | null {
17
+ return getContainer().dataSource === 'postgres' ? new PostgresThemeAdminRepository(getDb()) : null
18
+ }
19
+
20
+ export function requireThemeAdmin(): PostgresThemeAdminRepository {
21
+ const repository = themeAdminRepository()
22
+ if (repository === null) {
23
+ throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-24'))
24
+ }
25
+ return repository
26
+ }
27
+
28
+ export type TokenRow = EditableToken
29
+
30
+ export interface ThemeAdminView {
31
+ readonly key: string
32
+ readonly title: string
33
+ readonly isBuildTheme: boolean
34
+ readonly isDefault: boolean
35
+ readonly enabled: boolean
36
+ readonly tokens: readonly TokenRow[]
37
+ readonly customCss: string
38
+ readonly customised: boolean
39
+ readonly updatedAt: Date | null
40
+ }
41
+
42
+ export interface ThemeListing {
43
+ readonly key: string
44
+ readonly title: string
45
+ readonly isBuildTheme: boolean
46
+ readonly isDefault: boolean
47
+ readonly enabled: boolean
48
+ readonly overriddenTokens: number
49
+ readonly hasCustomCss: boolean
50
+ readonly updatedAt: Date | null
51
+ }
52
+
53
+ const buildThemeKey = forumConfig.defaultTheme
54
+
55
+ function countOverrides(raw: unknown): number {
56
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return 0
57
+
58
+ const record = raw as Record<string, unknown>
59
+ const scheme = (value: unknown): string[] =>
60
+ typeof value === 'object' && value !== null && !Array.isArray(value)
61
+ ? Object.keys(value as Record<string, unknown>)
62
+ : []
63
+
64
+ const keys = Object.keys(record)
65
+ if (keys.length > 0 && keys.every((key) => key === 'light' || key === 'dark')) {
66
+ return new Set([...scheme(record.light), ...scheme(record.dark)]).size
67
+ }
68
+ return keys.length
69
+ }
70
+
71
+ export async function themeListing(): Promise<readonly ThemeListing[]> {
72
+ const repository = themeAdminRepository()
73
+ const rows = repository === null ? [] : await repository.list()
74
+ const byKey = new Map(rows.map((row) => [row.key, row]))
75
+
76
+ const registered = Object.values(forumConfig.themes)
77
+ const claimed = registered.find((theme) => byKey.get(theme.key)?.isDefault === true)
78
+
79
+ return registered.map((theme) => {
80
+ const row = byKey.get(theme.key)
81
+ const enabled = row?.enabled !== false
82
+ return {
83
+ key: theme.key,
84
+ title: theme.title,
85
+ isBuildTheme: theme.key === buildThemeKey,
86
+ isDefault:
87
+ claimed === undefined || byKey.get(claimed.key)?.enabled === false
88
+ ? theme.key === buildThemeKey
89
+ : claimed.key === theme.key,
90
+ enabled,
91
+ overriddenTokens: countOverrides(row?.tokenOverrides),
92
+ hasCustomCss: (row?.customCss ?? null) !== null,
93
+ updatedAt: row?.updatedAt ?? null,
94
+ }
95
+ })
96
+ }
97
+
98
+ export async function buildThemeAdminView(key: string): Promise<ThemeAdminView | null> {
99
+ const t = await getTranslator()
100
+ const installed = forumConfig.themes[key]
101
+ const repository = themeAdminRepository()
102
+ if (installed === undefined || repository === null) return null
103
+
104
+ const record: ThemeRecord | null = await repository.read(key)
105
+
106
+ let overrides: TokenOverrides = { light: {}, dark: {} }
107
+ try {
108
+ overrides = validateTokenOverrides(installed.tokens, record?.tokenOverrides)
109
+ } catch {
110
+ /* ignore */
111
+ }
112
+
113
+ const listing = (await themeListing()).find((entry) => entry.key === key)
114
+
115
+ return {
116
+ key,
117
+ title: installed.title,
118
+ isBuildTheme: key === buildThemeKey,
119
+ isDefault: listing?.isDefault ?? key === buildThemeKey,
120
+ enabled: listing?.enabled ?? true,
121
+ tokens: Object.keys(installed.tokens.light).map((name) => {
122
+ const light = installed.tokens.light[name] ?? ''
123
+ const dark = installed.tokens.dark[name] ?? ''
124
+ const meta = tokenCopy(name, t)
125
+ return {
126
+ name,
127
+ label: meta.label,
128
+ hint: meta.hint,
129
+ kind: meta.kind,
130
+ light,
131
+ dark,
132
+ overrideLight: overrides.light[name] ?? '',
133
+ overrideDark: overrides.dark[name] ?? '',
134
+ }
135
+ }),
136
+ customCss: record?.customCss ?? '',
137
+ customised: record !== null,
138
+ updatedAt: record?.updatedAt ?? null,
139
+ }
140
+ }
141
+
142
+ export function themeTokens(
143
+ key: string,
144
+ ): { light: Readonly<Record<string, string>>; dark: Readonly<Record<string, string>> } | null {
145
+ const installed = forumConfig.themes[key]
146
+ return installed === undefined
147
+ ? null
148
+ : { light: installed.tokens.light, dark: installed.tokens.dark }
149
+ }
150
+
151
+ export function themeTitle(key: string): string | null {
152
+ return forumConfig.themes[key]?.title ?? null
153
+ }
154
+
155
+ export function isBuildTheme(key: string): boolean {
156
+ return key === buildThemeKey
157
+ }
158
+
159
+ export interface SampleSurface {
160
+ readonly background: string
161
+ readonly foreground: string
162
+ }
163
+
164
+ export async function boardSampleSurfaces(): Promise<{
165
+ readonly light: SampleSurface
166
+ readonly dark: SampleSurface
167
+ }> {
168
+ const listing = await themeListing().catch(() => [])
169
+ const key = listing.find((entry) => entry.isDefault)?.key ?? buildThemeKey
170
+ const theme = forumConfig.themes[key] ?? forumConfig.themes[buildThemeKey]
171
+
172
+ const light = theme?.tokens.light ?? {}
173
+ const dark = theme?.tokens.dark ?? {}
174
+
175
+ let overrides: TokenOverrides = { light: {}, dark: {} }
176
+ if (theme !== undefined) {
177
+ const record = await themeAdminRepository()
178
+ ?.read(key)
179
+ .catch(() => null)
180
+ try {
181
+ overrides = validateTokenOverrides(theme.tokens, record?.tokenOverrides)
182
+ } catch {
183
+ /* ignore */
184
+ }
185
+ }
186
+
187
+ const pick = (
188
+ shipped: Readonly<Record<string, string>>,
189
+ override: Readonly<Record<string, string>>,
190
+ name: string,
191
+ ): string => override[name] ?? shipped[name] ?? ''
192
+
193
+ return {
194
+ light: {
195
+ background: pick(light, overrides.light, 'card'),
196
+ foreground: pick(light, overrides.light, 'card-foreground'),
197
+ },
198
+ dark: {
199
+ background: pick(dark, overrides.dark, 'card'),
200
+ foreground: pick(dark, overrides.dark, 'card-foreground'),
201
+ },
202
+ }
203
+ }
@@ -0,0 +1,89 @@
1
+ import 'server-only'
2
+
3
+ import forumConfig from '@board/config'
4
+ import { cache } from 'react'
5
+
6
+ import { CacheTags, cachedGlobal, env } from '@meith/core'
7
+ import { getDb, PostgresThemeRepository, type ThemeRuntimeState } from '@meith/db'
8
+ import { drivers } from '@meith/drivers'
9
+ import { DARK_TOKENS as DEFAULT_DARK, LIGHT_TOKENS as DEFAULT_LIGHT } from '@meith/theme-default'
10
+
11
+ import {
12
+ type BoardTheme,
13
+ effectiveTokens,
14
+ renderBoardStyle,
15
+ type ThemeRuntimeStyle,
16
+ type ThemeTokens,
17
+ } from './theme-style'
18
+
19
+ export interface ThemeChoice {
20
+ readonly key: string
21
+ readonly title: string
22
+ }
23
+
24
+ export interface BoardThemeStyle extends ThemeRuntimeStyle {
25
+ readonly choices: readonly ThemeChoice[]
26
+ readonly defaultKey: string
27
+ readonly defaultTokens: ThemeTokens
28
+ }
29
+
30
+ const registered = Object.values(forumConfig.themes)
31
+ const buildTheme = forumConfig.themes[forumConfig.defaultTheme]!
32
+
33
+ const COMPILED_TOKENS = { light: DEFAULT_LIGHT, dark: DEFAULT_DARK }
34
+
35
+ function shippedStyle(): BoardThemeStyle {
36
+ return composeBoard([])
37
+ }
38
+
39
+ function composeBoard(rows: readonly ThemeRuntimeState[]): BoardThemeStyle {
40
+ const byKey = new Map(rows.map((row) => [row.key, row]))
41
+
42
+ const enabled = registered.filter((theme) => byKey.get(theme.key)?.enabled !== false)
43
+
44
+ if (!enabled.some((theme) => theme.key === buildTheme.key)) enabled.unshift(buildTheme)
45
+
46
+ const claimed = registered.find((theme) => byKey.get(theme.key)?.isDefault === true)
47
+ const defaultKey =
48
+ claimed !== undefined && enabled.includes(claimed) ? claimed.key : buildTheme.key
49
+
50
+ const themes: BoardTheme[] = enabled.map((theme) => ({
51
+ key: theme.key,
52
+ tokens: theme.tokens,
53
+ overrides: byKey.get(theme.key)?.tokenOverrides,
54
+ customCss: byKey.get(theme.key)?.customCss ?? null,
55
+ }))
56
+
57
+ const active = themes.find((theme) => theme.key === defaultKey)
58
+
59
+ return {
60
+ ...renderBoardStyle({ themes, defaultKey, baseline: COMPILED_TOKENS }),
61
+ choices: enabled.map((theme) => ({ key: theme.key, title: theme.title })),
62
+ defaultKey,
63
+ defaultTokens: active === undefined ? COMPILED_TOKENS : effectiveTokens(active),
64
+ }
65
+ }
66
+
67
+ const TTL_SECONDS = 60
68
+
69
+ function loadPostgresBoardStyle(): Promise<BoardThemeStyle> {
70
+ return cachedGlobal(
71
+ drivers().cache,
72
+ {
73
+ key: ['board-theme-style'],
74
+ tags: registered.map((theme) => CacheTags.theme(theme.key)),
75
+ revalidate: TTL_SECONDS,
76
+ },
77
+ async () => composeBoard(await new PostgresThemeRepository(getDb()).listRuntime()),
78
+ )
79
+ }
80
+
81
+ export const getBoardThemeStyle = cache(async (): Promise<BoardThemeStyle> => {
82
+ if (env.DATA_SOURCE === 'fixture') return shippedStyle()
83
+
84
+ return loadPostgresBoardStyle().catch(() => shippedStyle())
85
+ })
86
+
87
+ export async function getThemeRuntimeStyle(): Promise<ThemeRuntimeStyle> {
88
+ return getBoardThemeStyle()
89
+ }
@@ -0,0 +1,279 @@
1
+ import { colourToHex } from '@meith/theme-kit'
2
+
3
+ export interface ThemeTokens {
4
+ readonly light: Readonly<Record<string, string>>
5
+ readonly dark: Readonly<Record<string, string>>
6
+ }
7
+
8
+ export interface ThemeRuntimeStyle {
9
+ readonly css: string
10
+ readonly browserThemeColor: { readonly light: string; readonly dark: string }
11
+ }
12
+
13
+ export interface TokenOverrides {
14
+ readonly light: Readonly<Record<string, string>>
15
+ readonly dark: Readonly<Record<string, string>>
16
+ }
17
+
18
+ export interface BoardTheme {
19
+ readonly key: string
20
+ readonly tokens: ThemeTokens
21
+ readonly overrides: unknown
22
+ readonly customCss: string | null
23
+ }
24
+
25
+ export type ColourScheme = 'light' | 'dark'
26
+
27
+ export const COLOUR_SCHEMES: readonly ColourScheme[] = ['light', 'dark']
28
+
29
+ const MAX_CUSTOM_CSS_LENGTH = 100_000
30
+
31
+ const THEME_KEY = /^[a-z0-9][a-z0-9_-]*$/i
32
+
33
+ function isRecord(value: unknown): value is Record<string, unknown> {
34
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
35
+ }
36
+
37
+ export function assertSafeCssValue(name: string, value: string): void {
38
+ if (
39
+ value.length === 0 ||
40
+ value.length > 256 ||
41
+ /[;{}<>]/.test(value) ||
42
+ !/^[a-zA-Z0-9#%(),.\s'"/+*=-]+$/.test(value)
43
+ ) {
44
+ throw new Error(`Theme ${name} has an unsafe CSS value.`)
45
+ }
46
+ }
47
+
48
+ function assertSafeThemeKey(key: string): void {
49
+ if (!THEME_KEY.test(key)) {
50
+ throw new Error(`Theme key "${key}" cannot be used in a stylesheet selector.`)
51
+ }
52
+ }
53
+
54
+ function isSchemeShaped(raw: Record<string, unknown>): boolean {
55
+ const keys = Object.keys(raw)
56
+ return (
57
+ keys.every((key) => key === 'light' || key === 'dark') &&
58
+ keys.every((key) => isRecord(raw[key]))
59
+ )
60
+ }
61
+
62
+ function validateScheme(tokens: ThemeTokens, raw: Record<string, unknown>): Record<string, string> {
63
+ const overrides: Record<string, string> = {}
64
+ for (const [name, value] of Object.entries(raw)) {
65
+ if (!(name in tokens.light) || !(name in tokens.dark)) {
66
+ throw new Error(`Theme token "${name}" is not declared by this theme.`)
67
+ }
68
+ if (typeof value !== 'string') {
69
+ throw new Error(`Theme token "${name}" must have a string value.`)
70
+ }
71
+ assertSafeCssValue(`token "${name}"`, value)
72
+ if (name === 'background' && colorToHex(value) === null) {
73
+ throw new Error('Theme token "background" must be an sRGB hex or OKLCH colour.')
74
+ }
75
+ overrides[name] = value
76
+ }
77
+ return overrides
78
+ }
79
+
80
+ export function validateTokenOverrides(tokens: ThemeTokens, raw: unknown): TokenOverrides {
81
+ if (raw === undefined || raw === null) return { light: {}, dark: {} }
82
+ if (!isRecord(raw)) throw new Error('Theme token overrides must be an object.')
83
+
84
+ if (isSchemeShaped(raw)) {
85
+ return {
86
+ light: validateScheme(tokens, isRecord(raw.light) ? raw.light : {}),
87
+ dark: validateScheme(tokens, isRecord(raw.dark) ? raw.dark : {}),
88
+ }
89
+ }
90
+
91
+ const both = validateScheme(tokens, raw)
92
+ return { light: both, dark: { ...both } }
93
+ }
94
+
95
+ export function validateCustomCss(value: string | null): string | null {
96
+ if (value === null || value.trim() === '') return null
97
+ if (
98
+ value.length > MAX_CUSTOM_CSS_LENGTH ||
99
+ /<\/style|@import|url\s*\(|expression\s*\(/i.test(value)
100
+ ) {
101
+ throw new Error('Theme custom CSS contains an unsafe construct.')
102
+ }
103
+ return value
104
+ }
105
+
106
+ export const colorToHex = colourToHex
107
+
108
+ function declarations(values: Readonly<Record<string, string>>): string {
109
+ return Object.entries(values)
110
+ .map(([name, value]) => `--${name}:${value};`)
111
+ .join('')
112
+ }
113
+
114
+ export function effectiveTokens(theme: BoardTheme): ThemeTokens {
115
+ const overrides = validateTokenOverrides(theme.tokens, theme.overrides)
116
+ return {
117
+ light: { ...theme.tokens.light, ...overrides.light },
118
+ dark: { ...theme.tokens.dark, ...overrides.dark },
119
+ }
120
+ }
121
+
122
+ function differing(
123
+ values: Readonly<Record<string, string>>,
124
+ against: Readonly<Record<string, string>>,
125
+ ): Record<string, string> {
126
+ const out: Record<string, string> = {}
127
+ for (const [name, value] of Object.entries(values)) {
128
+ if (against[name] !== value) {
129
+ assertSafeCssValue(`token "${name}"`, value)
130
+ out[name] = value
131
+ }
132
+ }
133
+ return out
134
+ }
135
+
136
+ function restatedForDark(
137
+ light: Readonly<Record<string, string>>,
138
+ dark: Record<string, string>,
139
+ darkValues: Readonly<Record<string, string>>,
140
+ ): Record<string, string> {
141
+ const out = { ...dark }
142
+
143
+ for (const name of Object.keys(light)) {
144
+ if (name in out) continue
145
+ const value = darkValues[name]
146
+ if (value === undefined) continue
147
+ assertSafeCssValue(`token "${name}"`, value)
148
+ out[name] = value
149
+ }
150
+
151
+ return out
152
+ }
153
+
154
+ function schemeBlocks(
155
+ light: Record<string, string>,
156
+ dark: Record<string, string>,
157
+ selector: { light: string; dark: string; media: string },
158
+ ): string {
159
+ const lightCss = declarations(light)
160
+ const darkCss = declarations(dark)
161
+
162
+ return (
163
+ (lightCss === '' ? '' : `${selector.light}{${lightCss}}`) +
164
+ (darkCss === ''
165
+ ? ''
166
+ : `${selector.dark}{${darkCss}}` +
167
+ `@media (prefers-color-scheme: dark){${selector.media}{${darkCss}}}`)
168
+ )
169
+ }
170
+
171
+ export function renderBoardStyle(input: {
172
+ readonly themes: readonly BoardTheme[]
173
+ readonly defaultKey: string
174
+ readonly baseline: ThemeTokens
175
+ }): ThemeRuntimeStyle {
176
+ const active = input.themes.find((theme) => theme.key === input.defaultKey)
177
+ if (active === undefined) {
178
+ throw new Error(`Theme "${input.defaultKey}" is not among the enabled themes.`)
179
+ }
180
+
181
+ const activeTokens = effectiveTokens(active)
182
+
183
+ const activeLight = differing(activeTokens.light, input.baseline.light)
184
+
185
+ let css = schemeBlocks(
186
+ activeLight,
187
+ restatedForDark(
188
+ activeLight,
189
+ differing(activeTokens.dark, input.baseline.dark),
190
+ activeTokens.dark,
191
+ ),
192
+ { light: ':root', dark: '.dark', media: ':root:not(.light)' },
193
+ )
194
+ css += validateCustomCss(active.customCss) ?? ''
195
+
196
+ for (const theme of input.themes) {
197
+ if (theme.key === active.key) continue
198
+ assertSafeThemeKey(theme.key)
199
+
200
+ const tokens = effectiveTokens(theme)
201
+ const scope = `[data-theme="${theme.key}"]`
202
+ const light = differing(tokens.light, activeTokens.light)
203
+
204
+ css += schemeBlocks(
205
+ light,
206
+ restatedForDark(light, differing(tokens.dark, activeTokens.dark), tokens.dark),
207
+ {
208
+ light: `:root${scope}`,
209
+ dark: `.dark${scope}`,
210
+ media: `:root${scope}:not(.light)`,
211
+ },
212
+ )
213
+
214
+ const custom = validateCustomCss(theme.customCss)
215
+ if (custom !== null) css += `:root${scope}{${custom}}`
216
+ }
217
+
218
+ const light = colorToHex(activeTokens.light.background ?? '')
219
+ const dark = colorToHex(activeTokens.dark.background ?? '')
220
+ if (light === null || dark === null) {
221
+ throw new Error('Theme background tokens must be sRGB hex or OKLCH colours.')
222
+ }
223
+
224
+ return { css, browserThemeColor: { light, dark } }
225
+ }
226
+
227
+ export function renderThemeStyle(
228
+ tokens: ThemeTokens,
229
+ rawOverrides: unknown,
230
+ customCss: string | null,
231
+ baseline: ThemeTokens = tokens,
232
+ ): ThemeRuntimeStyle {
233
+ return renderBoardStyle({
234
+ themes: [{ key: 'default', tokens, overrides: rawOverrides, customCss }],
235
+ defaultKey: 'default',
236
+ baseline,
237
+ })
238
+ }
239
+
240
+ export interface GroupNameColour {
241
+ readonly groupId: number
242
+ readonly light: string | null
243
+ readonly dark: string | null
244
+ }
245
+
246
+ export function groupNameClass(groupId: number): string {
247
+ return `gname-${groupId}`
248
+ }
249
+
250
+ export function renderGroupNameStyle(groups: readonly GroupNameColour[]): string {
251
+ if (groups.length === 0) return ''
252
+
253
+ const block = (pick: (group: GroupNameColour) => string | null, prefix: string): string =>
254
+ groups
255
+ .map((group) => {
256
+ const colour = pick(group)
257
+ if (colour === null) return ''
258
+ assertSafeCssValue(`group ${group.groupId} colour`, colour)
259
+ const selector = `.${groupNameClass(group.groupId)}`
260
+ return `${prefix}${selector}${selector}{color:${colour};}`
261
+ })
262
+ .join('')
263
+
264
+ const scheme = (
265
+ pick: (group: GroupNameColour) => string | null,
266
+ chosen: ColourScheme,
267
+ other: ColourScheme,
268
+ ): string => {
269
+ const chose = block(pick, `.${chosen} `)
270
+ if (chose === '') return ''
271
+
272
+ const followsTheSystem = block(pick, `:root:not(.${other}) `)
273
+ return `${chose}@media (prefers-color-scheme: ${chosen}){${followsTheSystem}}`
274
+ }
275
+
276
+ return (
277
+ scheme((group) => group.light, 'light', 'dark') + scheme((group) => group.dark, 'dark', 'light')
278
+ )
279
+ }
@@ -0,0 +1,52 @@
1
+ import 'server-only'
2
+
3
+ import forumConfig from '@board/config'
4
+ import { cookies } from 'next/headers'
5
+ import { cache } from 'react'
6
+
7
+ import { assertThemeContract, type ResolvedTheme, resolveTheme } from '@meith/theme-kit'
8
+
9
+ import {
10
+ type ColourSchemePreference,
11
+ isColourScheme,
12
+ SCHEME_COOKIE,
13
+ THEME_COOKIE,
14
+ } from '@/view/theme-preference'
15
+
16
+ import { getBoardThemeStyle } from './theme-runtime'
17
+
18
+ const RESOLVED: ReadonlyMap<string, ResolvedTheme> = new Map(
19
+ Object.values(forumConfig.themes)
20
+ .filter((theme) => theme.theme !== undefined)
21
+ .map((theme) => [theme.key, resolveTheme(theme.theme!)]),
22
+ )
23
+
24
+ export const buildTheme: ResolvedTheme = RESOLVED.get(forumConfig.defaultTheme)!
25
+
26
+ if (buildTheme === undefined) {
27
+ throw new Error(
28
+ `community.config.ts: defaultTheme "${forumConfig.defaultTheme}" fills no slots, so this ` +
29
+ 'board cannot render a page. A token-only theme is legitimate as an alternate, ' +
30
+ 'never as the theme a build is made of.',
31
+ )
32
+ }
33
+
34
+ for (const theme of RESOLVED.values()) assertThemeContract(theme)
35
+
36
+ export const currentThemeKey = cache(async (): Promise<string> => {
37
+ const { choices, defaultKey } = await getBoardThemeStyle()
38
+ const chosen = (await cookies()).get(THEME_COOKIE)?.value
39
+
40
+ return chosen !== undefined && choices.some((choice) => choice.key === chosen)
41
+ ? chosen
42
+ : defaultKey
43
+ })
44
+
45
+ export const currentTheme = cache(async (): Promise<ResolvedTheme> => {
46
+ return RESOLVED.get(await currentThemeKey()) ?? buildTheme
47
+ })
48
+
49
+ export const currentColourScheme = cache(async (): Promise<ColourSchemePreference> => {
50
+ const chosen = (await cookies()).get(SCHEME_COOKIE)?.value
51
+ return isColourScheme(chosen) ? chosen : 'system'
52
+ })