@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,125 @@
1
+ import 'server-only'
2
+
3
+ import { cache } from 'react'
4
+
5
+ import type { Actor, ModeratedTarget, ModeratorRights } from '@meith/authorization'
6
+ import { canHoldThreads } from '@meith/forums'
7
+ import type { Translator } from '@meith/i18n'
8
+ import { ModerationQueue } from '@meith/moderation'
9
+
10
+ import { untranslated } from '@/view/time'
11
+
12
+ import { getContainer } from './container'
13
+ import { getActor } from './context'
14
+ import { hasReportScope, resolveReportScope } from './report-scope'
15
+
16
+ export interface ModCpAccess {
17
+ readonly actor: Actor
18
+ readonly userId: number
19
+ readonly forumIds: readonly number[]
20
+ readonly hasGroupAccess: boolean
21
+ readonly canWarn: boolean
22
+ readonly canLookUpIp: boolean
23
+ }
24
+
25
+ export const resolveModCpAccess = cache(
26
+ async function resolveModCpAccess(): Promise<ModCpAccess | null> {
27
+ const actor = await getActor()
28
+ const { authorizer, modcp } = getContainer()
29
+
30
+ if (modcp === null || actor.userId === null) return null
31
+
32
+ const hasGroupAccess = authorizer.can(actor, 'modcp.access')
33
+ const forumIds = await authorizer.moderatedForumIds(actor)
34
+ if (!hasGroupAccess && forumIds.length === 0) return null
35
+
36
+ return {
37
+ actor,
38
+ userId: actor.userId,
39
+ forumIds,
40
+ hasGroupAccess,
41
+ canWarn: getContainer().warnings !== null && authorizer.can(actor, 'user.warn'),
42
+ canLookUpIp: actor.global.isAdministrator === true || actor.global.isSuperModerator === true,
43
+ }
44
+ },
45
+ )
46
+
47
+ export interface ModCpCounts {
48
+ readonly pending: number
49
+ readonly openReports: number
50
+ }
51
+
52
+ export const modCpCounts = cache(async function modCpCounts(): Promise<ModCpCounts> {
53
+ const access = await resolveModCpAccess()
54
+ if (access === null) return { pending: 0, openReports: 0 }
55
+
56
+ const { moderationQueue, reports } = getContainer()
57
+
58
+ const [pending, openReports] = await Promise.all([
59
+ moderationQueue === null
60
+ ? Promise.resolve(0)
61
+ : new ModerationQueue({ queue: moderationQueue })
62
+ .countPending(access.forumIds)
63
+ .catch(() => 0),
64
+ reports === null
65
+ ? Promise.resolve(0)
66
+ : resolveReportScope()
67
+ .then((scope) => (hasReportScope(scope) ? reports.countOpen(scope) : 0))
68
+ .catch(() => 0),
69
+ ])
70
+
71
+ return { pending, openReports }
72
+ })
73
+
74
+ export interface ModeratedForumRights {
75
+ readonly forumId: number
76
+ readonly title: string
77
+ readonly slug: string
78
+ readonly rights: readonly string[]
79
+ }
80
+
81
+ const RIGHT_LABEL_KEYS: Readonly<Record<keyof ModeratorRights, string>> = {
82
+ canApproveContent: 'modcp.right.approve-content',
83
+ canEditPosts: 'modcp.right.edit-posts',
84
+ canSoftDeletePosts: 'modcp.right.delete-posts',
85
+ canRestorePosts: 'modcp.right.restore-posts',
86
+ canOpenCloseThreads: 'modcp.right.lock-unlock',
87
+ canStickThreads: 'modcp.right.pin-unpin',
88
+ canMoveThreads: 'modcp.right.move-threads',
89
+ canMergeThreads: 'modcp.right.merge-threads',
90
+ canSplitThreads: 'modcp.right.split-threads',
91
+ }
92
+
93
+ export async function moderatedForumRights(
94
+ access: ModCpAccess,
95
+ t: Translator = untranslated(),
96
+ ): Promise<readonly ModeratedForumRights[]> {
97
+ const { authorizer, forums } = getContainer()
98
+ const rows = await forums.listListing()
99
+
100
+ const resolved = await Promise.all(
101
+ access.forumIds.map(async (forumId): Promise<ModeratedForumRights | null> => {
102
+ const row = rows.find((r) => r.id === forumId)
103
+ if (row === undefined || !canHoldThreads(row.type)) return null
104
+ const rights = await authorizer.moderatorRightsIn(access.actor, forumId)
105
+ return {
106
+ forumId,
107
+ title: row.title,
108
+ slug: row.slug,
109
+ rights: (Object.keys(RIGHT_LABEL_KEYS) as (keyof ModeratorRights)[])
110
+ .filter((key) => rights[key])
111
+ .map((key) => t.t(RIGHT_LABEL_KEYS[key])),
112
+ }
113
+ }),
114
+ )
115
+
116
+ return resolved.filter((row): row is ModeratedForumRights => row !== null)
117
+ }
118
+
119
+ export async function moderatorTargetFor(
120
+ actor: Actor,
121
+ forumId: number,
122
+ forum: Awaited<ReturnType<ReturnType<typeof getContainer>['authorizer']['forumMatrix']>>,
123
+ ): Promise<ModeratedTarget> {
124
+ return getContainer().authorizer.moderatorTargetIn(actor, forumId, forum)
125
+ }
@@ -0,0 +1,165 @@
1
+ 'use server'
2
+
3
+ import { redirect } from 'next/navigation'
4
+
5
+ import { ForbiddenError, ValidationError } from '@meith/core'
6
+ import { msg } from '@meith/i18n'
7
+ import { ModerationQueue, parseSelection } from '@meith/moderation'
8
+
9
+ import { recordAdminAction } from './admin'
10
+ import type { FormState } from './auth-form-state'
11
+ import { avatarService } from './avatars'
12
+ import { getContainer } from './container'
13
+ import { getActor } from './context'
14
+ import { formStateReporter } from './form-state-reporter'
15
+ import { tr } from './i18n'
16
+ import { emitEvent } from './plugin-view'
17
+
18
+ const toFormState = formStateReporter('moderation-actions', 'unexpected error in the queue')
19
+
20
+ function outcomeQuery(outcome: {
21
+ decision: string
22
+ applied: number
23
+ refused: number
24
+ missing: number
25
+ }): string {
26
+ const parts = [`did=${outcome.decision}`, `n=${outcome.applied}`]
27
+ if (outcome.refused > 0) parts.push(`refused=${outcome.refused}`)
28
+ if (outcome.missing > 0) parts.push(`gone=${outcome.missing}`)
29
+ return parts.join('&')
30
+ }
31
+
32
+ export async function moderateQueueAction(_prev: FormState, form: FormData): Promise<FormState> {
33
+ const decision = form.get('decision')
34
+ if (decision !== 'approve' && decision !== 'reject') {
35
+ return { error: await tr('notice.app.choose-approve-reject') }
36
+ }
37
+
38
+ const { authorizer, moderationQueue } = getContainer()
39
+ if (moderationQueue === null) {
40
+ return {
41
+ error: await tr('notice.app.board-running-in-memory-sample-data-2'),
42
+ }
43
+ }
44
+
45
+ let outcome: Awaited<ReturnType<ModerationQueue['decide']>>
46
+ try {
47
+ const actor = await getActor()
48
+ if (actor.userId === null) {
49
+ throw new ForbiddenError(msg('error.app.must-logged-moderate'))
50
+ }
51
+
52
+ const moderated = new Set(await authorizer.moderatedForumIds(actor))
53
+ if (moderated.size === 0) {
54
+ throw new ForbiddenError(msg('error.app.moderate-any-forums'))
55
+ }
56
+
57
+ const selection = parseSelection(
58
+ form.getAll('item').filter((v): v is string => typeof v === 'string'),
59
+ )
60
+ if (selection.length === 0) {
61
+ throw new ValidationError(msg('error.app.select-at-least-one-item'))
62
+ }
63
+
64
+ outcome = await new ModerationQueue({ queue: moderationQueue }).decide({
65
+ selection,
66
+ decision,
67
+ moderatedForumIds: moderated,
68
+ actorUserId: actor.userId,
69
+ })
70
+
71
+ const moderation = { moderatorId: actor.userId, reason: null }
72
+ for (const item of outcome.decided) {
73
+ await emitEvent(
74
+ 'approval.decided',
75
+ { kind: item.kind, id: item.id, approved: decision === 'approve' },
76
+ moderation,
77
+ )
78
+ }
79
+ if (outcome.decided.length > 0) {
80
+ await emitEvent(
81
+ 'moderation.logged',
82
+ { action: `queue.${decision}`, targetId: outcome.decided[0]!.id },
83
+ moderation,
84
+ )
85
+ }
86
+ } catch (err) {
87
+ return toFormState(err)
88
+ }
89
+
90
+ redirect(`/moderation?${outcomeQuery(outcome)}`)
91
+ }
92
+
93
+ export async function setSignatureLockAction(_prev: FormState, form: FormData): Promise<FormState> {
94
+ const text = (name: string): string => {
95
+ const value = form.get(name)
96
+ return typeof value === 'string' ? value.trim() : ''
97
+ }
98
+
99
+ const userId = Number(text('userId'))
100
+ const returnTo = userId > 0 ? `/member/${userId}` : '/'
101
+
102
+ try {
103
+ const actor = await getActor()
104
+ if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged'))
105
+ getContainer().authorizer.require(actor, 'user.warn')
106
+
107
+ const store = getContainer().signatures
108
+ if (store === null) {
109
+ throw new ForbiddenError(msg('error.app.board-keeps-signatures'))
110
+ }
111
+ if (!Number.isInteger(userId) || userId <= 0) {
112
+ throw new ValidationError(msg('error.app.such-member'))
113
+ }
114
+
115
+ const locked = form.get('locked') === '1'
116
+ const reason = text('reason')
117
+
118
+ if (locked && reason === '') {
119
+ throw new ValidationError(msg('error.app.say-why-member-shown-reason'))
120
+ }
121
+
122
+ await store.setLocked({ userId, locked, reason: locked ? reason : null })
123
+ await recordAdminAction({
124
+ action: locked ? 'signature.lock' : 'signature.unlock',
125
+ detail: { userId },
126
+ })
127
+ } catch (err) {
128
+ return toFormState(err)
129
+ }
130
+
131
+ redirect(`${returnTo}?signature=${form.get('locked') === '1' ? 'locked' : 'unlocked'}`)
132
+ }
133
+
134
+ export async function setAvatarLockAction(_prev: FormState, form: FormData): Promise<FormState> {
135
+ const text = (name: string): string => {
136
+ const value = form.get(name)
137
+ return typeof value === 'string' ? value.trim() : ''
138
+ }
139
+
140
+ const userId = Number(text('userId'))
141
+ const returnTo = userId > 0 ? `/member/${userId}` : '/'
142
+
143
+ try {
144
+ const actor = await getActor()
145
+ if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged'))
146
+ getContainer().authorizer.require(actor, 'user.warn')
147
+
148
+ const service = avatarService()
149
+ if (service === null) throw new ForbiddenError(msg('error.app.board-keeps-avatars'))
150
+ if (!Number.isInteger(userId) || userId <= 0) {
151
+ throw new ValidationError(msg('error.app.such-member'))
152
+ }
153
+
154
+ const locked = form.get('locked') === '1'
155
+ await service.setLock({ userId, locked, reason: text('reason') })
156
+ await recordAdminAction({
157
+ action: locked ? 'avatar.lock' : 'avatar.unlock',
158
+ detail: { userId },
159
+ })
160
+ } catch (err) {
161
+ return toFormState(err)
162
+ }
163
+
164
+ redirect(`${returnTo}?avatar=${form.get('locked') === '1' ? 'locked' : 'unlocked'}`)
165
+ }
@@ -0,0 +1,217 @@
1
+ 'use server'
2
+
3
+ import { revalidatePath } from 'next/cache'
4
+
5
+ import { CacheTags, ValidationError } from '@meith/core'
6
+ import type {
7
+ NavigationAudience,
8
+ NavigationDropTarget,
9
+ NavigationItemInput,
10
+ PostgresNavigationRepository,
11
+ } from '@meith/db'
12
+ import { drivers } from '@meith/drivers'
13
+ import { msg } from '@meith/i18n'
14
+
15
+ import { NAVIGATION_AUDIENCE_VALUES, outlineOf } from '../view/navigation'
16
+ import { isNavigationNudge, isWhereItIs, nudgeTarget } from '../view/navigation-arrange'
17
+ import { recordAdminAction, requireAdmin } from './admin'
18
+ import type { FormState } from './auth-form-state'
19
+ import { formStateReporter } from './form-state-reporter'
20
+ import { checkbox, trimmedText } from './form-values'
21
+ import { navigationRepository } from './navigation'
22
+ import { isSafeLocalPath } from './safe-path'
23
+
24
+ const PANEL = '/admin/content/navigation'
25
+
26
+ const toFormState = formStateReporter('navigation-admin', 'navigation administration write failed')
27
+
28
+ function requireNavigation(): PostgresNavigationRepository {
29
+ const repository = navigationRepository()
30
+ if (repository === null) {
31
+ throw new ValidationError(msg('error.app.board-running-in-memory-navigation'))
32
+ }
33
+ return repository
34
+ }
35
+
36
+ function itemId(form: FormData): number {
37
+ const value = Number(trimmedText(form, 'id'))
38
+ if (!Number.isSafeInteger(value) || value <= 0) {
39
+ throw new ValidationError(msg('error.app.such-row'))
40
+ }
41
+ return value
42
+ }
43
+
44
+ function isAbsoluteWebAddress(value: string): boolean {
45
+ try {
46
+ const url = new URL(value)
47
+ return url.protocol === 'http:' || url.protocol === 'https:'
48
+ } catch {
49
+ return false
50
+ }
51
+ }
52
+
53
+ function href(form: FormData): string {
54
+ const value = trimmedText(form, 'href')
55
+ if (value === '') throw new ValidationError(msg('error.db.navigation-item-needs-address'))
56
+ if (isSafeLocalPath(value) || isAbsoluteWebAddress(value)) return value
57
+ throw new ValidationError(msg('error.app.navigation-address-path-or-web-address'))
58
+ }
59
+
60
+ function audience(form: FormData): NavigationAudience {
61
+ const value = trimmedText(form, 'audience')
62
+ const found = NAVIGATION_AUDIENCE_VALUES.find((entry) => entry === value)
63
+ if (found === undefined) throw new ValidationError(msg('error.app.navigation-audience-unknown'))
64
+ return found
65
+ }
66
+
67
+ function optionalId(form: FormData, name: string): number | null {
68
+ const value = trimmedText(form, name)
69
+ if (value === '') return null
70
+
71
+ const parsed = Number(value)
72
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) {
73
+ throw new ValidationError(msg('error.app.such-row'))
74
+ }
75
+ return parsed
76
+ }
77
+
78
+ function visibleToGroups(form: FormData): readonly number[] {
79
+ return form
80
+ .getAll('groups')
81
+ .map((value) => Number(typeof value === 'string' ? value : ''))
82
+ .filter((id) => Number.isSafeInteger(id) && id > 0)
83
+ }
84
+
85
+ function itemInput(form: FormData): NavigationItemInput {
86
+ return {
87
+ parentId: optionalId(form, 'parentId'),
88
+ label: trimmedText(form, 'label'),
89
+ href: href(form),
90
+ audience: audience(form),
91
+ newTab: checkbox(form, 'newTab'),
92
+ enabled: checkbox(form, 'enabled'),
93
+ visibleToGroups: visibleToGroups(form),
94
+ }
95
+ }
96
+
97
+ async function refresh(): Promise<void> {
98
+ await drivers().cache.invalidateTags([CacheTags.navigation()])
99
+ revalidatePath(PANEL)
100
+ }
101
+
102
+ export async function createNavigationItemAction(
103
+ _prev: FormState,
104
+ form: FormData,
105
+ ): Promise<FormState> {
106
+ try {
107
+ await requireAdmin()
108
+
109
+ const navigationItemId = await requireNavigation().create(itemInput(form))
110
+
111
+ await refresh()
112
+ await recordAdminAction({
113
+ action: 'content.navigation_item_added',
114
+ detail: { navigationItemId },
115
+ })
116
+
117
+ return { notice: 'saved' }
118
+ } catch (err) {
119
+ return toFormState(err)
120
+ }
121
+ }
122
+
123
+ export async function updateNavigationItemAction(
124
+ _prev: FormState,
125
+ form: FormData,
126
+ ): Promise<FormState> {
127
+ try {
128
+ await requireAdmin()
129
+
130
+ const navigationItemId = itemId(form)
131
+ await requireNavigation().update(navigationItemId, itemInput(form))
132
+
133
+ await refresh()
134
+ await recordAdminAction({
135
+ action: 'content.navigation_item_changed',
136
+ detail: { navigationItemId },
137
+ })
138
+
139
+ return { notice: 'saved' }
140
+ } catch (err) {
141
+ return toFormState(err)
142
+ }
143
+ }
144
+
145
+ export async function deleteNavigationItemAction(
146
+ _prev: FormState,
147
+ form: FormData,
148
+ ): Promise<FormState> {
149
+ try {
150
+ await requireAdmin()
151
+
152
+ const navigationItemId = itemId(form)
153
+ await requireNavigation().delete(navigationItemId)
154
+
155
+ await refresh()
156
+ await recordAdminAction({
157
+ action: 'content.navigation_item_removed',
158
+ detail: { navigationItemId },
159
+ })
160
+
161
+ return { notice: 'deleted' }
162
+ } catch (err) {
163
+ return toFormState(err)
164
+ }
165
+ }
166
+
167
+ export async function arrangeNavigationItemAction(
168
+ _prev: FormState,
169
+ form: FormData,
170
+ ): Promise<FormState> {
171
+ try {
172
+ await requireAdmin()
173
+
174
+ const repository = requireNavigation()
175
+ const navigationItemId = itemId(form)
176
+ const outline = outlineOf(await repository.list())
177
+
178
+ if (!outline.some((row) => row.id === navigationItemId)) {
179
+ throw new ValidationError(msg('error.app.such-row'))
180
+ }
181
+
182
+ const target = dropTarget(form, outline, navigationItemId)
183
+ if (isWhereItIs(outline, navigationItemId, target)) return { notice: 'unmoved' }
184
+
185
+ await repository.arrange(navigationItemId, target)
186
+
187
+ await refresh()
188
+ await recordAdminAction({
189
+ action: 'content.navigation_item_moved',
190
+ detail: { navigationItemId, parentId: target.parentId, afterId: target.afterId },
191
+ })
192
+
193
+ return { notice: 'moved' }
194
+ } catch (err) {
195
+ return toFormState(err)
196
+ }
197
+ }
198
+
199
+ function dropTarget(
200
+ form: FormData,
201
+ outline: ReturnType<typeof outlineOf>,
202
+ id: number,
203
+ ): NavigationDropTarget {
204
+ const nudge = trimmedText(form, 'nudge')
205
+
206
+ if (nudge !== '') {
207
+ if (!isNavigationNudge(nudge)) throw new ValidationError(msg('error.app.direction'))
208
+
209
+ const target = nudgeTarget(outline, id, nudge)
210
+ if (target === null) {
211
+ throw new ValidationError(msg('error.app.navigation-item-go-further-direction'))
212
+ }
213
+ return target
214
+ }
215
+
216
+ return { parentId: optionalId(form, 'parentId'), afterId: optionalId(form, 'afterId') }
217
+ }
@@ -0,0 +1,112 @@
1
+ import 'server-only'
2
+
3
+ import type { Actor } from '@meith/authorization'
4
+ import { CacheTags, cachedGlobal, env, logger } from '@meith/core'
5
+ import { getDb, type NavigationItemRow, PostgresNavigationRepository } from '@meith/db'
6
+ import { drivers } from '@meith/drivers'
7
+ import { pluginKeyOfNavigation, pluginNavigationPlacements } from '@meith/plugin-kit'
8
+ import type { LinkModel, ViewerModel } from '@meith/theme-kit'
9
+
10
+ import { buildNavigation, defaultNavigationItems, type NavigationNames } from '../view/navigation'
11
+ import { getContainer } from './container'
12
+ import { getTranslator } from './i18n'
13
+ import { activeDefinitions, pluginHost, syncPluginEnablement } from './plugin-host'
14
+ import { searchEnabled } from './search'
15
+
16
+ const TTL_SECONDS = 60
17
+
18
+ export function navigationRepository(): PostgresNavigationRepository | null {
19
+ return env.DATA_SOURCE === 'postgres' ? new PostgresNavigationRepository(getDb()) : null
20
+ }
21
+
22
+ export async function navigationItems(): Promise<readonly NavigationItemRow[]> {
23
+ const repository = navigationRepository()
24
+ if (repository === null) return defaultNavigationItems()
25
+
26
+ try {
27
+ return await cachedGlobal<NavigationItemRow[]>(
28
+ drivers().cache,
29
+ { key: ['navigation', 'items'], tags: [CacheTags.navigation()], revalidate: TTL_SECONDS },
30
+ async () => [...(await repository.list())],
31
+ )
32
+ } catch (error) {
33
+ logger().warn({ err: String(error) }, 'could not read the board navigation')
34
+ return defaultNavigationItems()
35
+ }
36
+ }
37
+
38
+ export function pluginNavigationNames(): NavigationNames {
39
+ return Object.fromEntries(
40
+ pluginNavigationPlacements(activeDefinitions()).map((item) => [
41
+ item.key,
42
+ { label: item.label, labelKey: item.labelKey, labelArgs: item.labelArgs },
43
+ ]),
44
+ )
45
+ }
46
+
47
+ /**
48
+ * A row belongs to a plugin that is switched off, or has failed its way off, or
49
+ * is no longer in the build. It is left in place — an operator's ordering is
50
+ * worth keeping — but it is not a link anybody should be offered.
51
+ */
52
+ async function pluginRowIsLive(key: string | null): Promise<boolean> {
53
+ const pluginKey = pluginKeyOfNavigation(key)
54
+ if (pluginKey === null) return true
55
+
56
+ await syncPluginEnablement()
57
+ return activeDefinitions().some((plugin) => plugin.key === pluginKey)
58
+ ? pluginHost.isEnabled(pluginKey)
59
+ : false
60
+ }
61
+
62
+ function pluginNavigationDrifted(items: readonly NavigationItemRow[]): boolean {
63
+ const declared = pluginNavigationPlacements(activeDefinitions())
64
+ const stored = new Map(
65
+ items
66
+ .filter((item) => item.key?.startsWith('plugin.') === true)
67
+ .map((item) => [item.key as string, item.href] as const),
68
+ )
69
+ if (declared.length !== stored.size) return true
70
+ return declared.some((item) => stored.get(item.key) !== item.href)
71
+ }
72
+
73
+ export async function boardNavigation(
74
+ actor: Actor,
75
+ viewer: ViewerModel,
76
+ ): Promise<readonly LinkModel[]> {
77
+ const { authorizer } = getContainer()
78
+
79
+ let items = await navigationItems()
80
+ if (pluginNavigationDrifted(items)) {
81
+ await syncPluginNavigation()
82
+ items = await navigationItems()
83
+ }
84
+ const live = await Promise.all(items.map((item) => pluginRowIsLive(item.key)))
85
+ const shown = items.filter((_, index) => live[index] === true)
86
+
87
+ return buildNavigation(shown, viewer, {
88
+ searchEnabled: await searchEnabled(),
89
+ t: await getTranslator(),
90
+ names: pluginNavigationNames(),
91
+ admits: (item) => authorizer.inAnyGroup(actor, item.visibleToGroups),
92
+ })
93
+ }
94
+
95
+ export async function syncPluginNavigation(): Promise<void> {
96
+ const repository = navigationRepository()
97
+ if (repository === null) return
98
+
99
+ try {
100
+ await repository.syncPluginItems(
101
+ pluginNavigationPlacements(activeDefinitions()).map((item) => ({
102
+ key: item.key,
103
+ href: item.href,
104
+ audience: item.audience,
105
+ parentKey: item.parentKey,
106
+ })),
107
+ )
108
+ await drivers().cache.invalidateTags([CacheTags.navigation()])
109
+ } catch (error) {
110
+ logger().warn({ err: String(error) }, 'could not reconcile the plugin navigation')
111
+ }
112
+ }
@@ -0,0 +1,13 @@
1
+ import 'server-only'
2
+
3
+ import { headers } from 'next/headers'
4
+
5
+ import { NONCE_HEADER } from './location-header'
6
+
7
+ export async function cspNonce(): Promise<string | undefined> {
8
+ try {
9
+ return (await headers()).get(NONCE_HEADER) ?? undefined
10
+ } catch {
11
+ return undefined
12
+ }
13
+ }