@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,188 @@
1
+ export const NAVIGATION_NUDGES = ['up', 'down', 'in', 'out'] as const
2
+
3
+ export type NavigationNudge = (typeof NAVIGATION_NUDGES)[number]
4
+
5
+ export const NAVIGATION_MAX_DEPTH = 1
6
+
7
+ export interface NavigationOutlineRow {
8
+ readonly id: number
9
+ readonly parentId: number | null
10
+ readonly depth: number
11
+ }
12
+
13
+ export interface NavigationDropTarget {
14
+ readonly parentId: number | null
15
+ readonly afterId: number | null
16
+ }
17
+
18
+ export interface NavigationDropProjection extends NavigationDropTarget {
19
+ readonly index: number
20
+ readonly depth: number
21
+ }
22
+
23
+ export function isNavigationNudge(value: string): value is NavigationNudge {
24
+ return (NAVIGATION_NUDGES as readonly string[]).includes(value)
25
+ }
26
+
27
+ export function childrenOf<T extends NavigationOutlineRow>(
28
+ outline: readonly T[],
29
+ parentId: number | null,
30
+ ): T[] {
31
+ return outline.filter((row) => row.parentId === parentId)
32
+ }
33
+
34
+ export function hasChildren(outline: readonly NavigationOutlineRow[], id: number): boolean {
35
+ return outline.some((row) => row.parentId === id)
36
+ }
37
+
38
+ export function subtreeOf<T extends NavigationOutlineRow>(outline: readonly T[], id: number): T[] {
39
+ const start = outline.findIndex((row) => row.id === id)
40
+ if (start === -1) return []
41
+
42
+ const root = outline[start] as T
43
+ let end = start + 1
44
+ while (end < outline.length && (outline[end] as T).depth > root.depth) end += 1
45
+
46
+ return outline.slice(start, end)
47
+ }
48
+
49
+ export function withoutSubtree<T extends NavigationOutlineRow>(
50
+ outline: readonly T[],
51
+ id: number,
52
+ ): T[] {
53
+ const moving = new Set(subtreeOf(outline, id).map((row) => row.id))
54
+ return outline.filter((row) => !moving.has(row.id))
55
+ }
56
+
57
+ export function depthAllowedFor(outline: readonly NavigationOutlineRow[], id: number): number {
58
+ return hasChildren(outline, id) ? 0 : NAVIGATION_MAX_DEPTH
59
+ }
60
+
61
+ export function nudgeTarget(
62
+ outline: readonly NavigationOutlineRow[],
63
+ id: number,
64
+ nudge: NavigationNudge,
65
+ ): NavigationDropTarget | null {
66
+ const row = outline.find((entry) => entry.id === id)
67
+ if (row === undefined) return null
68
+
69
+ const siblings = childrenOf(outline, row.parentId)
70
+ const at = siblings.findIndex((entry) => entry.id === id)
71
+
72
+ if (nudge === 'up') {
73
+ if (at <= 0) return null
74
+ return { parentId: row.parentId, afterId: siblings[at - 2]?.id ?? null }
75
+ }
76
+
77
+ if (nudge === 'down') {
78
+ const next = siblings[at + 1]
79
+ if (next === undefined) return null
80
+ return { parentId: row.parentId, afterId: next.id }
81
+ }
82
+
83
+ if (nudge === 'in') {
84
+ const previous = siblings[at - 1]
85
+ if (previous === undefined || previous.depth >= NAVIGATION_MAX_DEPTH) return null
86
+ if (hasChildren(outline, id)) return null
87
+ return { parentId: previous.id, afterId: childrenOf(outline, previous.id).at(-1)?.id ?? null }
88
+ }
89
+
90
+ if (row.parentId === null) return null
91
+ return { parentId: null, afterId: row.parentId }
92
+ }
93
+
94
+ export function availableNudges(
95
+ outline: readonly NavigationOutlineRow[],
96
+ id: number,
97
+ ): Record<NavigationNudge, boolean> {
98
+ return {
99
+ up: nudgeTarget(outline, id, 'up') !== null,
100
+ down: nudgeTarget(outline, id, 'down') !== null,
101
+ in: nudgeTarget(outline, id, 'in') !== null,
102
+ out: nudgeTarget(outline, id, 'out') !== null,
103
+ }
104
+ }
105
+
106
+ export function projectDrop(
107
+ rows: readonly NavigationOutlineRow[],
108
+ index: number,
109
+ depth: number,
110
+ maxDepth: number = NAVIGATION_MAX_DEPTH,
111
+ ): NavigationDropProjection {
112
+ const at = Math.min(Math.max(index, 0), rows.length)
113
+ const previous = rows[at - 1]
114
+ const next = rows[at]
115
+
116
+ if (previous === undefined) return { index: at, depth: 0, parentId: null, afterId: null }
117
+
118
+ const deepest = Math.min(NAVIGATION_MAX_DEPTH, maxDepth)
119
+ const shallowest = Math.min(next?.depth ?? 0, deepest)
120
+ const settled = Math.min(Math.max(depth, shallowest), deepest)
121
+
122
+ if (settled > 0) {
123
+ return previous.depth === 0
124
+ ? { index: at, depth: settled, parentId: previous.id, afterId: null }
125
+ : { index: at, depth: settled, parentId: previous.parentId, afterId: previous.id }
126
+ }
127
+
128
+ for (let cursor = at - 1; cursor >= 0; cursor -= 1) {
129
+ const candidate = rows[cursor] as NavigationOutlineRow
130
+ if (candidate.depth === 0) return { index: at, depth: 0, parentId: null, afterId: candidate.id }
131
+ }
132
+
133
+ return { index: at, depth: 0, parentId: null, afterId: null }
134
+ }
135
+
136
+ export function projectionOf(
137
+ outline: readonly NavigationOutlineRow[],
138
+ id: number,
139
+ target: NavigationDropTarget,
140
+ ): NavigationDropProjection {
141
+ const rows = withoutSubtree(outline, id)
142
+ const parent = rows.find((row) => row.id === target.parentId)
143
+ const depth = target.parentId === null ? 0 : (parent?.depth ?? 0) + 1
144
+
145
+ if (target.afterId === null) {
146
+ const index = parent === undefined ? 0 : rows.indexOf(parent) + 1
147
+ return { ...target, index, depth }
148
+ }
149
+
150
+ const after = rows.find((row) => row.id === target.afterId)
151
+ if (after === undefined) return { ...target, index: rows.length, depth }
152
+
153
+ return { ...target, index: rows.indexOf(after) + subtreeOf(rows, after.id).length, depth }
154
+ }
155
+
156
+ export function applyDrop<T extends NavigationOutlineRow>(
157
+ outline: readonly T[],
158
+ id: number,
159
+ projection: NavigationDropProjection,
160
+ ): T[] {
161
+ const block = subtreeOf(outline, id)
162
+ if (block.length === 0) return [...outline]
163
+
164
+ const rest = withoutSubtree(outline, id)
165
+ const shift = projection.depth - (block[0] as T).depth
166
+
167
+ const moved = block.map((row, offset) => ({
168
+ ...row,
169
+ depth: row.depth + shift,
170
+ ...(offset === 0 ? { parentId: projection.parentId } : {}),
171
+ }))
172
+
173
+ return [...rest.slice(0, projection.index), ...moved, ...rest.slice(projection.index)]
174
+ }
175
+
176
+ export function isWhereItIs(
177
+ outline: readonly NavigationOutlineRow[],
178
+ id: number,
179
+ target: NavigationDropTarget,
180
+ ): boolean {
181
+ const row = outline.find((entry) => entry.id === id)
182
+ if (row === undefined || row.parentId !== target.parentId) return false
183
+
184
+ const siblings = childrenOf(outline, row.parentId)
185
+ const at = siblings.findIndex((entry) => entry.id === id)
186
+
187
+ return (siblings[at - 1]?.id ?? null) === target.afterId
188
+ }
@@ -0,0 +1,173 @@
1
+ import type { NavigationAudience, NavigationItemRow } from '@meith/db'
2
+ import type { Translator } from '@meith/i18n'
3
+ import type { LinkModel, ViewerModel } from '@meith/theme-kit'
4
+
5
+ import { untranslated } from './time'
6
+
7
+ export type { NavigationAudience }
8
+
9
+ export const NAVIGATION_AUDIENCE_MESSAGE_KEYS: Readonly<Record<NavigationAudience, string>> = {
10
+ all: 'adminNavigation.audience.all',
11
+ guests: 'adminNavigation.audience.guests',
12
+ members: 'adminNavigation.audience.members',
13
+ staff: 'adminNavigation.audience.staff',
14
+ }
15
+
16
+ export const NAVIGATION_AUDIENCE_VALUES = Object.keys(
17
+ NAVIGATION_AUDIENCE_MESSAGE_KEYS,
18
+ ) as readonly NavigationAudience[]
19
+
20
+ export interface BuiltInNavigationItem {
21
+ readonly key: string
22
+ readonly messageKey: string
23
+ readonly href: string
24
+ readonly audience: NavigationAudience
25
+ }
26
+
27
+ export const BUILT_IN_NAVIGATION: readonly BuiltInNavigationItem[] = [
28
+ { key: 'home', messageKey: 'nav.home', href: '/', audience: 'all' },
29
+ { key: 'new-posts', messageKey: 'nav.newPosts', href: '/discover/new', audience: 'all' },
30
+ {
31
+ key: 'unanswered',
32
+ messageKey: 'nav.unanswered',
33
+ href: '/discover/unanswered',
34
+ audience: 'all',
35
+ },
36
+ {
37
+ key: 'my-posts',
38
+ messageKey: 'nav.myPosts',
39
+ href: '/discover/participated',
40
+ audience: 'members',
41
+ },
42
+ { key: 'search', messageKey: 'nav.search', href: '/search', audience: 'all' },
43
+ { key: 'online', messageKey: 'nav.online', href: '/online', audience: 'all' },
44
+ ]
45
+
46
+ const SEARCH_KEY = 'search'
47
+
48
+ export type NavigationOutlineItem = NavigationItemRow & { readonly depth: number }
49
+
50
+ export function builtInNavigation(key: string | null): BuiltInNavigationItem | null {
51
+ if (key === null) return null
52
+ return BUILT_IN_NAVIGATION.find((item) => item.key === key) ?? null
53
+ }
54
+
55
+ export function defaultNavigationItems(): readonly NavigationItemRow[] {
56
+ return BUILT_IN_NAVIGATION.map((item, index) => ({
57
+ id: index + 1,
58
+ key: item.key,
59
+ parentId: null,
60
+ label: '',
61
+ href: item.href,
62
+ displayOrder: index * 10,
63
+ audience: item.audience,
64
+ newTab: false,
65
+ enabled: true,
66
+ visibleToGroups: [],
67
+ }))
68
+ }
69
+
70
+ /**
71
+ * What a plugin called an item, for the rows the board holds on its behalf.
72
+ * Keyed by the same `plugin.<plugin>.<item>` the row carries.
73
+ */
74
+ export interface NavigationName {
75
+ readonly label: string
76
+ readonly labelKey: string | null
77
+ readonly labelArgs?: Parameters<Translator['t']>[1] | null
78
+ }
79
+
80
+ export interface NavigationNames {
81
+ readonly [key: string]: NavigationName
82
+ }
83
+
84
+ export function navigationLabel(
85
+ item: Pick<NavigationItemRow, 'key' | 'label'>,
86
+ t: Translator = untranslated(),
87
+ names: NavigationNames = {},
88
+ ): string {
89
+ if (item.label.trim() !== '') return item.label
90
+
91
+ const builtIn = builtInNavigation(item.key)
92
+ if (builtIn !== null) return t.t(builtIn.messageKey)
93
+
94
+ const named = item.key === null ? undefined : names[item.key]
95
+ if (named === undefined) return ''
96
+ if (named.labelKey === null || !t.has(named.labelKey)) return named.label
97
+ return named.labelArgs === null || named.labelArgs === undefined
98
+ ? t.t(named.labelKey)
99
+ : t.t(named.labelKey, named.labelArgs)
100
+ }
101
+
102
+ function inOrder(rows: readonly NavigationItemRow[]): readonly NavigationItemRow[] {
103
+ return [...rows].sort((a, b) => a.displayOrder - b.displayOrder || a.id - b.id)
104
+ }
105
+
106
+ export function outlineOf(rows: readonly NavigationItemRow[]): NavigationOutlineItem[] {
107
+ const sorted = inOrder(rows)
108
+ const outline: NavigationOutlineItem[] = []
109
+
110
+ for (const top of sorted.filter((row) => row.parentId === null)) {
111
+ outline.push({ ...top, depth: 0 })
112
+ for (const child of sorted.filter((row) => row.parentId === top.id)) {
113
+ outline.push({ ...child, depth: 1 })
114
+ }
115
+ }
116
+
117
+ return outline
118
+ }
119
+
120
+ function isStaff(viewer: ViewerModel): boolean {
121
+ return viewer.canAccessAdminCp || viewer.canAccessModCp
122
+ }
123
+
124
+ export function audienceAdmits(audience: NavigationAudience, viewer: ViewerModel): boolean {
125
+ if (audience === 'guests') return viewer.isGuest
126
+ if (audience === 'members') return !viewer.isGuest
127
+ if (audience === 'staff') return isStaff(viewer)
128
+ return true
129
+ }
130
+
131
+ export interface NavigationOptions {
132
+ readonly searchEnabled?: boolean
133
+ readonly t?: Translator
134
+ readonly admits?: (item: NavigationItemRow) => boolean
135
+ readonly names?: NavigationNames
136
+ }
137
+
138
+ export function buildNavigation(
139
+ items: readonly NavigationItemRow[],
140
+ viewer: ViewerModel,
141
+ options: NavigationOptions = {},
142
+ ): readonly LinkModel[] {
143
+ const t = options.t ?? untranslated()
144
+ const admits = options.admits ?? (() => true)
145
+ const names = options.names ?? {}
146
+
147
+ const shown = (item: NavigationItemRow): boolean =>
148
+ item.enabled &&
149
+ !(item.key === SEARCH_KEY && options.searchEnabled === false) &&
150
+ audienceAdmits(item.audience, viewer) &&
151
+ admits(item) &&
152
+ navigationLabel(item, t, names) !== ''
153
+
154
+ const linkOf = (item: NavigationItemRow, submenu: readonly LinkModel[]): LinkModel => ({
155
+ label: navigationLabel(item, t, names),
156
+ href: item.href,
157
+ ...(item.newTab ? { newTab: true } : {}),
158
+ ...(submenu.length === 0 ? {} : { submenu }),
159
+ })
160
+
161
+ const sorted = inOrder(items)
162
+
163
+ return sorted
164
+ .filter((item) => item.parentId === null && shown(item))
165
+ .map((item) =>
166
+ linkOf(
167
+ item,
168
+ sorted
169
+ .filter((child) => child.parentId === item.id && shown(child))
170
+ .map((child) => linkOf(child, [])),
171
+ ),
172
+ )
173
+ }
@@ -0,0 +1,202 @@
1
+ import type { Translator } from '@meith/i18n'
2
+ import type { MessageListRow } from '@meith/messages'
3
+ import type { QueueItem, ReportRow } from '@meith/moderation'
4
+ import type { NotificationView } from '@meith/notifications'
5
+ import type { TimeModel } from '@meith/theme-kit'
6
+
7
+ import { copyFor } from './copy'
8
+ import { formatTime, untranslated } from './time'
9
+
10
+ export const NOTIFICATION_MENU_PREVIEW = 10
11
+
12
+ export type NotificationMenuTabKind = 'notifications' | 'messages' | 'mod'
13
+
14
+ export interface NotificationMenuRow {
15
+ readonly key: string
16
+ /** The id a mark-seen form submits, or `null` for a row that is acted on elsewhere. */
17
+ readonly seenId: number | null
18
+ readonly subject: string
19
+ readonly meta: string | null
20
+ readonly href: string | null
21
+ readonly at: TimeModel
22
+ readonly isUnread: boolean
23
+ /** A short category label shown before the subject, or `null`. */
24
+ readonly tag: string | null
25
+ }
26
+
27
+ export interface ModerationSummary {
28
+ readonly queue: readonly QueueItem[]
29
+ readonly reports: readonly ReportRow[]
30
+ readonly pending: number
31
+ readonly openReports: number
32
+ }
33
+
34
+ export interface NotificationMenuTab {
35
+ readonly kind: NotificationMenuTabKind
36
+ readonly label: string
37
+ readonly unread: number
38
+ readonly rows: readonly NotificationMenuRow[]
39
+ readonly allHref: string
40
+ readonly emptyLabel: string
41
+ }
42
+
43
+ export interface NotificationMenuModel {
44
+ readonly total: number
45
+ readonly tabs: readonly NotificationMenuTab[]
46
+ }
47
+
48
+ function notificationRow(row: NotificationView, now: Date, t: Translator): NotificationMenuRow {
49
+ return {
50
+ key: `notification-${row.id}`,
51
+ seenId: row.id,
52
+ subject: row.subject,
53
+ meta: row.body === '' ? null : firstLine(row.body),
54
+ href: row.href,
55
+ at: formatTime(row.updatedAt, now, t),
56
+ isUnread: !row.isRead,
57
+ tag: null,
58
+ }
59
+ }
60
+
61
+ function messageRow(row: MessageListRow, now: Date, t: Translator): NotificationMenuRow {
62
+ const from = row.counterparties[0] ?? null
63
+ const more = row.moreCounterparties
64
+ return {
65
+ key: `message-${row.copyId}`,
66
+ seenId: row.copyId,
67
+ subject: row.subject === '' ? t.t('board.notificationMenu.noSubject') : row.subject,
68
+ meta:
69
+ from === null
70
+ ? null
71
+ : more > 0
72
+ ? t.t('board.notificationMenu.fromMany', { name: from, count: more })
73
+ : t.t('board.notificationMenu.from', { name: from }),
74
+ href: `/messages/${row.messageId}`,
75
+ at: formatTime(row.sentAt, now, t),
76
+ isUnread: row.readAt === null && row.role !== 'author',
77
+ tag: null,
78
+ }
79
+ }
80
+
81
+ function queueRow(item: QueueItem, now: Date, t: Translator): NotificationMenuRow {
82
+ return {
83
+ key: `queue-${item.kind}-${item.id}`,
84
+ seenId: null,
85
+ subject: item.threadTitle,
86
+ meta: item.forumTitle,
87
+ href: '/moderation',
88
+ at: formatTime(item.createdAt, now, t),
89
+ isUnread: false,
90
+ tag: t.t('board.notificationMenu.mod.pending'),
91
+ }
92
+ }
93
+
94
+ function reportRow(report: ReportRow, now: Date, t: Translator): NotificationMenuRow {
95
+ return {
96
+ key: `report-${report.id}`,
97
+ seenId: null,
98
+ subject: report.targetLabel,
99
+ meta: firstLine(report.reason),
100
+ href: '/moderation/reports',
101
+ at: formatTime(report.createdAt, now, t),
102
+ isUnread: false,
103
+ tag: t.t('board.notificationMenu.mod.report'),
104
+ }
105
+ }
106
+
107
+ function firstLine(body: string): string {
108
+ const line = body.split('\n', 1)[0] ?? ''
109
+ return line.length > 140 ? `${line.slice(0, 139)}…` : line
110
+ }
111
+
112
+ export function buildNotificationMenuView(input: {
113
+ readonly notifications: readonly NotificationView[]
114
+ readonly notificationsUnread: number
115
+ readonly messages: readonly MessageListRow[]
116
+ readonly messagesUnread: number
117
+ readonly moderation: ModerationSummary | null
118
+ readonly now: Date
119
+ readonly t?: Translator
120
+ }): NotificationMenuModel {
121
+ const t = input.t ?? untranslated()
122
+
123
+ const notificationsTab: NotificationMenuTab = {
124
+ kind: 'notifications',
125
+ label: t.t('board.notificationMenu.tab.notifications'),
126
+ unread: input.notificationsUnread,
127
+ rows: input.notifications
128
+ .filter((row) => !row.isRead)
129
+ .slice(0, NOTIFICATION_MENU_PREVIEW)
130
+ .map((row) => notificationRow(row, input.now, t)),
131
+ allHref: '/notifications',
132
+ emptyLabel: t.t('board.notificationMenu.empty.notifications'),
133
+ }
134
+
135
+ const messagesTab: NotificationMenuTab = {
136
+ kind: 'messages',
137
+ label: t.t('board.notificationMenu.tab.messages'),
138
+ unread: input.messagesUnread,
139
+ rows: input.messages
140
+ .filter((row) => row.readAt === null && row.role !== 'author')
141
+ .slice(0, NOTIFICATION_MENU_PREVIEW)
142
+ .map((row) => messageRow(row, input.now, t)),
143
+ allHref: '/messages',
144
+ emptyLabel: t.t('board.notificationMenu.empty.messages'),
145
+ }
146
+
147
+ const tabs: NotificationMenuTab[] = [notificationsTab, messagesTab]
148
+
149
+ const moderationUnread =
150
+ input.moderation === null ? 0 : input.moderation.pending + input.moderation.openReports
151
+
152
+ if (input.moderation !== null) {
153
+ const rows = [
154
+ ...input.moderation.reports.map((report) => ({
155
+ at: report.createdAt,
156
+ row: reportRow(report, input.now, t),
157
+ })),
158
+ ...input.moderation.queue.map((item) => ({
159
+ at: item.createdAt,
160
+ row: queueRow(item, input.now, t),
161
+ })),
162
+ ]
163
+ .sort((a, b) => b.at.getTime() - a.at.getTime())
164
+ .slice(0, NOTIFICATION_MENU_PREVIEW)
165
+ .map((entry) => entry.row)
166
+
167
+ tabs.push({
168
+ kind: 'mod',
169
+ label: t.t('board.notificationMenu.tab.mod'),
170
+ unread: moderationUnread,
171
+ rows,
172
+ allHref: '/moderation',
173
+ emptyLabel: t.t('board.notificationMenu.empty.mod'),
174
+ })
175
+ }
176
+
177
+ return {
178
+ total: input.notificationsUnread + input.messagesUnread + moderationUnread,
179
+ tabs,
180
+ }
181
+ }
182
+
183
+ export function notificationMenuCopy(
184
+ t: Translator = untranslated(),
185
+ ): Readonly<Record<string, string>> {
186
+ return copyFor(
187
+ [
188
+ 'board.notificationMenu.trigger',
189
+ 'board.notificationMenu.heading',
190
+ 'board.notificationMenu.markSeen',
191
+ 'board.notificationMenu.markAllSeen',
192
+ 'board.notificationMenu.viewAll',
193
+ 'board.notificationMenu.close',
194
+ 'board.notificationMenu.unreadNotifications',
195
+ 'board.notificationMenu.unreadMessages',
196
+ 'board.notificationMenu.unreadMod',
197
+ 'board.notificationMenu.totalUnread',
198
+ 'form.working',
199
+ ],
200
+ t,
201
+ )
202
+ }
@@ -0,0 +1,84 @@
1
+ import type { Translator } from '@meith/i18n'
2
+ import type { NotificationPreferenceView, NotificationView } from '@meith/notifications'
3
+ import type { TimeModel } from '@meith/theme-kit'
4
+
5
+ import { formatTime, untranslated } from './time'
6
+
7
+ export interface NotificationRowView {
8
+ readonly id: number
9
+ readonly subject: string
10
+ readonly body: string
11
+ readonly href: string | null
12
+ readonly at: TimeModel
13
+ readonly isRead: boolean
14
+ readonly repeated: string | null
15
+ }
16
+
17
+ export interface NotificationCentreView {
18
+ readonly rows: readonly NotificationRowView[]
19
+ readonly unread: number
20
+ readonly nextHref: string | null
21
+ readonly preferencesHref: string
22
+ }
23
+
24
+ export function buildNotificationCentreView(input: {
25
+ readonly rows: readonly NotificationView[]
26
+ readonly unread: number
27
+ readonly nextCursor?: string | undefined
28
+ readonly now: Date
29
+ readonly t?: Translator
30
+ }): NotificationCentreView {
31
+ return {
32
+ rows: input.rows.map((row) => ({
33
+ id: row.id,
34
+ subject: row.subject,
35
+ body: row.body,
36
+ href: row.href,
37
+ at: formatTime(row.updatedAt, input.now, input.t),
38
+ isRead: row.isRead,
39
+ repeated: row.occurrences > 1 ? `Happened ${row.occurrences} times` : null,
40
+ })),
41
+ unread: input.unread,
42
+ nextHref:
43
+ input.nextCursor === undefined
44
+ ? null
45
+ : `/notifications?after=${encodeURIComponent(input.nextCursor)}`,
46
+ preferencesHref: '/notifications/preferences',
47
+ }
48
+ }
49
+
50
+ export interface PreferencesView {
51
+ readonly rows: readonly NotificationPreferenceView[]
52
+ readonly backHref: string
53
+ }
54
+
55
+ export function buildPreferencesView(
56
+ rows: readonly NotificationPreferenceView[],
57
+ translator: Translator = untranslated(),
58
+ ): PreferencesView {
59
+ return {
60
+ rows: rows.map((row) => ({
61
+ ...row,
62
+ title: translated(translator, row.titleKey, row.title),
63
+ description: translated(translator, row.descriptionKey, row.description),
64
+ })),
65
+ backHref: '/notifications',
66
+ }
67
+ }
68
+
69
+ function translated(translator: Translator, key: string | undefined, fallback: string): string {
70
+ return key !== undefined && translator.has(key) ? translator.t(key) : fallback
71
+ }
72
+
73
+ export function notificationNotice(
74
+ query: {
75
+ readonly read?: string | undefined
76
+ readonly saved?: string | undefined
77
+ },
78
+ t: Translator = untranslated(),
79
+ ): string | null {
80
+ if (query.saved !== undefined) return t.t('notice.preferencesSaved')
81
+ if (query.read === 'all') return t.t('notice.allRead')
82
+ if (query.read === 'one') return t.t('notice.markedRead')
83
+ return null
84
+ }
@@ -0,0 +1,26 @@
1
+ export type OnboardingPanel = 'welcome' | 'profile' | 'first-post'
2
+
3
+ const ONBOARDING_COOKIE_PREFIX = 'meith_onboarding_'
4
+
5
+ export const ONBOARDING_PANELS: readonly OnboardingPanel[] = ['welcome', 'profile', 'first-post']
6
+
7
+ export const ONBOARDING_COOKIE_MAX_AGE = 60 * 60 * 24 * 365
8
+
9
+ export function onboardingCookieName(panel: OnboardingPanel): string {
10
+ return `${ONBOARDING_COOKIE_PREFIX}${panel.replace('-', '_')}`
11
+ }
12
+
13
+ export function isOnboardingPanel(value: unknown): value is OnboardingPanel {
14
+ return (ONBOARDING_PANELS as readonly unknown[]).includes(value)
15
+ }
16
+
17
+ export function isNewMember(postCount: number): boolean {
18
+ return postCount === 0
19
+ }
20
+
21
+ export function hasCompleteProfile(profile: {
22
+ avatarUrl: string | null
23
+ bio: string | null
24
+ }): boolean {
25
+ return profile.avatarUrl !== null && profile.bio !== null && profile.bio.trim() !== ''
26
+ }