@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,191 @@
1
+ import type { PaginationModel } from '@meith/theme-kit'
2
+
3
+ export const TRAIL_PARAM = 'seen'
4
+
5
+ export const TRAIL_LIMIT = 40
6
+
7
+ const TRAIL_SEPARATOR = '~'
8
+
9
+ const WINDOW = 3
10
+
11
+ export type QueryParams = Readonly<Record<string, string | string[] | undefined>>
12
+
13
+ export type Cursor = Readonly<Record<string, string>>
14
+
15
+ export interface PagerInput {
16
+ readonly path: string
17
+ readonly params: QueryParams
18
+ /**
19
+ * The query parameters the cursor owns. They are rewritten on every step and
20
+ * never carried forward from the current address, which is what keeps a
21
+ * two-part cursor — a rank and an id — from being half of one page and half
22
+ * of the next.
23
+ */
24
+ readonly cursorParams: readonly string[]
25
+ readonly nextCursor: Cursor | null
26
+ readonly pageSize?: number
27
+ readonly total?: number | null
28
+ }
29
+
30
+ export const PAGE_PARAM = 'page'
31
+
32
+ const NEIGHBOURS = 2
33
+
34
+ export interface OffsetPagerInput {
35
+ readonly path: string
36
+ readonly params: QueryParams
37
+ readonly pageParam?: string
38
+ readonly page: number
39
+ readonly pageSize: number
40
+ readonly total: number
41
+ }
42
+
43
+ export function one(params: QueryParams, key: string): string | undefined {
44
+ const raw = params[key]
45
+ const value = Array.isArray(raw) ? raw[0] : raw
46
+ return value === '' ? undefined : value
47
+ }
48
+
49
+ function encodeCursor(cursor: Cursor): string {
50
+ return new URLSearchParams(Object.entries(cursor)).toString()
51
+ }
52
+
53
+ function decodeCursor(entry: string): Cursor {
54
+ return Object.fromEntries(new URLSearchParams(entry))
55
+ }
56
+
57
+ export function readTrail(params: QueryParams): Cursor[] {
58
+ const raw = one(params, TRAIL_PARAM)
59
+ if (raw === undefined) return []
60
+
61
+ return raw
62
+ .split(TRAIL_SEPARATOR)
63
+ .filter((entry) => entry !== '')
64
+ .slice(-TRAIL_LIMIT)
65
+ .map(decodeCursor)
66
+ }
67
+
68
+ function href(input: PagerInput, cursor: Cursor | null, trail: readonly Cursor[]): string {
69
+ const query = new URLSearchParams()
70
+
71
+ for (const [key, value] of Object.entries(input.params)) {
72
+ if (key === TRAIL_PARAM || input.cursorParams.includes(key)) continue
73
+ const text = Array.isArray(value) ? value[0] : value
74
+ if (text !== undefined && text !== '') query.set(key, text)
75
+ }
76
+
77
+ if (cursor !== null) {
78
+ for (const [key, value] of Object.entries(cursor)) query.set(key, value)
79
+ }
80
+
81
+ if (trail.length > 0) {
82
+ query.set(TRAIL_PARAM, trail.map(encodeCursor).join(TRAIL_SEPARATOR))
83
+ }
84
+
85
+ const search = query.toString()
86
+ return search === '' ? input.path : `${input.path}?${search}`
87
+ }
88
+
89
+ function pageHref(input: PagerInput, trail: readonly Cursor[], page: number): string {
90
+ if (page <= 1) return href(input, null, [])
91
+ return href(input, trail[page - 2] ?? null, trail.slice(0, page - 1))
92
+ }
93
+
94
+ export function buildPager(input: PagerInput): PaginationModel {
95
+ const trail = readTrail(input.params)
96
+ const page = trail.length + 1
97
+
98
+ const size = input.pageSize ?? 0
99
+ const total = input.total ?? null
100
+ const exact = total !== null && size > 0
101
+
102
+ const pageCount = exact
103
+ ? Math.max(1, Math.ceil(total / size))
104
+ : page + (input.nextCursor === null ? 0 : 1)
105
+
106
+ const numbers = new Set<number>([1, page])
107
+ for (let step = 1; step <= WINDOW; step += 1) {
108
+ if (page - step > 1) numbers.add(page - step)
109
+ }
110
+
111
+ const pages = [...numbers]
112
+ .sort((a, b) => a - b)
113
+ .map((number) => ({
114
+ page: number,
115
+ href: pageHref(input, trail, number),
116
+ isCurrent: number === page,
117
+ }))
118
+
119
+ return {
120
+ page,
121
+ pageCount,
122
+ pageCountIsExact: exact,
123
+ pages,
124
+ previousHref: page === 1 ? null : pageHref(input, trail, page - 1),
125
+ nextHref:
126
+ input.nextCursor === null
127
+ ? null
128
+ : href(input, input.nextCursor, [...trail, input.nextCursor].slice(-TRAIL_LIMIT)),
129
+ }
130
+ }
131
+
132
+ export function readPage(params: QueryParams, pageParam: string = PAGE_PARAM): number {
133
+ const raw = one(params, pageParam)
134
+ if (raw === undefined) return 1
135
+
136
+ const page = Number(raw)
137
+ return Number.isSafeInteger(page) && page >= 1 ? page : 1
138
+ }
139
+
140
+ export function offsetOf(page: number, pageSize: number): number {
141
+ return (Math.max(page, 1) - 1) * pageSize
142
+ }
143
+
144
+ export function pageCountOf(total: number, pageSize: number): number {
145
+ return Math.max(1, Math.ceil(Math.max(total, 0) / Math.max(pageSize, 1)))
146
+ }
147
+
148
+ function numberedHref(input: OffsetPagerInput, page: number): string {
149
+ const pageParam = input.pageParam ?? PAGE_PARAM
150
+ const query = new URLSearchParams()
151
+
152
+ for (const [key, value] of Object.entries(input.params)) {
153
+ if (key === pageParam || key === TRAIL_PARAM) continue
154
+ const text = Array.isArray(value) ? value[0] : value
155
+ if (text !== undefined && text !== '') query.set(key, text)
156
+ }
157
+
158
+ if (page > 1) query.set(pageParam, String(page))
159
+
160
+ const search = query.toString()
161
+ return search === '' ? input.path : `${input.path}?${search}`
162
+ }
163
+
164
+ export function buildOffsetPager(input: OffsetPagerInput): PaginationModel {
165
+ const pageCount = pageCountOf(input.total, input.pageSize)
166
+ const page = Math.max(input.page, 1)
167
+
168
+ const anchor = Math.min(page, pageCount)
169
+
170
+ const numbers = new Set<number>([1, pageCount])
171
+ if (page <= pageCount) numbers.add(page)
172
+ for (let step = 1; step <= NEIGHBOURS; step += 1) {
173
+ if (anchor - step >= 1) numbers.add(anchor - step)
174
+ if (anchor + step <= pageCount) numbers.add(anchor + step)
175
+ }
176
+
177
+ return {
178
+ page,
179
+ pageCount,
180
+ pageCountIsExact: true,
181
+ pages: [...numbers]
182
+ .sort((a, b) => a - b)
183
+ .map((number) => ({
184
+ page: number,
185
+ href: numberedHref(input, number),
186
+ isCurrent: number === page,
187
+ })),
188
+ previousHref: page === 1 ? null : numberedHref(input, page > pageCount ? pageCount : page - 1),
189
+ nextHref: page >= pageCount ? null : numberedHref(input, page + 1),
190
+ }
191
+ }
@@ -0,0 +1,203 @@
1
+ import type { Translator } from '@meith/i18n'
2
+ import type {
3
+ PanelKind,
4
+ PanelNavCurrent,
5
+ PanelNavIcon,
6
+ PanelNavModel,
7
+ PanelNavSectionModel,
8
+ } from '@meith/theme-kit'
9
+
10
+ import { untranslated } from './time'
11
+
12
+ export interface PanelSubsection {
13
+ readonly href: string
14
+ readonly titleKey?: string
15
+ /** Set instead of `titleKey` where the name is data, such as a plugin's. */
16
+ readonly titleText?: string
17
+ readonly record?: boolean
18
+ }
19
+
20
+ export interface PanelSection {
21
+ readonly href: string
22
+ readonly titleKey?: string
23
+ readonly titleText?: string
24
+ readonly blurbKey?: string
25
+ readonly blurbText?: string
26
+ readonly icon?: PanelNavIcon
27
+ readonly children?: readonly PanelSubsection[]
28
+ }
29
+
30
+ export function panelText(
31
+ t: Translator,
32
+ entry: { readonly titleKey?: string; readonly titleText?: string },
33
+ ): string
34
+ export function panelText(
35
+ t: Translator,
36
+ entry: { readonly blurbKey?: string; readonly blurbText?: string },
37
+ field: 'blurb',
38
+ ): string
39
+ export function panelText(
40
+ t: Translator,
41
+ entry: Readonly<Record<string, string | undefined>>,
42
+ field: 'title' | 'blurb' = 'title',
43
+ ): string {
44
+ const text = entry[`${field}Text`]
45
+ if (text !== undefined) return text
46
+
47
+ const key = entry[`${field}Key`]
48
+ return key === undefined ? '' : t.t(key)
49
+ }
50
+
51
+ export interface PanelSectionCopy {
52
+ readonly href: string
53
+ readonly title: string
54
+ readonly blurb: string
55
+ }
56
+
57
+ export function panelSectionCopy(nav: PanelNav, t: Translator): readonly PanelSectionCopy[] {
58
+ return nav.map((section) => ({
59
+ href: section.href,
60
+ title: panelText(t, section),
61
+ blurb: panelText(t, section, 'blurb'),
62
+ }))
63
+ }
64
+
65
+ export type PanelNav = readonly PanelSection[]
66
+
67
+ function parts(href: string): { path: string; params: URLSearchParams } {
68
+ const cut = href.indexOf('?')
69
+ if (cut === -1) return { path: href, params: new URLSearchParams() }
70
+ return { path: href.slice(0, cut), params: new URLSearchParams(href.slice(cut + 1)) }
71
+ }
72
+
73
+ function paramsAgree(here: URLSearchParams, wanted: URLSearchParams): boolean {
74
+ for (const [key, value] of wanted) {
75
+ if (here.get(key) !== value) return false
76
+ }
77
+ return true
78
+ }
79
+
80
+ export function isUnder(location: string, href: string): boolean {
81
+ const here = parts(location)
82
+ const want = parts(href)
83
+ if (here.path !== want.path && !here.path.startsWith(`${want.path}/`)) return false
84
+ return paramsAgree(here.params, want.params)
85
+ }
86
+
87
+ export function isHere(location: string, href: string): boolean {
88
+ const here = parts(location)
89
+ const want = parts(href)
90
+ return here.path === want.path && paramsAgree(here.params, want.params)
91
+ }
92
+
93
+ function longest(location: string, hrefs: readonly string[]): string | null {
94
+ let best: string | null = null
95
+ for (const href of hrefs) {
96
+ if (!isUnder(location, href)) continue
97
+ if (best === null || href.length > best.length) best = href
98
+ }
99
+ return best
100
+ }
101
+
102
+ export function sectionHrefIn(nav: PanelNav, location: string): string | null {
103
+ return longest(
104
+ location,
105
+ nav.map((section) => section.href),
106
+ )
107
+ }
108
+
109
+ export function deepestHrefIn(nav: PanelNav, location: string): string | null {
110
+ return longest(
111
+ location,
112
+ nav.flatMap((section) => [
113
+ section.href,
114
+ ...(section.children ?? []).map((child) => child.href),
115
+ ]),
116
+ )
117
+ }
118
+
119
+ export function flattenNav(nav: PanelNav): readonly (PanelSection | PanelSubsection)[] {
120
+ return nav.flatMap((section) => [section, ...(section.children ?? [])])
121
+ }
122
+
123
+ export function visibleChildren(
124
+ section: PanelSection,
125
+ deepest: string | null,
126
+ ): readonly PanelSubsection[] {
127
+ return (section.children ?? []).filter((child) => child.record !== true || child.href === deepest)
128
+ }
129
+
130
+ export type PanelCounts = Readonly<Record<string, number>>
131
+
132
+ export function countFor(counts: PanelCounts | undefined, href: string): number | null {
133
+ const count = counts?.[href] ?? 0
134
+ return count > 0 ? count : null
135
+ }
136
+
137
+ export function currentProps(
138
+ location: string,
139
+ href: string,
140
+ deepest: string | null,
141
+ ): { readonly 'aria-current'?: 'page' | 'true' } {
142
+ if (href !== deepest) return {}
143
+ return { 'aria-current': isHere(location, href) ? 'page' : 'true' }
144
+ }
145
+
146
+ function currentFor(
147
+ location: string,
148
+ href: string,
149
+ deepest: string | null,
150
+ ): PanelNavCurrent | null {
151
+ if (href !== deepest) return null
152
+ return isHere(location, href) ? 'here' : 'under'
153
+ }
154
+
155
+ export function buildPanelNavModel(input: {
156
+ readonly panel: PanelKind
157
+ readonly nav: PanelNav
158
+ readonly overviewHref: string
159
+ readonly location: string
160
+ readonly fallbackHref?: string | undefined
161
+ readonly counts?: PanelCounts | undefined
162
+ readonly label?: string
163
+ readonly t?: Translator | undefined
164
+ }): PanelNavModel {
165
+ const t = input.t ?? untranslated()
166
+ const fallback = input.fallbackHref ?? null
167
+ const open = sectionHrefIn(input.nav, input.location) ?? fallback
168
+ const deepest = deepestHrefIn(input.nav, input.location) ?? fallback
169
+
170
+ const sections: PanelNavSectionModel[] = input.nav.map((section) => {
171
+ const isOpen = open === section.href
172
+
173
+ return {
174
+ href: section.href,
175
+ title: panelText(t, section),
176
+ icon: section.icon ?? null,
177
+ count: countFor(input.counts, section.href),
178
+ current: currentFor(input.location, section.href, deepest),
179
+ isRecord: false,
180
+ isOpen,
181
+ isOverview: section.href === input.overviewHref,
182
+ children: isOpen
183
+ ? visibleChildren(section, deepest).map((child) => ({
184
+ href: child.href,
185
+ title: panelText(t, child),
186
+ icon: null,
187
+ count: countFor(input.counts, child.href),
188
+ current: currentFor(input.location, child.href, deepest),
189
+ isRecord: child.record === true,
190
+ }))
191
+ : [],
192
+ }
193
+ })
194
+
195
+ const here = flattenNav(input.nav).find((item) => item.href === deepest)
196
+
197
+ return {
198
+ panel: input.panel,
199
+ label: input.label ?? t.t('panel.sections'),
200
+ sections,
201
+ currentTitle: here === undefined ? null : panelText(t, here),
202
+ }
203
+ }
@@ -0,0 +1,42 @@
1
+ import { pluginAdminPath } from '@meith/plugin-kit'
2
+
3
+ import type { PanelSubsection } from './panel-nav'
4
+
5
+ export interface PluginPage {
6
+ readonly path: string
7
+ readonly title: string
8
+ readonly href: string
9
+ }
10
+
11
+ export interface PluginPanelTab {
12
+ readonly href: string
13
+ readonly label: string
14
+ readonly isCurrent: boolean
15
+ }
16
+
17
+ export const PLUGIN_SETTINGS_TAB = 'Settings'
18
+
19
+ export function pluginPanelTabs(input: {
20
+ readonly pluginKey: string
21
+ readonly pages: readonly PluginPage[]
22
+ readonly current: string | null
23
+ }): readonly PluginPanelTab[] {
24
+ if (input.pages.length === 0) return []
25
+
26
+ return [
27
+ {
28
+ href: pluginAdminPath(input.pluginKey, ''),
29
+ label: PLUGIN_SETTINGS_TAB,
30
+ isCurrent: input.current === null,
31
+ },
32
+ ...input.pages.map((page) => ({
33
+ href: page.href,
34
+ label: page.title,
35
+ isCurrent: page.path === input.current,
36
+ })),
37
+ ]
38
+ }
39
+
40
+ export function pluginNavChildren(pages: readonly PluginPage[]): readonly PanelSubsection[] {
41
+ return pages.map((page) => ({ href: page.href, titleText: page.title }))
42
+ }
@@ -0,0 +1,61 @@
1
+ import type { Translator } from '@meith/i18n'
2
+ import type { PostFormModel } from '@meith/theme-kit'
3
+
4
+ import { postLink } from './post-link'
5
+ import { untranslated } from './time'
6
+
7
+ export interface PostFormInput {
8
+ readonly forum: { readonly id: number; readonly title: string; readonly slug: string }
9
+ readonly errorMessage?: string | null
10
+ readonly t?: Translator
11
+ }
12
+
13
+ export function buildNewThreadView(input: PostFormInput): Omit<PostFormModel, 'regions'> {
14
+ const t = input.t ?? untranslated()
15
+
16
+ return {
17
+ mode: 'thread',
18
+ heading: t.t('postForm.newThread', { forum: input.forum.title }),
19
+ cancelHref: `/${input.forum.id}-${input.forum.slug}`,
20
+ cancelLabel: t.t('postForm.backToForum', { forum: input.forum.title }),
21
+ errorMessage: input.errorMessage ?? null,
22
+ }
23
+ }
24
+
25
+ export interface ReplyViewInput {
26
+ readonly thread: { readonly id: number; readonly title: string; readonly slug: string }
27
+ readonly errorMessage?: string | null
28
+ readonly t?: Translator
29
+ }
30
+
31
+ export function buildReplyView(input: ReplyViewInput): Omit<PostFormModel, 'regions'> {
32
+ const t = input.t ?? untranslated()
33
+
34
+ return {
35
+ mode: 'reply',
36
+ heading: t.t('postForm.reply', { thread: input.thread.title }),
37
+ cancelHref: `/thread/${input.thread.id}-${input.thread.slug}`,
38
+ cancelLabel: t.t('postForm.backToThread'),
39
+ errorMessage: input.errorMessage ?? null,
40
+ }
41
+ }
42
+
43
+ export interface EditViewInput {
44
+ readonly thread: { readonly id: number; readonly title: string; readonly slug: string }
45
+ readonly postId: number
46
+ readonly isDeleted: boolean
47
+ readonly errorMessage?: string | null
48
+ readonly t?: Translator
49
+ }
50
+
51
+ export function buildEditView(input: EditViewInput): Omit<PostFormModel, 'regions'> {
52
+ const t = input.t ?? untranslated()
53
+
54
+ return {
55
+ mode: 'edit',
56
+ heading: t.t(input.isDeleted ? 'postForm.deletedPost' : 'postForm.editPost'),
57
+ cancelHref: postLink(`/thread/${input.thread.id}-${input.thread.slug}`, input.postId),
58
+ cancelLabel: t.t('postForm.backToThread'),
59
+ errorMessage: input.errorMessage ?? null,
60
+ }
61
+ }
@@ -0,0 +1,29 @@
1
+ import type { PostLocation } from '@meith/posts'
2
+
3
+ export function postAnchor(number: number): string {
4
+ return `post-${number}`
5
+ }
6
+
7
+ export function postLink(threadHref: string, postId: number): string {
8
+ const path = threadHref.split('#')[0] ?? ''
9
+ return `${path}${path.includes('?') ? '&' : '?'}post=${postId}`
10
+ }
11
+
12
+ export function locatedHref(
13
+ path: string,
14
+ query: Record<string, string | readonly string[] | undefined>,
15
+ location: PostLocation,
16
+ ): string {
17
+ const params = new URLSearchParams()
18
+ for (const [key, value] of Object.entries(query)) {
19
+ if (value === undefined || key === 'post' || key === 'after' || key === 'page') continue
20
+ for (const one of typeof value === 'string' ? [value] : value) params.append(key, one)
21
+ }
22
+ if (location.afterId !== null) {
23
+ params.set('after', String(location.afterId))
24
+ params.set('page', String(location.page))
25
+ }
26
+
27
+ const search = params.toString()
28
+ return `${path}${search === '' ? '' : `?${search}`}#${postAnchor(location.number)}`
29
+ }
@@ -0,0 +1,99 @@
1
+ import type { Translator } from '@meith/i18n'
2
+ import type { BoardStatsModel, OnlineMemberModel, WhoIsOnlineModel } from '@meith/theme-kit'
3
+
4
+ import { count } from './count'
5
+ import { type MemberIdentity, nameClassOf } from './member-identity'
6
+ import { memberHref } from './member-profile'
7
+ import { formatTime, untranslated } from './time'
8
+
9
+ export interface OnlineRow {
10
+ readonly userId: number
11
+ readonly username: string
12
+ readonly invisible: boolean
13
+ readonly lastSeenAt: Date
14
+ readonly forumId: number | null
15
+ readonly forumTitle: string | null
16
+ readonly threadId: number | null
17
+ readonly threadTitle: string | null
18
+ readonly threadSlug: string | null
19
+ }
20
+
21
+ export interface OnlineInput {
22
+ readonly members: readonly OnlineRow[]
23
+ readonly guestCount: number
24
+ readonly recordCount: number
25
+ readonly recordAt: Date | null
26
+ readonly now: Date
27
+ readonly t?: Translator | undefined
28
+ readonly identities?: ReadonlyMap<number, MemberIdentity>
29
+ }
30
+
31
+ export function locationOf(
32
+ row: OnlineRow,
33
+ t: Translator = untranslated(),
34
+ ): { label: string; href: string | null } {
35
+ if (row.threadId !== null && row.threadTitle !== null) {
36
+ return {
37
+ label: t.t('presence.reading', { title: row.threadTitle }),
38
+ href: `/thread/${row.threadId}-${row.threadSlug ?? ''}`,
39
+ }
40
+ }
41
+
42
+ if (row.forumId !== null && row.forumTitle !== null) {
43
+ return { label: t.t('presence.viewing', { title: row.forumTitle }), href: `/${row.forumId}` }
44
+ }
45
+
46
+ return { label: t.t('presence.somewhere'), href: null }
47
+ }
48
+
49
+ export function buildWhoIsOnlineModel(input: OnlineInput): WhoIsOnlineModel {
50
+ const members: OnlineMemberModel[] = input.members.map((row) => ({
51
+ userId: row.userId,
52
+ username: row.username,
53
+ profileHref: memberHref(row.userId),
54
+ nameClass: nameClassOf(input.identities, row.userId),
55
+ location: locationOf(row, input.t ?? untranslated()),
56
+ isInvisible: row.invisible,
57
+ lastSeen: formatTime(row.lastSeenAt, input.now, input.t),
58
+ }))
59
+
60
+ return {
61
+ guestCount: count(input.guestCount, input.t),
62
+ members,
63
+ memberCount: count(members.length, input.t),
64
+ total: count(members.length + input.guestCount, input.t),
65
+ recordCount: count(input.recordCount, input.t),
66
+ recordAt: input.recordAt === null ? null : formatTime(input.recordAt, input.now, input.t),
67
+ fullListHref: '/online',
68
+ }
69
+ }
70
+
71
+ export interface StatsInput {
72
+ readonly threadCount: number
73
+ readonly postCount: number
74
+ readonly memberCount: number
75
+ readonly newestUserId: number | null
76
+ readonly newestUsername: string | null
77
+ readonly computedAt: Date | null
78
+ readonly now: Date
79
+ readonly t?: Translator | undefined
80
+ readonly identities?: ReadonlyMap<number, MemberIdentity>
81
+ }
82
+
83
+ export function buildBoardStatsModel(input: StatsInput): BoardStatsModel {
84
+ return {
85
+ threadCount: count(input.threadCount, input.t),
86
+ postCount: count(input.postCount, input.t),
87
+ memberCount: count(input.memberCount, input.t),
88
+ newestMember:
89
+ input.newestUsername === null
90
+ ? null
91
+ : {
92
+ userId: input.newestUserId,
93
+ username: input.newestUsername,
94
+ profileHref: input.newestUserId === null ? null : memberHref(input.newestUserId),
95
+ nameClass: nameClassOf(input.identities, input.newestUserId),
96
+ },
97
+ computedAt: input.computedAt === null ? null : formatTime(input.computedAt, input.now, input.t),
98
+ }
99
+ }
@@ -0,0 +1,52 @@
1
+ import type { PromotionRule } from '@meith/groups'
2
+
3
+ export interface PromotionRuleFormValues {
4
+ readonly id: number
5
+ readonly title: string
6
+ readonly enabled: boolean
7
+ readonly displayOrder: string
8
+ readonly minPostCount: string
9
+ readonly minReputation: string
10
+ readonly minDaysRegistered: string
11
+ readonly fromPrimaryGroupId: string
12
+ readonly toPrimaryGroupId: string
13
+ }
14
+
15
+ function field(value: number | null | undefined): string {
16
+ return value === null || value === undefined ? '' : String(value)
17
+ }
18
+
19
+ export function promotionRuleFormValues(rule: PromotionRule): PromotionRuleFormValues {
20
+ return {
21
+ id: rule.id,
22
+ title: rule.title,
23
+ enabled: rule.enabled,
24
+ displayOrder: String(rule.displayOrder),
25
+ minPostCount: field(rule.minPostCount),
26
+ minReputation: field(rule.minReputation),
27
+ minDaysRegistered: field(rule.minDaysRegistered),
28
+ fromPrimaryGroupId: field(rule.fromPrimaryGroupId),
29
+ toPrimaryGroupId: String(rule.toPrimaryGroupId),
30
+ }
31
+ }
32
+
33
+ export function promotionRuleSummary(
34
+ rule: PromotionRule,
35
+ groupTitle: (id: number | null) => string,
36
+ ): string {
37
+ const criteria: string[] = []
38
+ if (rule.minPostCount !== undefined) criteria.push(`${rule.minPostCount} posts`)
39
+ if (rule.minReputation !== undefined) criteria.push(`${rule.minReputation} reputation`)
40
+ if (rule.minDaysRegistered !== undefined) {
41
+ criteria.push(`${rule.minDaysRegistered} days registered`)
42
+ }
43
+
44
+ const from =
45
+ rule.fromPrimaryGroupId === null || rule.fromPrimaryGroupId === undefined
46
+ ? 'any group'
47
+ : groupTitle(rule.fromPrimaryGroupId)
48
+
49
+ const wants = criteria.length === 0 ? 'no criteria' : `at least ${criteria.join(', ')}`
50
+
51
+ return `${from} → ${groupTitle(rule.toPrimaryGroupId)} · ${wants}`
52
+ }
@@ -0,0 +1,21 @@
1
+ import type { RedirectNoticeModel } from '@meith/theme-kit'
2
+
3
+ export const REDIRECT_DELAY_SECONDS = 2
4
+
5
+ export function localHref(value: string | undefined): string {
6
+ if (value === undefined || !value.startsWith('/')) return '/'
7
+ const origin = 'https://forum.invalid'
8
+ const url = new URL(value, origin)
9
+ return url.origin === origin ? `${url.pathname}${url.search}${url.hash}` : '/'
10
+ }
11
+
12
+ export function buildRedirectNotice(
13
+ target: string | undefined,
14
+ message: string | undefined,
15
+ ): RedirectNoticeModel {
16
+ return {
17
+ targetHref: localHref(target),
18
+ message: message?.trim() || 'Continuing…',
19
+ delaySeconds: REDIRECT_DELAY_SECONDS,
20
+ }
21
+ }