@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
package/proxy.ts ADDED
@@ -0,0 +1,160 @@
1
+ import { type NextRequest, NextResponse } from 'next/server'
2
+
3
+ import { env } from '@meith/core/env'
4
+
5
+ import { contentSecurityPolicy, newNonce, readsAsAsset } from './src/server/content-security-policy'
6
+ import {
7
+ DEV_GUEST_COOKIE,
8
+ DEV_REMEMBER_COOKIE,
9
+ DEV_SESSION_COOKIE,
10
+ GUEST_COOKIE,
11
+ GUEST_COOKIE_DAYS,
12
+ guestCookie,
13
+ guestCookieName,
14
+ REMEMBER_COOKIE,
15
+ SESSION_COOKIE,
16
+ } from './src/server/cookies'
17
+ import {
18
+ FRESH_GUEST_HEADER,
19
+ NONCE_HEADER,
20
+ PATH_HEADER,
21
+ QUERY_HEADER,
22
+ } from './src/server/location-header'
23
+ import {
24
+ PREFERENCE_COOKIE_MAX_AGE,
25
+ THEME_COOKIE,
26
+ THEME_QUERY,
27
+ themeFromQuery,
28
+ } from './src/view/theme-preference'
29
+
30
+ export const PROTECTED_PREFIXES = [
31
+ '/usercp',
32
+ '/messages',
33
+ '/notifications',
34
+ '/subscriptions',
35
+ '/moderation',
36
+ '/modcp',
37
+ '/admin',
38
+ ]
39
+
40
+ const RESUME_PATH = '/auth/resume'
41
+
42
+ const DAY_MS = 86_400_000
43
+
44
+ const CSP_HEADER = 'content-security-policy'
45
+
46
+ interface Policy {
47
+ readonly nonce: string
48
+ readonly value: string
49
+ }
50
+
51
+ /**
52
+ * Passes the request through, and hands a reader something to be counted by.
53
+ *
54
+ * The cookie is minted here because the Edge is the only place that can set one
55
+ * on an ordinary page response — a render cannot. The row it stands for is
56
+ * written by the render, which has the database this runtime does not.
57
+ *
58
+ * Whether the client *returned* the cookie is the load-bearing part, and it
59
+ * cannot be read downstream: Next reflects a cookie set here into the request
60
+ * the render sees, so a first-ever visit is indistinguishable from a returning
61
+ * one by cookie alone. Hence the header — set when the cookie is new, deleted
62
+ * otherwise, so a crawler that never keeps one is never counted and a client
63
+ * cannot supply its own.
64
+ */
65
+ function withPath(req: NextRequest, policy: Policy): NextResponse {
66
+ const fresh = !req.cookies.has(GUEST_COOKIE) && !req.cookies.has(DEV_GUEST_COOKIE)
67
+
68
+ const headers = new Headers(req.headers)
69
+ headers.set(NONCE_HEADER, policy.nonce)
70
+ headers.set(CSP_HEADER, policy.value)
71
+ headers.set(PATH_HEADER, req.nextUrl.pathname)
72
+ if (req.nextUrl.search === '') headers.delete(QUERY_HEADER)
73
+ else headers.set(QUERY_HEADER, req.nextUrl.search)
74
+ if (fresh) headers.set(FRESH_GUEST_HEADER, '1')
75
+ else headers.delete(FRESH_GUEST_HEADER)
76
+
77
+ const res = NextResponse.next({ request: { headers } })
78
+ if (!fresh) return res
79
+
80
+ const secure = req.nextUrl.protocol === 'https:'
81
+ res.cookies.set(
82
+ guestCookieName(secure),
83
+ crypto.randomUUID(),
84
+ guestCookie(new Date(Date.now() + GUEST_COOKIE_DAYS * DAY_MS), secure),
85
+ )
86
+ return res
87
+ }
88
+
89
+ function isProtected(pathname: string): boolean {
90
+ return PROTECTED_PREFIXES.some((p) => pathname === p || pathname.startsWith(`${p}/`))
91
+ }
92
+
93
+ function themeRedirect(req: NextRequest): NextResponse | null {
94
+ if (req.method !== 'GET') return null
95
+
96
+ const requested = themeFromQuery(req.nextUrl.searchParams.get(THEME_QUERY))
97
+ if (requested === null) return null
98
+
99
+ const url = req.nextUrl.clone()
100
+ url.searchParams.delete(THEME_QUERY)
101
+
102
+ const res = NextResponse.redirect(url)
103
+ res.cookies.set(THEME_COOKIE, requested, {
104
+ path: '/',
105
+ maxAge: PREFERENCE_COOKIE_MAX_AGE,
106
+ sameSite: 'lax',
107
+ httpOnly: false,
108
+ })
109
+ return res
110
+ }
111
+
112
+ function triage(req: NextRequest, policy: Policy): NextResponse {
113
+ const themed = themeRedirect(req)
114
+ if (themed !== null) return themed
115
+
116
+ const { pathname, search } = req.nextUrl
117
+ const hasSession = req.cookies.has(SESSION_COOKIE) || req.cookies.has(DEV_SESSION_COOKIE)
118
+ const hasRemember = req.cookies.has(REMEMBER_COOKIE) || req.cookies.has(DEV_REMEMBER_COOKIE)
119
+
120
+ if (hasSession) return withPath(req, policy)
121
+
122
+ if (hasRemember && pathname !== RESUME_PATH) {
123
+ const url = req.nextUrl.clone()
124
+ url.pathname = RESUME_PATH
125
+ url.search = ''
126
+ url.searchParams.set('next', `${pathname}${search}`)
127
+ return NextResponse.redirect(url)
128
+ }
129
+
130
+ if (!hasRemember && isProtected(pathname)) {
131
+ const url = req.nextUrl.clone()
132
+ url.pathname = '/login'
133
+ url.search = ''
134
+ url.searchParams.set('next', `${pathname}${search}`)
135
+ return NextResponse.redirect(url)
136
+ }
137
+
138
+ return withPath(req, policy)
139
+ }
140
+
141
+ export function proxy(req: NextRequest): NextResponse {
142
+ const nonce = newNonce()
143
+ const policy: Policy = {
144
+ nonce,
145
+ value: contentSecurityPolicy({
146
+ nonce,
147
+ development: env.NODE_ENV !== 'production',
148
+ remoteImages: env.REMOTE_IMAGES,
149
+ }),
150
+ }
151
+
152
+ const res = readsAsAsset(req.nextUrl.pathname) ? NextResponse.next() : triage(req, policy)
153
+
154
+ res.headers.set(CSP_HEADER, policy.value)
155
+ return res
156
+ }
157
+
158
+ export const config = {
159
+ matcher: ['/((?!_next/static|_next/image).*)'],
160
+ }
@@ -0,0 +1,87 @@
1
+ 'use client'
2
+
3
+ import { useActionState } from 'react'
4
+
5
+ import { Button } from '@meith/ui/button'
6
+
7
+ import { EMPTY_STATE } from '@/server/auth-form-state'
8
+ import { revokeOtherSessionsAction, revokeSessionAction } from '@/server/session-actions'
9
+
10
+ import { FormError } from '../auth/form-controls'
11
+ import { type Copy, fromCopy } from '../shell/copy'
12
+
13
+ const CARD = 'flex flex-col gap-4 rounded-lg border border-border bg-card p-5'
14
+
15
+ const ROW =
16
+ 'flex flex-wrap items-center justify-between gap-3 rounded-md border border-border px-3 py-2'
17
+
18
+ export interface SessionView {
19
+ readonly id: number
20
+ readonly device: string
21
+ readonly detail: string
22
+ readonly current: boolean
23
+ }
24
+
25
+ export function ActiveSessions({
26
+ sessions,
27
+ copy,
28
+ }: {
29
+ readonly sessions: readonly SessionView[]
30
+ readonly copy: Copy
31
+ }) {
32
+ const [revokeState, revoke] = useActionState(revokeSessionAction, EMPTY_STATE)
33
+ const [allState, revokeAll] = useActionState(revokeOtherSessionsAction, EMPTY_STATE)
34
+
35
+ const others = sessions.filter((session) => !session.current).length
36
+
37
+ return (
38
+ <section className={CARD}>
39
+ <div className="flex flex-col gap-1">
40
+ <h2 className="text-lg font-semibold tracking-tight">
41
+ {fromCopy(copy, 'accountForm.sessions.title')}
42
+ </h2>
43
+ <p className="text-xs text-muted-foreground">
44
+ {fromCopy(copy, 'accountForm.sessions.blurb')}
45
+ </p>
46
+ </div>
47
+
48
+ <FormError message={revokeState.error ?? allState.error} />
49
+
50
+ <ul className="flex flex-col gap-2">
51
+ {sessions.map((session) => (
52
+ <li key={session.id} className={ROW}>
53
+ <span className="flex flex-col text-sm">
54
+ <span className="font-medium">
55
+ {session.device}
56
+ {session.current ? (
57
+ <span className="font-normal text-muted-foreground">
58
+ {' '}
59
+ {fromCopy(copy, 'accountForm.sessions.thisDevice')}
60
+ </span>
61
+ ) : null}
62
+ </span>
63
+ <span className="text-xs text-muted-foreground">{session.detail}</span>
64
+ </span>
65
+
66
+ {session.current ? null : (
67
+ <form action={revoke}>
68
+ <input type="hidden" name="sessionId" value={session.id} />
69
+ <Button type="submit" variant="destructive" size="sm">
70
+ {fromCopy(copy, 'accountForm.sessions.signOut')}
71
+ </Button>
72
+ </form>
73
+ )}
74
+ </li>
75
+ ))}
76
+ </ul>
77
+
78
+ {others > 0 ? (
79
+ <form action={revokeAll}>
80
+ <Button type="submit" variant="outline" size="sm">
81
+ {fromCopy(copy, 'accountForm.sessions.signOutElsewhere')}
82
+ </Button>
83
+ </form>
84
+ ) : null}
85
+ </section>
86
+ )
87
+ }
@@ -0,0 +1,119 @@
1
+ 'use client'
2
+
3
+ import { useActionState } from 'react'
4
+
5
+ import { AVATAR_BOX } from '@meith/avatars/limits'
6
+
7
+ import { EMPTY_STATE } from '@/server/auth-form-state'
8
+ import { removeAvatarAction, saveAvatarAction } from '@/server/usercp-actions'
9
+
10
+ import { FormError, SubmitButton } from '../auth/form-controls'
11
+ import { type Copy, fromCopy } from '../shell/copy'
12
+
13
+ const CARD = 'flex flex-col gap-3 rounded-lg border border-border bg-card p-4'
14
+
15
+ export function AvatarForm({
16
+ currentUrl,
17
+ status,
18
+ failureReason,
19
+ locked,
20
+ lockedReason,
21
+ copy,
22
+ }: {
23
+ currentUrl: string | null
24
+ status: 'none' | 'pending' | 'ready' | 'failed'
25
+ failureReason: string | null
26
+ locked: boolean
27
+ lockedReason: string | null
28
+ copy: Copy
29
+ }) {
30
+ const [saveState, save] = useActionState(saveAvatarAction, EMPTY_STATE)
31
+ const [removeState, remove] = useActionState(removeAvatarAction, EMPTY_STATE)
32
+
33
+ if (locked) {
34
+ return (
35
+ <div className={CARD}>
36
+ <h2 className="text-lg font-semibold tracking-tight">
37
+ {fromCopy(copy, 'accountForm.avatar.lockedTitle')}
38
+ </h2>
39
+ <p className="text-sm text-muted-foreground">
40
+ {fromCopy(copy, 'accountForm.avatar.lockedBody')}
41
+ {lockedReason === null ? null : (
42
+ <>
43
+ {' '}
44
+ {copy['accountForm.locked.reasonLead']}
45
+ <span className="text-foreground">{lockedReason}</span>
46
+ {copy['accountForm.locked.reasonTail']}
47
+ </>
48
+ )}
49
+ </p>
50
+ </div>
51
+ )
52
+ }
53
+
54
+ return (
55
+ <div className="flex flex-col gap-4">
56
+ <div className={CARD}>
57
+ <h2 className="text-lg font-semibold tracking-tight">
58
+ {fromCopy(copy, 'accountForm.avatar.title')}
59
+ </h2>
60
+
61
+ {status === 'pending' && (
62
+ <p className="text-sm text-muted-foreground">
63
+ {fromCopy(copy, 'accountForm.avatar.pending')}
64
+ </p>
65
+ )}
66
+ {status === 'failed' && (
67
+ <p className="text-sm text-muted-foreground">
68
+ {failureReason ?? fromCopy(copy, 'accountForm.avatar.failed')}
69
+ </p>
70
+ )}
71
+
72
+ {currentUrl === null ? (
73
+ <p className="text-sm text-muted-foreground">
74
+ {fromCopy(copy, 'accountForm.avatar.none')}
75
+ </p>
76
+ ) : (
77
+ <img
78
+ src={currentUrl}
79
+ alt={fromCopy(copy, 'accountForm.avatar.title')}
80
+ width={AVATAR_BOX.width}
81
+ height={AVATAR_BOX.height}
82
+ className="h-24 w-24 rounded-md border border-border object-cover"
83
+ />
84
+ )}
85
+ </div>
86
+
87
+ <form action={save} className={CARD} noValidate>
88
+ <FormError message={saveState.error} />
89
+ <label className="flex flex-col gap-1 text-sm">
90
+ <span className="font-medium">{fromCopy(copy, 'accountForm.avatar.choose')}</span>
91
+ <input
92
+ type="file"
93
+ name="avatar"
94
+ accept=".png,.jpg,.jpeg"
95
+ className="rounded-md border border-border bg-background px-3 py-2 text-sm file:mr-3 file:rounded file:border-0 file:bg-muted file:px-3 file:py-1 file:text-sm"
96
+ />
97
+ <span className="text-xs text-muted-foreground">
98
+ {fromCopy(copy, 'accountForm.avatar.hint')}
99
+ </span>
100
+ </label>
101
+ <div>
102
+ <SubmitButton>{fromCopy(copy, 'accountForm.avatar.upload')}</SubmitButton>
103
+ </div>
104
+ </form>
105
+
106
+ {currentUrl !== null && (
107
+ <form action={remove} className={CARD} noValidate>
108
+ <FormError message={removeState.error} />
109
+ <p className="text-sm text-muted-foreground">
110
+ {fromCopy(copy, 'accountForm.avatar.removeBlurb')}
111
+ </p>
112
+ <div>
113
+ <SubmitButton>{fromCopy(copy, 'accountForm.avatar.remove')}</SubmitButton>
114
+ </div>
115
+ </form>
116
+ )}
117
+ </div>
118
+ )
119
+ }
@@ -0,0 +1,76 @@
1
+ 'use client'
2
+
3
+ import { useActionState } from 'react'
4
+
5
+ import { Input } from '@meith/ui'
6
+ import { Button } from '@meith/ui/button'
7
+
8
+ import { EMPTY_STATE } from '@/server/auth-form-state'
9
+ import { proveCredentialAction } from '@/server/credential-proof-actions'
10
+
11
+ import { FormError } from '../auth/form-controls'
12
+
13
+ export interface CredentialProofCopy {
14
+ readonly codeConsumed: string
15
+ readonly codeLabel: string
16
+ readonly codeSubmit: string
17
+ readonly passwordLabel: string
18
+ readonly passwordSubmit: string
19
+ }
20
+
21
+ export function CredentialProofForm({
22
+ copy,
23
+ hasPassword,
24
+ hasSecondFactor,
25
+ next,
26
+ }: {
27
+ readonly copy: CredentialProofCopy
28
+ readonly hasPassword: boolean
29
+ readonly hasSecondFactor: boolean
30
+ readonly next: string
31
+ }) {
32
+ const [state, action] = useActionState(proveCredentialAction, EMPTY_STATE)
33
+
34
+ return (
35
+ <div className="flex flex-col gap-4">
36
+ <FormError message={state.error} />
37
+ {hasPassword ? (
38
+ <form
39
+ action={action}
40
+ className="flex flex-col gap-3 rounded-lg border border-border bg-card p-5"
41
+ >
42
+ <input type="hidden" name="method" value="password" />
43
+ <input type="hidden" name="next" value={next} />
44
+ <label className="flex flex-col gap-1 text-sm">
45
+ <span className="font-medium">{copy.passwordLabel}</span>
46
+ <Input name="password" type="password" autoComplete="current-password" required />
47
+ </label>
48
+ <div>
49
+ <Button type="submit" variant="primary">
50
+ {copy.passwordSubmit}
51
+ </Button>
52
+ </div>
53
+ </form>
54
+ ) : null}
55
+ {hasSecondFactor ? (
56
+ <form
57
+ action={action}
58
+ className="flex flex-col gap-3 rounded-lg border border-border bg-card p-5"
59
+ >
60
+ <input type="hidden" name="method" value="code" />
61
+ <input type="hidden" name="next" value={next} />
62
+ <label className="flex flex-col gap-1 text-sm">
63
+ <span className="font-medium">{copy.codeLabel}</span>
64
+ <Input name="code" inputMode="numeric" autoComplete="one-time-code" required />
65
+ </label>
66
+ <p className="text-sm text-muted-foreground">{copy.codeConsumed}</p>
67
+ <div>
68
+ <Button type="submit" variant="primary">
69
+ {copy.codeSubmit}
70
+ </Button>
71
+ </div>
72
+ </form>
73
+ ) : null}
74
+ </div>
75
+ )
76
+ }
@@ -0,0 +1,14 @@
1
+ import { logoutAction } from '@/server/auth-actions'
2
+
3
+ export function LogoutForm({ label }: { label: string }) {
4
+ return (
5
+ <form action={logoutAction}>
6
+ <button
7
+ type="submit"
8
+ className="font-medium text-foreground hover:underline underline-offset-2"
9
+ >
10
+ {label}
11
+ </button>
12
+ </form>
13
+ )
14
+ }
@@ -0,0 +1,157 @@
1
+ 'use client'
2
+
3
+ import { useActionState } from 'react'
4
+
5
+ import { EMPTY_STATE } from '@/server/auth-form-state'
6
+ import {
7
+ markAllNotificationsReadAction,
8
+ markNotificationReadAction,
9
+ saveNotificationPreferencesAction,
10
+ } from '@/server/notification-actions'
11
+
12
+ import { FormError } from '../auth/form-controls'
13
+ import { type Copy, fromCopy } from '../shell/copy'
14
+
15
+ const BUTTON =
16
+ 'inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
17
+
18
+ const LINK_BUTTON =
19
+ 'text-sm font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
20
+
21
+ export function MarkNotificationReadForm({
22
+ notificationId,
23
+ copy,
24
+ }: {
25
+ notificationId: number
26
+ copy: Copy
27
+ }) {
28
+ const [state, action] = useActionState(markNotificationReadAction, EMPTY_STATE)
29
+
30
+ return (
31
+ <form action={action} className="contents">
32
+ <input type="hidden" name="notificationId" value={notificationId} />
33
+ <FormError message={state.error} />
34
+ <button type="submit" className={LINK_BUTTON}>
35
+ {fromCopy(copy, 'accountForm.notifications.markRead')}
36
+ </button>
37
+ </form>
38
+ )
39
+ }
40
+
41
+ export function MarkAllNotificationsReadForm({ unread, copy }: { unread: number; copy: Copy }) {
42
+ const [state, action] = useActionState(markAllNotificationsReadAction, EMPTY_STATE)
43
+
44
+ if (unread === 0) return null
45
+
46
+ return (
47
+ <form action={action}>
48
+ <FormError message={state.error} />
49
+ <button type="submit" className={BUTTON}>
50
+ {fromCopy(copy, 'accountForm.notifications.markAllRead')}
51
+ </button>
52
+ </form>
53
+ )
54
+ }
55
+
56
+ export interface PreferenceRow {
57
+ readonly kind: string
58
+ readonly title: string
59
+ readonly description: string
60
+ readonly email: boolean
61
+ readonly push: boolean
62
+ }
63
+
64
+ const CHECKBOX =
65
+ 'size-4 rounded border-border focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
66
+
67
+ export function NotificationPreferencesForm({
68
+ rows,
69
+ push,
70
+ copy,
71
+ }: {
72
+ rows: readonly PreferenceRow[]
73
+ push: boolean
74
+ copy: Copy
75
+ }) {
76
+ const [state, action] = useActionState(saveNotificationPreferencesAction, EMPTY_STATE)
77
+
78
+ return (
79
+ <form
80
+ action={action}
81
+ className="flex flex-col gap-5 rounded-lg border border-border bg-card p-5"
82
+ >
83
+ <FormError message={state.error} />
84
+
85
+ <fieldset className={push ? 'flex flex-col gap-5' : 'flex flex-col gap-4'}>
86
+ <legend className="text-sm font-medium">
87
+ {fromCopy(
88
+ copy,
89
+ push ? 'accountForm.notifications.channels' : 'accountForm.notifications.legend',
90
+ )}
91
+ </legend>
92
+
93
+ {rows.map((row) =>
94
+ push ? (
95
+ <div
96
+ key={row.kind}
97
+ className="flex flex-col gap-2 text-sm sm:flex-row sm:items-start sm:justify-between sm:gap-6"
98
+ >
99
+ <span className="sm:flex-1">
100
+ <span className="font-medium">{row.title}</span>
101
+ <span className="block text-muted-foreground">{row.description}</span>
102
+ </span>
103
+
104
+ <span className="flex shrink-0 items-center gap-5">
105
+ <label className="flex items-center gap-2 text-xs text-muted-foreground">
106
+ <input
107
+ type="checkbox"
108
+ name="email"
109
+ value={row.kind}
110
+ defaultChecked={row.email}
111
+ className={CHECKBOX}
112
+ />
113
+ {fromCopy(copy, 'accountForm.notifications.email')}
114
+ </label>
115
+
116
+ <label className="flex items-center gap-2 text-xs text-muted-foreground">
117
+ <input
118
+ type="checkbox"
119
+ name="push"
120
+ value={row.kind}
121
+ defaultChecked={row.push}
122
+ className={CHECKBOX}
123
+ />
124
+ {fromCopy(copy, 'accountForm.notifications.push')}
125
+ </label>
126
+ </span>
127
+ </div>
128
+ ) : (
129
+ <label key={row.kind} className="flex items-start gap-3 text-sm">
130
+ <input
131
+ type="checkbox"
132
+ name="email"
133
+ value={row.kind}
134
+ defaultChecked={row.email}
135
+ className={`mt-1 ${CHECKBOX}`}
136
+ />
137
+ <span>
138
+ <span className="font-medium">{row.title}</span>
139
+ <span className="block text-muted-foreground">{row.description}</span>
140
+ </span>
141
+ </label>
142
+ ),
143
+ )}
144
+ </fieldset>
145
+
146
+ <p className="text-xs text-muted-foreground">
147
+ {fromCopy(copy, 'accountForm.notifications.blurb')}
148
+ </p>
149
+
150
+ <div>
151
+ <button type="submit" className={BUTTON}>
152
+ {fromCopy(copy, 'accountForm.notifications.submit')}
153
+ </button>
154
+ </div>
155
+ </form>
156
+ )
157
+ }
@@ -0,0 +1,81 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+
5
+ import { Alert, AlertDescription, Input } from '@meith/ui'
6
+ import { Button } from '@meith/ui/button'
7
+
8
+ import { enrolPasskey, passkeyMessage, passkeysAvailable } from '@/components/auth/passkey-client'
9
+
10
+ import { type Copy, fromCopy } from '../shell/copy'
11
+
12
+ export function PasskeyEnrol({ copy }: { readonly copy: Copy }) {
13
+ const [available, setAvailable] = useState<boolean | null>(null)
14
+ const [label, setLabel] = useState('')
15
+ const [busy, setBusy] = useState(false)
16
+ const [error, setError] = useState<string | null>(null)
17
+
18
+ useEffect(() => {
19
+ setAvailable(passkeysAvailable())
20
+ }, [])
21
+
22
+ if (available === false) {
23
+ return (
24
+ <p className="text-sm text-muted-foreground">
25
+ {fromCopy(copy, 'accountForm.passkey.unsupported')}
26
+ </p>
27
+ )
28
+ }
29
+
30
+ return (
31
+ <div className="flex flex-col gap-3">
32
+ {error === null ? null : (
33
+ <Alert tone="error">
34
+ <AlertDescription>{error}</AlertDescription>
35
+ </Alert>
36
+ )}
37
+
38
+ <label className="flex flex-col gap-1 text-sm">
39
+ <span className="font-medium">{fromCopy(copy, 'accountForm.passkey.label')}</span>
40
+ <Input
41
+ value={label}
42
+ maxLength={60}
43
+ placeholder={fromCopy(copy, 'accountForm.passkey.placeholder')}
44
+ onChange={(event) => setLabel(event.target.value)}
45
+ />
46
+ <span className="text-xs text-muted-foreground">
47
+ {fromCopy(copy, 'accountForm.passkey.hint')}
48
+ </span>
49
+ </label>
50
+
51
+ <div>
52
+ <Button
53
+ type="button"
54
+ variant="primary"
55
+ disabled={busy || available === null}
56
+ onClick={() => {
57
+ setError(null)
58
+ setBusy(true)
59
+ enrolPasskey(label, copy)
60
+ .then(() => {
61
+ window.location.assign('/usercp/security?passkey=added')
62
+ })
63
+ .catch((problem: unknown) => {
64
+ const message = passkeyMessage(problem, copy)
65
+ if (message.includes('recent verification')) {
66
+ window.location.assign('/usercp/security/verify?next=%2Fusercp%2Fsecurity')
67
+ return
68
+ }
69
+ setError(message)
70
+ setBusy(false)
71
+ })
72
+ }}
73
+ >
74
+ {busy
75
+ ? fromCopy(copy, 'authForm.passkey.waiting')
76
+ : fromCopy(copy, 'accountForm.passkey.submit')}
77
+ </Button>
78
+ </div>
79
+ </div>
80
+ )
81
+ }