@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,104 @@
1
+ import 'server-only'
2
+
3
+ import { VERIFICATION_TTL_HOURS } from '@meith/accounts'
4
+ import { logger } from '@meith/core'
5
+ import type { Translator } from '@meith/i18n'
6
+ import { absoluteUrl, type MailBody, renderMail } from '@meith/mail'
7
+
8
+ import { AUTH_CONFIG } from './auth-config'
9
+ import { brandedFor } from './mail-brand'
10
+ import { sendBoardMail } from './mail-send'
11
+
12
+ const VERIFY_PATH = '/verify'
13
+ const RESET_PATH = '/reset/confirm'
14
+
15
+ const FALLBACK_KEY = 'authMail.boardFallback'
16
+
17
+ async function send(input: {
18
+ readonly email: string
19
+ readonly body: (board: string, link: string | null) => MailBody
20
+ readonly path: string
21
+ readonly token: string
22
+ readonly t: Translator
23
+ readonly failure: string
24
+ readonly template: string
25
+ }): Promise<void> {
26
+ const { brand, boardName } = await brandedFor(input.t, FALLBACK_KEY)
27
+ const link = absoluteUrl(brand.boardUrl, `${input.path}?token=${encodeURIComponent(input.token)}`)
28
+
29
+ const mail = renderMail({ brand, t: input.t, body: input.body(boardName, link) })
30
+ const fromName = brand.fromName ?? ''
31
+
32
+ try {
33
+ await sendBoardMail(input.template, {
34
+ to: input.email,
35
+ subject: mail.subject,
36
+ text: mail.text,
37
+ html: mail.html,
38
+ ...(fromName === '' ? {} : { fromName }),
39
+ })
40
+ } catch (err) {
41
+ logger({ module: 'auth' }).error({ err }, input.failure)
42
+ throw err
43
+ }
44
+ }
45
+
46
+ export async function sendVerificationEmail(input: {
47
+ readonly token: string
48
+ readonly email: string
49
+ readonly username: string
50
+ readonly t: Translator
51
+ }): Promise<void> {
52
+ const { t } = input
53
+
54
+ await send({
55
+ email: input.email,
56
+ path: VERIFY_PATH,
57
+ token: input.token,
58
+ t,
59
+ failure: 'could not send a verification e-mail',
60
+ template: 'auth.verify',
61
+ body: (board, link) => ({
62
+ title: t.t('authMail.verify.subject'),
63
+ greeting: t.t('authMail.greeting', { username: input.username }),
64
+ paragraphs: [
65
+ t.t('authMail.verify.intro', { board }),
66
+ ...(link === null ? [t.t('authMail.verify.noLink', { board })] : []),
67
+ t.t('authMail.verify.ignore'),
68
+ ],
69
+ ...(link === null ? {} : { action: { label: t.t('authMail.verify.action'), href: link } }),
70
+ note: t.t('authMail.linkHours', { hours: VERIFICATION_TTL_HOURS }),
71
+ footer: [{ text: t.t('mail.footer.sentBy', { board }) }],
72
+ }),
73
+ })
74
+ }
75
+
76
+ export async function sendPasswordResetEmail(input: {
77
+ readonly token: string
78
+ readonly email: string
79
+ readonly username: string
80
+ readonly t: Translator
81
+ }): Promise<void> {
82
+ const { t } = input
83
+
84
+ await send({
85
+ email: input.email,
86
+ path: RESET_PATH,
87
+ token: input.token,
88
+ t,
89
+ failure: 'could not send a password reset e-mail',
90
+ template: 'auth.reset',
91
+ body: (board, link) => ({
92
+ title: t.t('authMail.reset.subject'),
93
+ greeting: t.t('authMail.greeting', { username: input.username }),
94
+ paragraphs: [
95
+ t.t('authMail.reset.intro', { board }),
96
+ ...(link === null ? [t.t('authMail.reset.noLink', { board })] : []),
97
+ t.t('authMail.reset.ignore'),
98
+ ],
99
+ ...(link === null ? {} : { action: { label: t.t('authMail.reset.action'), href: link } }),
100
+ note: t.t('authMail.linkMinutes', { minutes: AUTH_CONFIG.resetTokenTtlMinutes }),
101
+ footer: [{ text: t.t('mail.footer.sentBy', { board }) }],
102
+ }),
103
+ })
104
+ }
@@ -0,0 +1,83 @@
1
+ import { msg } from '@meith/i18n'
2
+ import 'server-only'
3
+
4
+ import { cache } from 'react'
5
+
6
+ import type { Actor } from '@meith/authorization'
7
+ import {
8
+ AvatarService,
9
+ avatarUrl,
10
+ avatarVisible,
11
+ NO_AVATAR,
12
+ type StoredAvatar,
13
+ } from '@meith/avatars'
14
+ import { ForbiddenError } from '@meith/core'
15
+ import { drivers } from '@meith/drivers'
16
+ import { imageProcessor } from '@meith/drivers/images'
17
+
18
+ import { getContainer } from './container'
19
+
20
+ export const AVATAR_FIELD = 'avatar'
21
+
22
+ export function avatarService(): AvatarService | null {
23
+ const { avatars } = getContainer()
24
+ if (avatars === null) return null
25
+
26
+ return new AvatarService({
27
+ avatars,
28
+ files: drivers().files,
29
+ images: imageProcessor,
30
+ })
31
+ }
32
+
33
+ export async function avatarFor(userId: number): Promise<StoredAvatar> {
34
+ const { avatars } = getContainer()
35
+ if (avatars === null) return NO_AVATAR
36
+ return (await avatars.find(userId)) ?? NO_AVATAR
37
+ }
38
+
39
+ export const avatarsFor = cache(
40
+ async (userIds: readonly number[]): Promise<ReadonlyMap<number, string>> => {
41
+ const { avatars } = getContainer()
42
+ if (avatars === null || userIds.length === 0) return new Map()
43
+
44
+ const stored = await avatars.readMany(userIds)
45
+ const urls = new Map<number, string>()
46
+ for (const [userId, avatar] of stored) {
47
+ const url = avatarUrl(userId, avatar)
48
+ if (url !== null) urls.set(userId, url)
49
+ }
50
+ return urls
51
+ },
52
+ )
53
+
54
+ export function canUploadAvatar(actor: Actor): boolean {
55
+ const { authorizer } = getContainer()
56
+ return avatarService() !== null && actor.userId !== null && authorizer.can(actor, 'avatar.upload')
57
+ }
58
+
59
+ export async function resolveAvatar(
60
+ actor: Actor,
61
+ userId: number,
62
+ ): Promise<{ readonly key: string } | null> {
63
+ const { avatars, authorizer } = getContainer()
64
+ if (avatars === null) return null
65
+
66
+ if (!authorizer.can(actor, 'profile.view')) return null
67
+
68
+ const avatar = await avatars.find(userId)
69
+ if (avatar === null || !avatarVisible(avatar) || avatar.key === null) return null
70
+
71
+ return { key: avatar.key }
72
+ }
73
+
74
+ export function requireAvatarService(actor: Actor): AvatarService {
75
+ const service = avatarService()
76
+ if (service === null) {
77
+ throw new ForbiddenError(msg('error.app.board-accept-avatar-uploads'))
78
+ }
79
+ if (actor.userId === null) {
80
+ throw new ForbiddenError(msg('error.app.must-logged-change-avatar'))
81
+ }
82
+ return service
83
+ }
@@ -0,0 +1,7 @@
1
+ 'use server'
2
+
3
+ import { renderLatestPanels } from './board-latest'
4
+
5
+ export async function refreshLatestPanels(): Promise<React.ReactNode> {
6
+ return renderLatestPanels()
7
+ }
@@ -0,0 +1,103 @@
1
+ import 'server-only'
2
+
3
+ import type { Actor } from '@meith/authorization'
4
+ import { contentScopeFrom } from '@meith/core'
5
+ import { getDb, type LatestScope, PostgresLatestRepository } from '@meith/db'
6
+ import { requireSlot, slotCopy } from '@meith/theme-kit'
7
+
8
+ import { getTranslator } from '@/server/i18n'
9
+ import { buildLatestPostsModel, buildLatestThreadsModel } from '@/view/board-latest'
10
+ import { distinctUserIds } from '@/view/member-identity'
11
+
12
+ import { getContainer } from './container'
13
+ import { activeWordFilter } from './content-admin'
14
+ import { getActor } from './context'
15
+ import { identitiesFor } from './group-identity'
16
+ import { filterView, viewerRef } from './plugin-view'
17
+ import { currentTheme } from './theme'
18
+ import { getViewerPreferences } from './viewer-preferences'
19
+
20
+ export const LATEST_ROWS = 5
21
+
22
+ export const LATEST_REFRESH_SECONDS = 60
23
+
24
+ export function latestRepository(): PostgresLatestRepository | null {
25
+ return getContainer().dataSource === 'postgres' ? new PostgresLatestRepository(getDb()) : null
26
+ }
27
+
28
+ export async function latestScopeFor(actor: Actor): Promise<LatestScope> {
29
+ const { authorizer } = getContainer()
30
+ const staff = actor.global.isAdministrator === true || actor.global.isSuperModerator === true
31
+
32
+ return {
33
+ ...(await authorizer.threadAudience(actor)),
34
+ content: contentScopeFrom({ seesUnapproved: staff, seesDeleted: staff }),
35
+ }
36
+ }
37
+
38
+ export async function renderLatestPanels(): Promise<React.ReactNode> {
39
+ const repo = latestRepository()
40
+ if (repo === null) return null
41
+
42
+ const actor = await getActor()
43
+ const now = new Date()
44
+ const translator = await getTranslator()
45
+
46
+ let threadRows: Awaited<ReturnType<PostgresLatestRepository['threads']>>
47
+ let postRows: Awaited<ReturnType<PostgresLatestRepository['posts']>>
48
+ try {
49
+ const scope = await latestScopeFor(actor)
50
+ ;[threadRows, postRows] = await Promise.all([
51
+ repo.threads(LATEST_ROWS, scope),
52
+ repo.posts(LATEST_ROWS, scope),
53
+ ])
54
+ } catch {
55
+ return null
56
+ }
57
+
58
+ const _preferences = await getViewerPreferences()
59
+ const identities = await identitiesFor(
60
+ distinctUserIds([
61
+ ...threadRows.map((row) => row.authorUserId),
62
+ ...postRows.map((row) => row.authorUserId),
63
+ ]),
64
+ )
65
+
66
+ const wordFilter = await activeWordFilter()
67
+
68
+ const theme = await currentTheme()
69
+ const LatestThreads = requireSlot(theme, 'LatestThreads')
70
+ const LatestPosts = requireSlot(theme, 'LatestPosts')
71
+
72
+ const context = viewerRef(actor)
73
+ const [threads, posts] = await Promise.all([
74
+ filterView(
75
+ 'view.latest-threads',
76
+ buildLatestThreadsModel({
77
+ rows: threadRows,
78
+ now,
79
+ t: translator,
80
+ identities,
81
+ }),
82
+ context,
83
+ ),
84
+ filterView(
85
+ 'view.latest-posts',
86
+ buildLatestPostsModel({
87
+ rows: postRows,
88
+ now,
89
+ t: translator,
90
+ identities,
91
+ wordFilter,
92
+ }),
93
+ context,
94
+ ),
95
+ ])
96
+
97
+ return (
98
+ <>
99
+ <LatestThreads {...threads} copy={slotCopy(theme, 'LatestThreads', translator)} />
100
+ <LatestPosts {...posts} copy={slotCopy(theme, 'LatestPosts', translator)} />
101
+ </>
102
+ )
103
+ }
@@ -0,0 +1,23 @@
1
+ import 'server-only'
2
+
3
+ import type { Actor } from '@meith/authorization'
4
+
5
+ import { getContainer } from './container'
6
+ import { getActor } from './context'
7
+ import { tr } from './i18n'
8
+ import { getSettings } from './settings'
9
+
10
+ export interface BoardOffline {
11
+ readonly message: string
12
+ }
13
+
14
+ export async function boardOffline(viewer?: Actor): Promise<BoardOffline | null> {
15
+ const settings = await getSettings()
16
+ if (settings.get('board.offline') !== true) return null
17
+
18
+ const actor = viewer ?? (await getActor())
19
+ if (getContainer().authorizer.can(actor, 'board.viewOffline')) return null
20
+
21
+ const message = settings.get('board.offline_message').trim()
22
+ return { message: message === '' ? await tr('offline.fallback') : message }
23
+ }
@@ -0,0 +1,14 @@
1
+ import 'server-only'
2
+
3
+ import { env } from '@meith/core'
4
+ import { type BoardUrlResolution, resolveBoardUrl } from '@meith/settings'
5
+
6
+ import { getSettings } from './settings'
7
+
8
+ export async function boardUrlResolution(): Promise<BoardUrlResolution> {
9
+ return resolveBoardUrl({ environment: env, settings: await getSettings() })
10
+ }
11
+
12
+ export async function boardUrl(): Promise<string> {
13
+ return (await boardUrlResolution()).url
14
+ }
@@ -0,0 +1,151 @@
1
+ import 'server-only'
2
+
3
+ import { drivers } from '@meith/drivers'
4
+ import { DARK_TOKENS, LIGHT_TOKENS } from '@meith/theme-default'
5
+ import { colourToHex } from '@meith/theme-kit'
6
+
7
+ import { BOARD_TITLE } from '@/view/shell'
8
+
9
+ import { logoKey } from './branding'
10
+ import { contentTypeFor } from './image-upload'
11
+ import { getSettings } from './settings'
12
+ import { getBoardThemeStyle } from './theme-runtime'
13
+ import type { ThemeTokens } from './theme-style'
14
+
15
+ export type BrandScheme = 'light' | 'dark'
16
+
17
+ export interface BrandPalette {
18
+ readonly background: string
19
+ readonly foreground: string
20
+ readonly primary: string
21
+ readonly primaryForeground: string
22
+ readonly muted: string
23
+ readonly border: string
24
+ }
25
+
26
+ export interface BrandInfo {
27
+ readonly title: string
28
+ readonly description: string
29
+ readonly initials: string
30
+ readonly light: BrandPalette
31
+ readonly dark: BrandPalette
32
+ }
33
+
34
+ const EMBEDDABLE_LOGO_TYPES = new Set(['image/png', 'image/jpeg'])
35
+
36
+ const MAX_EMBEDDED_LOGO_BYTES = 192 * 1024
37
+
38
+ const DESCRIPTION_LIMIT = 160
39
+
40
+ const MARK_FONT_STACK = 'system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'
41
+
42
+ function hex(value: string | undefined, fallback: string | undefined, ultimate: string): string {
43
+ return (
44
+ (value !== undefined ? colourToHex(value) : null) ??
45
+ (fallback !== undefined ? colourToHex(fallback) : null) ??
46
+ ultimate
47
+ )
48
+ }
49
+
50
+ function paletteFor(
51
+ tokens: Readonly<Record<string, string>>,
52
+ defaults: Readonly<Record<string, string>>,
53
+ ): BrandPalette {
54
+ return {
55
+ background: hex(tokens.background, defaults.background, '#ffffff'),
56
+ foreground: hex(tokens.foreground, defaults.foreground, '#111111'),
57
+ primary: hex(tokens.primary, defaults.primary, '#111111'),
58
+ primaryForeground: hex(tokens['primary-foreground'], defaults['primary-foreground'], '#ffffff'),
59
+ muted: hex(tokens['muted-foreground'], defaults['muted-foreground'], '#666666'),
60
+ border: hex(tokens.border, defaults.border, '#dddddd'),
61
+ }
62
+ }
63
+
64
+ export function brandInitials(name: string): string {
65
+ const words = name
66
+ .trim()
67
+ .split(/\s+/)
68
+ .filter((word) => word.length > 0)
69
+ if (words.length === 0) return '?'
70
+
71
+ const first = Array.from(words[0] ?? '')[0] ?? ''
72
+ const second = words.length > 1 ? (Array.from(words[1] ?? '')[0] ?? '') : ''
73
+ const letters = `${first}${second}`
74
+ return letters === '' ? '?' : letters.toUpperCase()
75
+ }
76
+
77
+ export function trimDescription(description: string): string {
78
+ const clean = description.trim()
79
+ if (clean.length <= DESCRIPTION_LIMIT) return clean
80
+ return `${clean.slice(0, DESCRIPTION_LIMIT - 1).trimEnd()}…`
81
+ }
82
+
83
+ export async function loadBrandInfo(): Promise<BrandInfo> {
84
+ let title = BOARD_TITLE
85
+ let description = ''
86
+ let tokens: ThemeTokens = { light: LIGHT_TOKENS, dark: DARK_TOKENS }
87
+
88
+ try {
89
+ const settings = await getSettings()
90
+ title = settings.get('board.name').trim() || BOARD_TITLE
91
+ description = settings.get('board.description').trim()
92
+ } catch {
93
+ /* ignore */
94
+ }
95
+
96
+ try {
97
+ tokens = (await getBoardThemeStyle()).defaultTokens
98
+ } catch {
99
+ /* ignore */
100
+ }
101
+
102
+ return {
103
+ title,
104
+ description: trimDescription(description),
105
+ initials: brandInitials(title),
106
+ light: paletteFor(tokens.light, LIGHT_TOKENS),
107
+ dark: paletteFor(tokens.dark, DARK_TOKENS),
108
+ }
109
+ }
110
+
111
+ export async function loadLogoDataUri(scheme: BrandScheme): Promise<string | null> {
112
+ try {
113
+ const key = (await logoKey(scheme)) ?? (await logoKey(scheme === 'light' ? 'dark' : 'light'))
114
+ if (key === null) return null
115
+
116
+ const type = contentTypeFor(key)
117
+ if (!EMBEDDABLE_LOGO_TYPES.has(type)) return null
118
+
119
+ const bytes = await drivers().files.get(key)
120
+ if (bytes === undefined || bytes.byteLength === 0) return null
121
+ if (bytes.byteLength > MAX_EMBEDDED_LOGO_BYTES) return null
122
+
123
+ return `data:${type};base64,${Buffer.from(bytes).toString('base64')}`
124
+ } catch {
125
+ return null
126
+ }
127
+ }
128
+
129
+ function escapeXml(value: string): string {
130
+ return value
131
+ .replaceAll('&', '&amp;')
132
+ .replaceAll('<', '&lt;')
133
+ .replaceAll('>', '&gt;')
134
+ .replaceAll('"', '&quot;')
135
+ .replaceAll("'", '&#39;')
136
+ }
137
+
138
+ export function buildFaviconSvg(info: BrandInfo): string {
139
+ const label = escapeXml(info.initials)
140
+ return [
141
+ `<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">`,
142
+ `<style>`,
143
+ `.brand-bg{fill:${info.light.primary}}.brand-fg{fill:${info.light.primaryForeground}}`,
144
+ `@media(prefers-color-scheme:dark){`,
145
+ `.brand-bg{fill:${info.dark.primary}}.brand-fg{fill:${info.dark.primaryForeground}}}`,
146
+ `</style>`,
147
+ `<rect class="brand-bg" width="64" height="64" rx="14"/>`,
148
+ `<text class="brand-fg" x="32" y="35" text-anchor="middle" dominant-baseline="central" font-family="${MARK_FONT_STACK}" font-size="34" font-weight="700">${label}</text>`,
149
+ `</svg>`,
150
+ ].join('')
151
+ }
@@ -0,0 +1,110 @@
1
+ import 'server-only'
2
+
3
+ import { ImageResponse } from 'next/og'
4
+ import type { ReactElement } from 'react'
5
+
6
+ import { type BrandInfo, loadBrandInfo, loadLogoDataUri } from './brand-assets'
7
+
8
+ interface MarkOptions {
9
+ readonly size: number
10
+ readonly maskable?: boolean
11
+ }
12
+
13
+ function markElement(info: BrandInfo, logo: string | null, options: MarkOptions): ReactElement {
14
+ const palette = info.light
15
+ const safeArea = options.maskable ? 0.62 : 0.76
16
+ const box = Math.round(options.size * safeArea)
17
+
18
+ return (
19
+ <div
20
+ style={{
21
+ width: '100%',
22
+ height: '100%',
23
+ display: 'flex',
24
+ alignItems: 'center',
25
+ justifyContent: 'center',
26
+ background: palette.background,
27
+ }}
28
+ >
29
+ {logo === null ? (
30
+ <div
31
+ style={{
32
+ display: 'flex',
33
+ fontSize: Math.round(box * 0.72),
34
+ fontWeight: 700,
35
+ color: palette.primary,
36
+ }}
37
+ >
38
+ {info.initials}
39
+ </div>
40
+ ) : (
41
+ <img src={logo} width={box} height={box} style={{ objectFit: 'contain' }} alt="" />
42
+ )}
43
+ </div>
44
+ )
45
+ }
46
+
47
+ function socialElement(info: BrandInfo, logo: string | null): ReactElement {
48
+ const palette = info.light
49
+
50
+ return (
51
+ <div
52
+ style={{
53
+ width: '100%',
54
+ height: '100%',
55
+ display: 'flex',
56
+ flexDirection: 'column',
57
+ justifyContent: 'center',
58
+ background: palette.background,
59
+ padding: '80px',
60
+ }}
61
+ >
62
+ {logo !== null && (
63
+ <img src={logo} height={112} style={{ objectFit: 'contain', marginBottom: 44 }} alt="" />
64
+ )}
65
+ <div
66
+ style={{
67
+ display: 'flex',
68
+ fontSize: 74,
69
+ fontWeight: 700,
70
+ color: palette.foreground,
71
+ lineHeight: 1.05,
72
+ }}
73
+ >
74
+ {info.title}
75
+ </div>
76
+ {info.description !== '' && (
77
+ <div style={{ display: 'flex', fontSize: 34, color: palette.muted, marginTop: 28 }}>
78
+ {info.description}
79
+ </div>
80
+ )}
81
+ <div
82
+ style={{
83
+ display: 'flex',
84
+ width: 184,
85
+ height: 14,
86
+ background: palette.primary,
87
+ borderRadius: 7,
88
+ marginTop: 48,
89
+ }}
90
+ />
91
+ </div>
92
+ )
93
+ }
94
+
95
+ export async function renderBrandMark(size: number, maskable = false): Promise<ImageResponse> {
96
+ const info = await loadBrandInfo()
97
+ const logo = await loadLogoDataUri('light')
98
+
99
+ return new ImageResponse(markElement(info, logo, { size, maskable }), {
100
+ width: size,
101
+ height: size,
102
+ })
103
+ }
104
+
105
+ export async function renderBrandSocial(): Promise<ImageResponse> {
106
+ const info = await loadBrandInfo()
107
+ const logo = await loadLogoDataUri('light')
108
+
109
+ return new ImageResponse(socialElement(info, logo), { width: 1200, height: 630 })
110
+ }
@@ -0,0 +1,53 @@
1
+ 'use server'
2
+
3
+ import { ValidationError } from '@meith/core'
4
+ import { msg } from '@meith/i18n'
5
+
6
+ import { recordAdminAction, requireAdmin } from './admin'
7
+ import type { FormState } from './auth-form-state'
8
+ import { isLogoScheme, LOGO_FIELD, removeLogo, saveLogo } from './branding'
9
+ import { formStateReporter } from './form-state-reporter'
10
+
11
+ const toFormState = formStateReporter('branding', 'logo write failed')
12
+
13
+ function scheme(form: FormData): 'light' | 'dark' {
14
+ const value = form.get('scheme')
15
+ if (!isLogoScheme(value)) throw new ValidationError(msg('error.app.such-logo'))
16
+ return value
17
+ }
18
+
19
+ export async function saveLogoAction(_prev: FormState, form: FormData): Promise<FormState> {
20
+ try {
21
+ await requireAdmin()
22
+ const target = scheme(form)
23
+
24
+ const file = form.get(LOGO_FIELD)
25
+ if (!(file instanceof File) || file.size === 0) {
26
+ throw new ValidationError(msg('error.app.choose-image-first'))
27
+ }
28
+
29
+ await saveLogo(target, file)
30
+ await recordAdminAction({
31
+ action: 'branding.logo_saved',
32
+ detail: { scheme: target, bytes: file.size },
33
+ })
34
+
35
+ return { notice: 'saved' }
36
+ } catch (err) {
37
+ return toFormState(err)
38
+ }
39
+ }
40
+
41
+ export async function removeLogoAction(_prev: FormState, form: FormData): Promise<FormState> {
42
+ try {
43
+ await requireAdmin()
44
+ const target = scheme(form)
45
+
46
+ await removeLogo(target)
47
+ await recordAdminAction({ action: 'branding.logo_removed', detail: { scheme: target } })
48
+
49
+ return { notice: 'removed' }
50
+ } catch (err) {
51
+ return toFormState(err)
52
+ }
53
+ }