@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.
- package/LICENSE.md +165 -0
- package/app/(auth)/layout.tsx +6 -0
- package/app/(auth)/login/page.tsx +106 -0
- package/app/(auth)/login/verify/page.tsx +55 -0
- package/app/(auth)/register/page.tsx +83 -0
- package/app/(auth)/reset/confirm/page.tsx +45 -0
- package/app/(auth)/reset/page.tsx +22 -0
- package/app/(auth)/verify/page.tsx +44 -0
- package/app/(auth)/verify/resend/page.tsx +51 -0
- package/app/(board)/[slug]/feed.xml/route.ts +48 -0
- package/app/(board)/[slug]/new/page.tsx +112 -0
- package/app/(board)/[slug]/page.tsx +403 -0
- package/app/(board)/discover/[view]/page.tsx +115 -0
- package/app/(board)/discover/page.tsx +5 -0
- package/app/(board)/layout.tsx +22 -0
- package/app/(board)/member/[id]/page.tsx +187 -0
- package/app/(board)/member/[id]/reputation/page.tsx +195 -0
- package/app/(board)/member/by-name/[name]/page.tsx +32 -0
- package/app/(board)/messages/[id]/page.tsx +103 -0
- package/app/(board)/messages/compose/page.tsx +60 -0
- package/app/(board)/messages/layout.tsx +5 -0
- package/app/(board)/messages/page.tsx +179 -0
- package/app/(board)/modcp/forums/page.tsx +59 -0
- package/app/(board)/modcp/ip/page.tsx +158 -0
- package/app/(board)/modcp/layout.tsx +5 -0
- package/app/(board)/modcp/log/page.tsx +98 -0
- package/app/(board)/modcp/page.tsx +112 -0
- package/app/(board)/moderation/layout.tsx +5 -0
- package/app/(board)/moderation/page.tsx +111 -0
- package/app/(board)/moderation/reports/page.tsx +193 -0
- package/app/(board)/moderation/warn/page.tsx +158 -0
- package/app/(board)/notifications/layout.tsx +5 -0
- package/app/(board)/notifications/page.tsx +144 -0
- package/app/(board)/notifications/preferences/page.tsx +65 -0
- package/app/(board)/online/page.tsx +119 -0
- package/app/(board)/page.tsx +195 -0
- package/app/(board)/plugins/[key]/[[...path]]/page.tsx +72 -0
- package/app/(board)/privacy/page.tsx +12 -0
- package/app/(board)/report/page.tsx +71 -0
- package/app/(board)/rules/page.tsx +12 -0
- package/app/(board)/search/[token]/page.tsx +130 -0
- package/app/(board)/search/page.tsx +289 -0
- package/app/(board)/stats/page.tsx +202 -0
- package/app/(board)/subscriptions/layout.tsx +5 -0
- package/app/(board)/subscriptions/page.tsx +142 -0
- package/app/(board)/terms/page.tsx +12 -0
- package/app/(board)/thread/[slug]/edit/page.tsx +107 -0
- package/app/(board)/thread/[slug]/feed.xml/route.ts +42 -0
- package/app/(board)/thread/[slug]/page.tsx +707 -0
- package/app/(board)/thread/[slug]/poll/page.tsx +41 -0
- package/app/(board)/thread/[slug]/post/[postId]/history/page.tsx +160 -0
- package/app/(board)/thread/[slug]/reply/page.tsx +136 -0
- package/app/(board)/unsubscribe/page.tsx +73 -0
- package/app/(board)/usercp/avatar/page.tsx +56 -0
- package/app/(board)/usercp/contacts/page.tsx +141 -0
- package/app/(board)/usercp/email/confirm/page.tsx +52 -0
- package/app/(board)/usercp/layout.tsx +23 -0
- package/app/(board)/usercp/options/page.tsx +56 -0
- package/app/(board)/usercp/page.tsx +71 -0
- package/app/(board)/usercp/profile/page.tsx +81 -0
- package/app/(board)/usercp/security/page.tsx +238 -0
- package/app/(board)/usercp/security/verify/page.tsx +92 -0
- package/app/(board)/usercp/signature/page.tsx +67 -0
- package/app/admin/antispam/page.tsx +155 -0
- package/app/admin/api/marketplace/screenshot/route.ts +68 -0
- package/app/admin/api/plugins/[key]/[...path]/route.ts +27 -0
- package/app/admin/api-tokens/page.tsx +129 -0
- package/app/admin/content/announcements/page.tsx +110 -0
- package/app/admin/content/attachments/page.tsx +199 -0
- package/app/admin/content/navigation/page.tsx +123 -0
- package/app/admin/content/page.tsx +170 -0
- package/app/admin/forums/[id]/page.tsx +96 -0
- package/app/admin/forums/[id]/permissions/page.tsx +108 -0
- package/app/admin/forums/page.tsx +45 -0
- package/app/admin/groups/[id]/page.tsx +133 -0
- package/app/admin/groups/memberships/page.tsx +56 -0
- package/app/admin/groups/page.tsx +104 -0
- package/app/admin/groups/promotions/page.tsx +143 -0
- package/app/admin/layout.tsx +86 -0
- package/app/admin/log/page.tsx +116 -0
- package/app/admin/page.tsx +206 -0
- package/app/admin/plugins/[key]/[[...path]]/page.tsx +276 -0
- package/app/admin/plugins/browse/page.tsx +70 -0
- package/app/admin/plugins/page.tsx +165 -0
- package/app/admin/security/page.tsx +124 -0
- package/app/admin/settings/page.tsx +190 -0
- package/app/admin/system/page.tsx +303 -0
- package/app/admin/themes/[key]/page.tsx +110 -0
- package/app/admin/themes/browse/page.tsx +70 -0
- package/app/admin/themes/page.tsx +163 -0
- package/app/admin/users/[id]/merge/page.tsx +136 -0
- package/app/admin/users/[id]/page.tsx +208 -0
- package/app/admin/users/mail/page.tsx +68 -0
- package/app/admin/users/page.tsx +260 -0
- package/app/admin/users/prune/page.tsx +186 -0
- package/app/api/health/route.ts +11 -0
- package/app/api/metrics/route.ts +66 -0
- package/app/api/plugins/[key]/[...path]/route.ts +27 -0
- package/app/api/read/all/route.ts +15 -0
- package/app/api/read/forum/[id]/route.ts +29 -0
- package/app/api/read/thread/[id]/route.ts +40 -0
- package/app/api/ready/route.ts +63 -0
- package/app/api/system/tick/route.ts +77 -0
- package/app/api/v1/[...path]/route.ts +208 -0
- package/app/api/v1/openapi.json/route.ts +12 -0
- package/app/apple-icon.ts +11 -0
- package/app/atom.xml/route.ts +7 -0
- package/app/attachment/[id]/route.ts +10 -0
- package/app/attachment/[id]/thumb/route.ts +10 -0
- package/app/auth/passkey/options/route.ts +145 -0
- package/app/auth/passkey/verify/route.ts +241 -0
- package/app/auth/resume/route.ts +55 -0
- package/app/auth/sso/[provider]/callback/route.ts +173 -0
- package/app/auth/sso/[provider]/go/route.ts +57 -0
- package/app/auth/sso/[provider]/route.ts +105 -0
- package/app/avatar/[id]/route.ts +31 -0
- package/app/brand/icon-192.png/route.ts +7 -0
- package/app/brand/icon-512.png/route.ts +7 -0
- package/app/brand/icon-maskable.png/route.ts +7 -0
- package/app/demo/stripe/[[...path]]/route.ts +27 -0
- package/app/error.tsx +15 -0
- package/app/feed.xml/route.ts +7 -0
- package/app/forumdisplay.php/page.tsx +11 -0
- package/app/group/[id]/badge/[scheme]/route.ts +26 -0
- package/app/icon.ts +13 -0
- package/app/install/page.tsx +221 -0
- package/app/jump/page.tsx +28 -0
- package/app/layout.tsx +124 -0
- package/app/logo/[scheme]/route.ts +24 -0
- package/app/manifest.ts +43 -0
- package/app/member.php/page.tsx +11 -0
- package/app/memberlist.php/page.tsx +11 -0
- package/app/not-found.tsx +14 -0
- package/app/opengraph-image.ts +11 -0
- package/app/redirect/page.tsx +44 -0
- package/app/robots.txt/route.ts +35 -0
- package/app/showthread.php/page.tsx +11 -0
- package/app/sitemap/[page]/route.ts +64 -0
- package/app/sitemap.xml/route.ts +31 -0
- package/app/twitter-image.ts +11 -0
- package/app/viewforum.php/page.tsx +11 -0
- package/app/viewtopic.php/page.tsx +11 -0
- package/bin/forum-web.mjs +241 -0
- package/components.json +21 -0
- package/instrumentation.ts +18 -0
- package/next.config.mjs +182 -0
- package/package.json +99 -0
- package/postcss.config.mjs +7 -0
- package/proxy.ts +160 -0
- package/src/components/account/active-sessions.tsx +87 -0
- package/src/components/account/avatar-form.tsx +119 -0
- package/src/components/account/credential-proof-form.tsx +76 -0
- package/src/components/account/logout-form.tsx +14 -0
- package/src/components/account/notification-forms.tsx +157 -0
- package/src/components/account/passkey-enrol.tsx +81 -0
- package/src/components/account/passkey-proof-button.tsx +47 -0
- package/src/components/account/push-device-form.tsx +193 -0
- package/src/components/account/relation-forms.tsx +69 -0
- package/src/components/account/reputation-forms.tsx +124 -0
- package/src/components/account/security-activity.tsx +46 -0
- package/src/components/account/sign-in-methods.tsx +166 -0
- package/src/components/account/subscription-forms.tsx +151 -0
- package/src/components/account/two-factor-forms.tsx +226 -0
- package/src/components/account/usercp-forms.tsx +387 -0
- package/src/components/account/usercp-nav.tsx +14 -0
- package/src/components/account/usercp-shell.tsx +34 -0
- package/src/components/admin/admin-forms.tsx +92 -0
- package/src/components/admin/admin-nav.tsx +52 -0
- package/src/components/admin/admin-undo.tsx +32 -0
- package/src/components/admin/api-token-forms.tsx +89 -0
- package/src/components/admin/badge-forms.tsx +97 -0
- package/src/components/admin/branding-forms.tsx +102 -0
- package/src/components/admin/content-forms.tsx +712 -0
- package/src/components/admin/form-bits.tsx +11 -0
- package/src/components/admin/forum-forms.tsx +524 -0
- package/src/components/admin/forum-tree.tsx +454 -0
- package/src/components/admin/group-forms.tsx +646 -0
- package/src/components/admin/mail-test-card.tsx +90 -0
- package/src/components/admin/marketplace-forms.tsx +30 -0
- package/src/components/admin/marketplace-listing.tsx +123 -0
- package/src/components/admin/navigation-forms.tsx +229 -0
- package/src/components/admin/navigation-tree.tsx +442 -0
- package/src/components/admin/oklch-picker.tsx +180 -0
- package/src/components/admin/plugin-forms.tsx +225 -0
- package/src/components/admin/settings-form.tsx +148 -0
- package/src/components/admin/system-forms.tsx +169 -0
- package/src/components/admin/theme-changes.tsx +354 -0
- package/src/components/admin/theme-forms.tsx +636 -0
- package/src/components/admin/theme-palette.tsx +119 -0
- package/src/components/admin/theme-preview.tsx +461 -0
- package/src/components/admin/user-bulk-toolbar.tsx +99 -0
- package/src/components/admin/user-forms.tsx +488 -0
- package/src/components/auth/auth-page.tsx +45 -0
- package/src/components/auth/form-controls.tsx +117 -0
- package/src/components/auth/login-form.tsx +47 -0
- package/src/components/auth/passkey-client.ts +171 -0
- package/src/components/auth/passkey-second-factor.tsx +69 -0
- package/src/components/auth/passkey-sign-in.tsx +61 -0
- package/src/components/auth/register-form.tsx +136 -0
- package/src/components/auth/resend-verification-form.tsx +34 -0
- package/src/components/auth/reset-confirm-form.tsx +43 -0
- package/src/components/auth/reset-request-form.tsx +35 -0
- package/src/components/auth/second-factor-form.tsx +51 -0
- package/src/components/auth/sso-buttons.tsx +47 -0
- package/src/components/board/live-region.tsx +74 -0
- package/src/components/board/search-off-notice.tsx +18 -0
- package/src/components/board/section-page.tsx +114 -0
- package/src/components/content/attachment-field.tsx +51 -0
- package/src/components/content/composer-field.ts +31 -0
- package/src/components/content/composer-ids.ts +6 -0
- package/src/components/content/composer-intents.tsx +27 -0
- package/src/components/content/composer-recovery.tsx +215 -0
- package/src/components/content/edit-post-form.tsx +121 -0
- package/src/components/content/markdown-editor.tsx +590 -0
- package/src/components/content/markdown-syntax.ts +53 -0
- package/src/components/content/mention-token.ts +23 -0
- package/src/components/content/multiquote-button.tsx +32 -0
- package/src/components/content/multiquote-selection.tsx +105 -0
- package/src/components/content/multiquote.ts +125 -0
- package/src/components/content/new-thread-form.tsx +169 -0
- package/src/components/content/poll-edit-form.tsx +96 -0
- package/src/components/content/poll.tsx +89 -0
- package/src/components/content/quote-in-place.tsx +51 -0
- package/src/components/content/reply-form.tsx +113 -0
- package/src/components/content/thanks-button.tsx +65 -0
- package/src/components/content/thread-rating.tsx +143 -0
- package/src/components/install/install-form.tsx +497 -0
- package/src/components/legal/legal-document.tsx +22 -0
- package/src/components/messages/message-forms.tsx +152 -0
- package/src/components/moderation/inline-moderation-form.tsx +158 -0
- package/src/components/moderation/modcp-nav.tsx +15 -0
- package/src/components/moderation/modcp-shell.tsx +41 -0
- package/src/components/moderation/queue-form.tsx +96 -0
- package/src/components/moderation/report-forms.tsx +120 -0
- package/src/components/moderation/signature-lock-form.tsx +83 -0
- package/src/components/moderation/thread-surgery-form.tsx +96 -0
- package/src/components/moderation/thread-tools-form.tsx +137 -0
- package/src/components/moderation/warning-forms.tsx +125 -0
- package/src/components/profile/custom-field.tsx +79 -0
- package/src/components/shell/appearance-icons.tsx +35 -0
- package/src/components/shell/board-notice.tsx +21 -0
- package/src/components/shell/copy-record.ts +20 -0
- package/src/components/shell/copy.tsx +17 -0
- package/src/components/shell/crash-notice.tsx +86 -0
- package/src/components/shell/demo-banner.tsx +49 -0
- package/src/components/shell/group-name-style.tsx +6 -0
- package/src/components/shell/measure.ts +1 -0
- package/src/components/shell/notification-menu.tsx +361 -0
- package/src/components/shell/offline-notice.tsx +29 -0
- package/src/components/shell/onboarding-banner.tsx +39 -0
- package/src/components/shell/page-shell.tsx +167 -0
- package/src/components/shell/panel-list.tsx +95 -0
- package/src/components/shell/panel-nav.tsx +40 -0
- package/src/components/shell/panel-overview.tsx +81 -0
- package/src/components/shell/panel-page.tsx +96 -0
- package/src/components/shell/panel-pagination.tsx +28 -0
- package/src/components/shell/panel-shell.tsx +31 -0
- package/src/components/shell/theme-runtime-style.tsx +6 -0
- package/src/components/shell/theme-switcher.tsx +97 -0
- package/src/components/shell/timezone-probe.tsx +45 -0
- package/src/components/shell/view-tabs.tsx +60 -0
- package/src/config.ts +13 -0
- package/src/server/absent-services.ts +29 -0
- package/src/server/admin-actions.ts +171 -0
- package/src/server/admin-settings-actions.ts +83 -0
- package/src/server/admin-undo-actions.ts +70 -0
- package/src/server/admin-undo.ts +75 -0
- package/src/server/admin.ts +112 -0
- package/src/server/announcements.ts +82 -0
- package/src/server/antispam.ts +310 -0
- package/src/server/api/content.ts +486 -0
- package/src/server/api/http.ts +119 -0
- package/src/server/api/members.ts +109 -0
- package/src/server/api/messages.ts +113 -0
- package/src/server/api/registry.ts +30 -0
- package/src/server/api/search.ts +98 -0
- package/src/server/api/subscriptions.ts +106 -0
- package/src/server/api-auth.ts +66 -0
- package/src/server/api-token-actions.ts +85 -0
- package/src/server/api-tokens-admin.ts +95 -0
- package/src/server/appearance-actions.ts +39 -0
- package/src/server/attachment-download.ts +43 -0
- package/src/server/attachment-embed.ts +63 -0
- package/src/server/attachment-upload-actions.ts +92 -0
- package/src/server/attachments.ts +257 -0
- package/src/server/auth-actions.ts +479 -0
- package/src/server/auth-config.ts +61 -0
- package/src/server/auth-events.ts +92 -0
- package/src/server/auth-form-state.ts +14 -0
- package/src/server/auth-mail.ts +104 -0
- package/src/server/avatars.ts +83 -0
- package/src/server/board-latest-actions.ts +7 -0
- package/src/server/board-latest.tsx +103 -0
- package/src/server/board-offline.ts +23 -0
- package/src/server/board-url.ts +14 -0
- package/src/server/brand-assets.ts +151 -0
- package/src/server/brand-mark.tsx +110 -0
- package/src/server/branding-actions.ts +53 -0
- package/src/server/branding.ts +101 -0
- package/src/server/cache-targets.ts +13 -0
- package/src/server/code-highlighting.ts +33 -0
- package/src/server/container.ts +402 -0
- package/src/server/content-actions.ts +459 -0
- package/src/server/content-admin-actions.ts +492 -0
- package/src/server/content-admin.ts +95 -0
- package/src/server/content-security-policy.ts +35 -0
- package/src/server/context.ts +32 -0
- package/src/server/cookies.ts +139 -0
- package/src/server/credential-proof-actions.ts +42 -0
- package/src/server/credential-proof.ts +35 -0
- package/src/server/current-location.ts +19 -0
- package/src/server/demo-stripe.ts +104 -0
- package/src/server/demo-uploads.ts +28 -0
- package/src/server/demo.ts +80 -0
- package/src/server/discovery.ts +104 -0
- package/src/server/embeds.ts +113 -0
- package/src/server/error-notice.tsx +41 -0
- package/src/server/federation-actions.ts +113 -0
- package/src/server/federation.ts +138 -0
- package/src/server/feed-builder.ts +72 -0
- package/src/server/feed-routes.ts +122 -0
- package/src/server/fixture-actor-source.ts +62 -0
- package/src/server/fixture-forum-repo.ts +59 -0
- package/src/server/fixture-member-profile-repo.ts +30 -0
- package/src/server/fixture-post-repo.ts +108 -0
- package/src/server/fixture-thread-repo.ts +98 -0
- package/src/server/form-state-reporter.ts +34 -0
- package/src/server/form-values.ts +23 -0
- package/src/server/forum-admin-actions.ts +353 -0
- package/src/server/forum-admin.ts +98 -0
- package/src/server/group-admin-actions.ts +341 -0
- package/src/server/group-admin.ts +98 -0
- package/src/server/group-badge-actions.ts +61 -0
- package/src/server/group-badge.ts +62 -0
- package/src/server/group-identity.ts +104 -0
- package/src/server/i18n-catalogs.ts +42 -0
- package/src/server/i18n.ts +69 -0
- package/src/server/image-upload.ts +109 -0
- package/src/server/inline-moderation-actions.ts +129 -0
- package/src/server/install-actions.ts +124 -0
- package/src/server/install.ts +281 -0
- package/src/server/legacy-redirect.ts +83 -0
- package/src/server/legal.ts +43 -0
- package/src/server/location-header.ts +37 -0
- package/src/server/mail-brand.ts +43 -0
- package/src/server/mail-health.ts +41 -0
- package/src/server/mail-send.ts +44 -0
- package/src/server/mail-test-actions.ts +67 -0
- package/src/server/mail-test.ts +56 -0
- package/src/server/markdown-pipeline.ts +31 -0
- package/src/server/marketplace-actions.ts +47 -0
- package/src/server/marketplace-admin.ts +260 -0
- package/src/server/mention-actions.ts +21 -0
- package/src/server/mention-search.ts +35 -0
- package/src/server/message-actions.ts +110 -0
- package/src/server/messages.ts +141 -0
- package/src/server/modcp.ts +125 -0
- package/src/server/moderation-actions.ts +165 -0
- package/src/server/navigation-admin-actions.ts +217 -0
- package/src/server/navigation.ts +112 -0
- package/src/server/nonce.ts +13 -0
- package/src/server/notification-actions.ts +84 -0
- package/src/server/notification-audience.ts +10 -0
- package/src/server/notification-menu-actions.ts +82 -0
- package/src/server/notification-menu.ts +96 -0
- package/src/server/notifications.ts +93 -0
- package/src/server/onboarding-actions.ts +27 -0
- package/src/server/onboarding.ts +89 -0
- package/src/server/page-metadata.ts +14 -0
- package/src/server/passkey-challenge.ts +42 -0
- package/src/server/plugin-admin-actions.ts +232 -0
- package/src/server/plugin-admin.ts +276 -0
- package/src/server/plugin-host.ts +143 -0
- package/src/server/plugin-pages.ts +192 -0
- package/src/server/plugin-panel.ts +49 -0
- package/src/server/plugin-routes.ts +298 -0
- package/src/server/plugin-view.tsx +58 -0
- package/src/server/poll-actions.ts +104 -0
- package/src/server/poll-scope.ts +52 -0
- package/src/server/post-notifications.ts +71 -0
- package/src/server/post-scope.ts +86 -0
- package/src/server/presence.ts +177 -0
- package/src/server/profile-fields.ts +112 -0
- package/src/server/push-actions.ts +113 -0
- package/src/server/push.ts +40 -0
- package/src/server/redirect-back.ts +15 -0
- package/src/server/registration.ts +7 -0
- package/src/server/relation-actions.ts +89 -0
- package/src/server/relations.ts +45 -0
- package/src/server/reply-core.ts +173 -0
- package/src/server/report-actions.ts +165 -0
- package/src/server/report-scope.ts +21 -0
- package/src/server/reputation-actions.ts +146 -0
- package/src/server/reputation-target.ts +49 -0
- package/src/server/reputation.ts +70 -0
- package/src/server/request-fingerprint.ts +65 -0
- package/src/server/safe-path.ts +16 -0
- package/src/server/same-origin.ts +45 -0
- package/src/server/search-page.ts +313 -0
- package/src/server/search.ts +45 -0
- package/src/server/secret-auth.ts +20 -0
- package/src/server/see-other.ts +11 -0
- package/src/server/seed-board.ts +417 -0
- package/src/server/session-actions.ts +78 -0
- package/src/server/session-cookies.ts +124 -0
- package/src/server/session-key.ts +17 -0
- package/src/server/settings.ts +47 -0
- package/src/server/signatures.ts +62 -0
- package/src/server/sso-handshake.ts +79 -0
- package/src/server/stats.ts +63 -0
- package/src/server/subscription-actions.ts +161 -0
- package/src/server/surgery-actions.ts +180 -0
- package/src/server/syndication.ts +77 -0
- package/src/server/system-admin-actions.ts +134 -0
- package/src/server/system-admin.ts +90 -0
- package/src/server/test-container.ts +114 -0
- package/src/server/theme-admin-actions.ts +242 -0
- package/src/server/theme-admin.ts +203 -0
- package/src/server/theme-runtime.ts +89 -0
- package/src/server/theme-style.ts +279 -0
- package/src/server/theme.ts +52 -0
- package/src/server/thread-core.ts +207 -0
- package/src/server/thread-rating-actions.ts +55 -0
- package/src/server/thread-tool-actions.ts +154 -0
- package/src/server/two-factor-actions.ts +178 -0
- package/src/server/two-factor.ts +86 -0
- package/src/server/upgrade-notice.ts +41 -0
- package/src/server/user-admin-actions.ts +572 -0
- package/src/server/user-admin.ts +165 -0
- package/src/server/usercp-actions.ts +263 -0
- package/src/server/usercp-mail.ts +120 -0
- package/src/server/viewer-preferences.ts +64 -0
- package/src/server/warning-actions.ts +122 -0
- package/src/styles/globals.css +969 -0
- package/src/theme/contract.fixture.ts +793 -0
- package/src/view/account-copy.ts +351 -0
- package/src/view/admin-content-copy.ts +66 -0
- package/src/view/admin-copy.ts +106 -0
- package/src/view/admin-forum-tree-copy.ts +40 -0
- package/src/view/admin-group-copy.ts +80 -0
- package/src/view/admin-log.ts +58 -0
- package/src/view/admin-nav.ts +164 -0
- package/src/view/admin-navigation-copy.ts +58 -0
- package/src/view/admin-panel-copy.ts +194 -0
- package/src/view/admin-settings.ts +173 -0
- package/src/view/admin-theme-copy.ts +229 -0
- package/src/view/admin-user-copy.ts +96 -0
- package/src/view/attachments.ts +51 -0
- package/src/view/auth-copy.ts +147 -0
- package/src/view/board-index.ts +167 -0
- package/src/view/board-latest.ts +101 -0
- package/src/view/breadcrumb.ts +50 -0
- package/src/view/contacts.ts +85 -0
- package/src/view/content-copy.ts +118 -0
- package/src/view/contrast.ts +286 -0
- package/src/view/copy.ts +101 -0
- package/src/view/count.ts +8 -0
- package/src/view/discovery-view.ts +74 -0
- package/src/view/editor-toolbar.ts +84 -0
- package/src/view/error-notice.ts +52 -0
- package/src/view/feed.ts +130 -0
- package/src/view/forum-cursor.ts +54 -0
- package/src/view/forum-display.ts +152 -0
- package/src/view/forum-jump.ts +60 -0
- package/src/view/forum-notice.ts +24 -0
- package/src/view/inline-moderation.ts +87 -0
- package/src/view/install-copy.ts +95 -0
- package/src/view/legal.ts +58 -0
- package/src/view/locale.ts +40 -0
- package/src/view/marketplace-panel.ts +51 -0
- package/src/view/member-identity.ts +23 -0
- package/src/view/member-profile.ts +65 -0
- package/src/view/messages.ts +222 -0
- package/src/view/metadata.ts +75 -0
- package/src/view/modcp-nav.ts +76 -0
- package/src/view/moderation-copy.ts +84 -0
- package/src/view/moderation-queue.ts +67 -0
- package/src/view/navigation-arrange.ts +188 -0
- package/src/view/navigation.ts +173 -0
- package/src/view/notification-menu.ts +202 -0
- package/src/view/notifications.ts +84 -0
- package/src/view/onboarding.ts +26 -0
- package/src/view/pager.ts +191 -0
- package/src/view/panel-nav.ts +203 -0
- package/src/view/plugin-panel.ts +42 -0
- package/src/view/post-form.ts +61 -0
- package/src/view/post-link.ts +29 -0
- package/src/view/presence.ts +99 -0
- package/src/view/promotion-rules.ts +52 -0
- package/src/view/redirect-notice.ts +21 -0
- package/src/view/reputation.ts +107 -0
- package/src/view/search-controls.ts +103 -0
- package/src/view/search-results.ts +291 -0
- package/src/view/security-activity.ts +73 -0
- package/src/view/setting-groups.ts +43 -0
- package/src/view/shell.ts +164 -0
- package/src/view/slug-id.ts +12 -0
- package/src/view/sso-hand-off.ts +44 -0
- package/src/view/sso-notices.ts +48 -0
- package/src/view/subscriptions.ts +76 -0
- package/src/view/theme-draft.ts +167 -0
- package/src/view/theme-preference.ts +30 -0
- package/src/view/theme-tokens.ts +333 -0
- package/src/view/thread-view.ts +280 -0
- package/src/view/time.ts +31 -0
- package/src/view/timezone.ts +35 -0
- package/src/view/two-factor.ts +6 -0
- package/src/view/usercp-nav.ts +103 -0
- package/src/view/usercp.ts +196 -0
- package/src/view/warnings.ts +128 -0
- package/src/view/word-filter.ts +5 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { msg } from '@meith/i18n'
|
|
2
|
+
import 'server-only'
|
|
3
|
+
|
|
4
|
+
import forumConfig from '@board/config'
|
|
5
|
+
|
|
6
|
+
import { ForbiddenError } from '@meith/core'
|
|
7
|
+
import { getDb, PostgresThemeAdminRepository, type ThemeRecord } from '@meith/db'
|
|
8
|
+
|
|
9
|
+
import type { EditableToken } from '@/view/theme-draft'
|
|
10
|
+
import { tokenCopy } from '@/view/theme-tokens'
|
|
11
|
+
|
|
12
|
+
import { getContainer } from './container'
|
|
13
|
+
import { getTranslator } from './i18n'
|
|
14
|
+
import { type TokenOverrides, validateTokenOverrides } from './theme-style'
|
|
15
|
+
|
|
16
|
+
export function themeAdminRepository(): PostgresThemeAdminRepository | null {
|
|
17
|
+
return getContainer().dataSource === 'postgres' ? new PostgresThemeAdminRepository(getDb()) : null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function requireThemeAdmin(): PostgresThemeAdminRepository {
|
|
21
|
+
const repository = themeAdminRepository()
|
|
22
|
+
if (repository === null) {
|
|
23
|
+
throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-24'))
|
|
24
|
+
}
|
|
25
|
+
return repository
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type TokenRow = EditableToken
|
|
29
|
+
|
|
30
|
+
export interface ThemeAdminView {
|
|
31
|
+
readonly key: string
|
|
32
|
+
readonly title: string
|
|
33
|
+
readonly isBuildTheme: boolean
|
|
34
|
+
readonly isDefault: boolean
|
|
35
|
+
readonly enabled: boolean
|
|
36
|
+
readonly tokens: readonly TokenRow[]
|
|
37
|
+
readonly customCss: string
|
|
38
|
+
readonly customised: boolean
|
|
39
|
+
readonly updatedAt: Date | null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ThemeListing {
|
|
43
|
+
readonly key: string
|
|
44
|
+
readonly title: string
|
|
45
|
+
readonly isBuildTheme: boolean
|
|
46
|
+
readonly isDefault: boolean
|
|
47
|
+
readonly enabled: boolean
|
|
48
|
+
readonly overriddenTokens: number
|
|
49
|
+
readonly hasCustomCss: boolean
|
|
50
|
+
readonly updatedAt: Date | null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const buildThemeKey = forumConfig.defaultTheme
|
|
54
|
+
|
|
55
|
+
function countOverrides(raw: unknown): number {
|
|
56
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return 0
|
|
57
|
+
|
|
58
|
+
const record = raw as Record<string, unknown>
|
|
59
|
+
const scheme = (value: unknown): string[] =>
|
|
60
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
61
|
+
? Object.keys(value as Record<string, unknown>)
|
|
62
|
+
: []
|
|
63
|
+
|
|
64
|
+
const keys = Object.keys(record)
|
|
65
|
+
if (keys.length > 0 && keys.every((key) => key === 'light' || key === 'dark')) {
|
|
66
|
+
return new Set([...scheme(record.light), ...scheme(record.dark)]).size
|
|
67
|
+
}
|
|
68
|
+
return keys.length
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function themeListing(): Promise<readonly ThemeListing[]> {
|
|
72
|
+
const repository = themeAdminRepository()
|
|
73
|
+
const rows = repository === null ? [] : await repository.list()
|
|
74
|
+
const byKey = new Map(rows.map((row) => [row.key, row]))
|
|
75
|
+
|
|
76
|
+
const registered = Object.values(forumConfig.themes)
|
|
77
|
+
const claimed = registered.find((theme) => byKey.get(theme.key)?.isDefault === true)
|
|
78
|
+
|
|
79
|
+
return registered.map((theme) => {
|
|
80
|
+
const row = byKey.get(theme.key)
|
|
81
|
+
const enabled = row?.enabled !== false
|
|
82
|
+
return {
|
|
83
|
+
key: theme.key,
|
|
84
|
+
title: theme.title,
|
|
85
|
+
isBuildTheme: theme.key === buildThemeKey,
|
|
86
|
+
isDefault:
|
|
87
|
+
claimed === undefined || byKey.get(claimed.key)?.enabled === false
|
|
88
|
+
? theme.key === buildThemeKey
|
|
89
|
+
: claimed.key === theme.key,
|
|
90
|
+
enabled,
|
|
91
|
+
overriddenTokens: countOverrides(row?.tokenOverrides),
|
|
92
|
+
hasCustomCss: (row?.customCss ?? null) !== null,
|
|
93
|
+
updatedAt: row?.updatedAt ?? null,
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function buildThemeAdminView(key: string): Promise<ThemeAdminView | null> {
|
|
99
|
+
const t = await getTranslator()
|
|
100
|
+
const installed = forumConfig.themes[key]
|
|
101
|
+
const repository = themeAdminRepository()
|
|
102
|
+
if (installed === undefined || repository === null) return null
|
|
103
|
+
|
|
104
|
+
const record: ThemeRecord | null = await repository.read(key)
|
|
105
|
+
|
|
106
|
+
let overrides: TokenOverrides = { light: {}, dark: {} }
|
|
107
|
+
try {
|
|
108
|
+
overrides = validateTokenOverrides(installed.tokens, record?.tokenOverrides)
|
|
109
|
+
} catch {
|
|
110
|
+
/* ignore */
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const listing = (await themeListing()).find((entry) => entry.key === key)
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
key,
|
|
117
|
+
title: installed.title,
|
|
118
|
+
isBuildTheme: key === buildThemeKey,
|
|
119
|
+
isDefault: listing?.isDefault ?? key === buildThemeKey,
|
|
120
|
+
enabled: listing?.enabled ?? true,
|
|
121
|
+
tokens: Object.keys(installed.tokens.light).map((name) => {
|
|
122
|
+
const light = installed.tokens.light[name] ?? ''
|
|
123
|
+
const dark = installed.tokens.dark[name] ?? ''
|
|
124
|
+
const meta = tokenCopy(name, t)
|
|
125
|
+
return {
|
|
126
|
+
name,
|
|
127
|
+
label: meta.label,
|
|
128
|
+
hint: meta.hint,
|
|
129
|
+
kind: meta.kind,
|
|
130
|
+
light,
|
|
131
|
+
dark,
|
|
132
|
+
overrideLight: overrides.light[name] ?? '',
|
|
133
|
+
overrideDark: overrides.dark[name] ?? '',
|
|
134
|
+
}
|
|
135
|
+
}),
|
|
136
|
+
customCss: record?.customCss ?? '',
|
|
137
|
+
customised: record !== null,
|
|
138
|
+
updatedAt: record?.updatedAt ?? null,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function themeTokens(
|
|
143
|
+
key: string,
|
|
144
|
+
): { light: Readonly<Record<string, string>>; dark: Readonly<Record<string, string>> } | null {
|
|
145
|
+
const installed = forumConfig.themes[key]
|
|
146
|
+
return installed === undefined
|
|
147
|
+
? null
|
|
148
|
+
: { light: installed.tokens.light, dark: installed.tokens.dark }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function themeTitle(key: string): string | null {
|
|
152
|
+
return forumConfig.themes[key]?.title ?? null
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function isBuildTheme(key: string): boolean {
|
|
156
|
+
return key === buildThemeKey
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface SampleSurface {
|
|
160
|
+
readonly background: string
|
|
161
|
+
readonly foreground: string
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function boardSampleSurfaces(): Promise<{
|
|
165
|
+
readonly light: SampleSurface
|
|
166
|
+
readonly dark: SampleSurface
|
|
167
|
+
}> {
|
|
168
|
+
const listing = await themeListing().catch(() => [])
|
|
169
|
+
const key = listing.find((entry) => entry.isDefault)?.key ?? buildThemeKey
|
|
170
|
+
const theme = forumConfig.themes[key] ?? forumConfig.themes[buildThemeKey]
|
|
171
|
+
|
|
172
|
+
const light = theme?.tokens.light ?? {}
|
|
173
|
+
const dark = theme?.tokens.dark ?? {}
|
|
174
|
+
|
|
175
|
+
let overrides: TokenOverrides = { light: {}, dark: {} }
|
|
176
|
+
if (theme !== undefined) {
|
|
177
|
+
const record = await themeAdminRepository()
|
|
178
|
+
?.read(key)
|
|
179
|
+
.catch(() => null)
|
|
180
|
+
try {
|
|
181
|
+
overrides = validateTokenOverrides(theme.tokens, record?.tokenOverrides)
|
|
182
|
+
} catch {
|
|
183
|
+
/* ignore */
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const pick = (
|
|
188
|
+
shipped: Readonly<Record<string, string>>,
|
|
189
|
+
override: Readonly<Record<string, string>>,
|
|
190
|
+
name: string,
|
|
191
|
+
): string => override[name] ?? shipped[name] ?? ''
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
light: {
|
|
195
|
+
background: pick(light, overrides.light, 'card'),
|
|
196
|
+
foreground: pick(light, overrides.light, 'card-foreground'),
|
|
197
|
+
},
|
|
198
|
+
dark: {
|
|
199
|
+
background: pick(dark, overrides.dark, 'card'),
|
|
200
|
+
foreground: pick(dark, overrides.dark, 'card-foreground'),
|
|
201
|
+
},
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import forumConfig from '@board/config'
|
|
4
|
+
import { cache } from 'react'
|
|
5
|
+
|
|
6
|
+
import { CacheTags, cachedGlobal, env } from '@meith/core'
|
|
7
|
+
import { getDb, PostgresThemeRepository, type ThemeRuntimeState } from '@meith/db'
|
|
8
|
+
import { drivers } from '@meith/drivers'
|
|
9
|
+
import { DARK_TOKENS as DEFAULT_DARK, LIGHT_TOKENS as DEFAULT_LIGHT } from '@meith/theme-default'
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
type BoardTheme,
|
|
13
|
+
effectiveTokens,
|
|
14
|
+
renderBoardStyle,
|
|
15
|
+
type ThemeRuntimeStyle,
|
|
16
|
+
type ThemeTokens,
|
|
17
|
+
} from './theme-style'
|
|
18
|
+
|
|
19
|
+
export interface ThemeChoice {
|
|
20
|
+
readonly key: string
|
|
21
|
+
readonly title: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface BoardThemeStyle extends ThemeRuntimeStyle {
|
|
25
|
+
readonly choices: readonly ThemeChoice[]
|
|
26
|
+
readonly defaultKey: string
|
|
27
|
+
readonly defaultTokens: ThemeTokens
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const registered = Object.values(forumConfig.themes)
|
|
31
|
+
const buildTheme = forumConfig.themes[forumConfig.defaultTheme]!
|
|
32
|
+
|
|
33
|
+
const COMPILED_TOKENS = { light: DEFAULT_LIGHT, dark: DEFAULT_DARK }
|
|
34
|
+
|
|
35
|
+
function shippedStyle(): BoardThemeStyle {
|
|
36
|
+
return composeBoard([])
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function composeBoard(rows: readonly ThemeRuntimeState[]): BoardThemeStyle {
|
|
40
|
+
const byKey = new Map(rows.map((row) => [row.key, row]))
|
|
41
|
+
|
|
42
|
+
const enabled = registered.filter((theme) => byKey.get(theme.key)?.enabled !== false)
|
|
43
|
+
|
|
44
|
+
if (!enabled.some((theme) => theme.key === buildTheme.key)) enabled.unshift(buildTheme)
|
|
45
|
+
|
|
46
|
+
const claimed = registered.find((theme) => byKey.get(theme.key)?.isDefault === true)
|
|
47
|
+
const defaultKey =
|
|
48
|
+
claimed !== undefined && enabled.includes(claimed) ? claimed.key : buildTheme.key
|
|
49
|
+
|
|
50
|
+
const themes: BoardTheme[] = enabled.map((theme) => ({
|
|
51
|
+
key: theme.key,
|
|
52
|
+
tokens: theme.tokens,
|
|
53
|
+
overrides: byKey.get(theme.key)?.tokenOverrides,
|
|
54
|
+
customCss: byKey.get(theme.key)?.customCss ?? null,
|
|
55
|
+
}))
|
|
56
|
+
|
|
57
|
+
const active = themes.find((theme) => theme.key === defaultKey)
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
...renderBoardStyle({ themes, defaultKey, baseline: COMPILED_TOKENS }),
|
|
61
|
+
choices: enabled.map((theme) => ({ key: theme.key, title: theme.title })),
|
|
62
|
+
defaultKey,
|
|
63
|
+
defaultTokens: active === undefined ? COMPILED_TOKENS : effectiveTokens(active),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const TTL_SECONDS = 60
|
|
68
|
+
|
|
69
|
+
function loadPostgresBoardStyle(): Promise<BoardThemeStyle> {
|
|
70
|
+
return cachedGlobal(
|
|
71
|
+
drivers().cache,
|
|
72
|
+
{
|
|
73
|
+
key: ['board-theme-style'],
|
|
74
|
+
tags: registered.map((theme) => CacheTags.theme(theme.key)),
|
|
75
|
+
revalidate: TTL_SECONDS,
|
|
76
|
+
},
|
|
77
|
+
async () => composeBoard(await new PostgresThemeRepository(getDb()).listRuntime()),
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const getBoardThemeStyle = cache(async (): Promise<BoardThemeStyle> => {
|
|
82
|
+
if (env.DATA_SOURCE === 'fixture') return shippedStyle()
|
|
83
|
+
|
|
84
|
+
return loadPostgresBoardStyle().catch(() => shippedStyle())
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
export async function getThemeRuntimeStyle(): Promise<ThemeRuntimeStyle> {
|
|
88
|
+
return getBoardThemeStyle()
|
|
89
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { colourToHex } from '@meith/theme-kit'
|
|
2
|
+
|
|
3
|
+
export interface ThemeTokens {
|
|
4
|
+
readonly light: Readonly<Record<string, string>>
|
|
5
|
+
readonly dark: Readonly<Record<string, string>>
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ThemeRuntimeStyle {
|
|
9
|
+
readonly css: string
|
|
10
|
+
readonly browserThemeColor: { readonly light: string; readonly dark: string }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TokenOverrides {
|
|
14
|
+
readonly light: Readonly<Record<string, string>>
|
|
15
|
+
readonly dark: Readonly<Record<string, string>>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface BoardTheme {
|
|
19
|
+
readonly key: string
|
|
20
|
+
readonly tokens: ThemeTokens
|
|
21
|
+
readonly overrides: unknown
|
|
22
|
+
readonly customCss: string | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ColourScheme = 'light' | 'dark'
|
|
26
|
+
|
|
27
|
+
export const COLOUR_SCHEMES: readonly ColourScheme[] = ['light', 'dark']
|
|
28
|
+
|
|
29
|
+
const MAX_CUSTOM_CSS_LENGTH = 100_000
|
|
30
|
+
|
|
31
|
+
const THEME_KEY = /^[a-z0-9][a-z0-9_-]*$/i
|
|
32
|
+
|
|
33
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
34
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function assertSafeCssValue(name: string, value: string): void {
|
|
38
|
+
if (
|
|
39
|
+
value.length === 0 ||
|
|
40
|
+
value.length > 256 ||
|
|
41
|
+
/[;{}<>]/.test(value) ||
|
|
42
|
+
!/^[a-zA-Z0-9#%(),.\s'"/+*=-]+$/.test(value)
|
|
43
|
+
) {
|
|
44
|
+
throw new Error(`Theme ${name} has an unsafe CSS value.`)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function assertSafeThemeKey(key: string): void {
|
|
49
|
+
if (!THEME_KEY.test(key)) {
|
|
50
|
+
throw new Error(`Theme key "${key}" cannot be used in a stylesheet selector.`)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isSchemeShaped(raw: Record<string, unknown>): boolean {
|
|
55
|
+
const keys = Object.keys(raw)
|
|
56
|
+
return (
|
|
57
|
+
keys.every((key) => key === 'light' || key === 'dark') &&
|
|
58
|
+
keys.every((key) => isRecord(raw[key]))
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function validateScheme(tokens: ThemeTokens, raw: Record<string, unknown>): Record<string, string> {
|
|
63
|
+
const overrides: Record<string, string> = {}
|
|
64
|
+
for (const [name, value] of Object.entries(raw)) {
|
|
65
|
+
if (!(name in tokens.light) || !(name in tokens.dark)) {
|
|
66
|
+
throw new Error(`Theme token "${name}" is not declared by this theme.`)
|
|
67
|
+
}
|
|
68
|
+
if (typeof value !== 'string') {
|
|
69
|
+
throw new Error(`Theme token "${name}" must have a string value.`)
|
|
70
|
+
}
|
|
71
|
+
assertSafeCssValue(`token "${name}"`, value)
|
|
72
|
+
if (name === 'background' && colorToHex(value) === null) {
|
|
73
|
+
throw new Error('Theme token "background" must be an sRGB hex or OKLCH colour.')
|
|
74
|
+
}
|
|
75
|
+
overrides[name] = value
|
|
76
|
+
}
|
|
77
|
+
return overrides
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function validateTokenOverrides(tokens: ThemeTokens, raw: unknown): TokenOverrides {
|
|
81
|
+
if (raw === undefined || raw === null) return { light: {}, dark: {} }
|
|
82
|
+
if (!isRecord(raw)) throw new Error('Theme token overrides must be an object.')
|
|
83
|
+
|
|
84
|
+
if (isSchemeShaped(raw)) {
|
|
85
|
+
return {
|
|
86
|
+
light: validateScheme(tokens, isRecord(raw.light) ? raw.light : {}),
|
|
87
|
+
dark: validateScheme(tokens, isRecord(raw.dark) ? raw.dark : {}),
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const both = validateScheme(tokens, raw)
|
|
92
|
+
return { light: both, dark: { ...both } }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function validateCustomCss(value: string | null): string | null {
|
|
96
|
+
if (value === null || value.trim() === '') return null
|
|
97
|
+
if (
|
|
98
|
+
value.length > MAX_CUSTOM_CSS_LENGTH ||
|
|
99
|
+
/<\/style|@import|url\s*\(|expression\s*\(/i.test(value)
|
|
100
|
+
) {
|
|
101
|
+
throw new Error('Theme custom CSS contains an unsafe construct.')
|
|
102
|
+
}
|
|
103
|
+
return value
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const colorToHex = colourToHex
|
|
107
|
+
|
|
108
|
+
function declarations(values: Readonly<Record<string, string>>): string {
|
|
109
|
+
return Object.entries(values)
|
|
110
|
+
.map(([name, value]) => `--${name}:${value};`)
|
|
111
|
+
.join('')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function effectiveTokens(theme: BoardTheme): ThemeTokens {
|
|
115
|
+
const overrides = validateTokenOverrides(theme.tokens, theme.overrides)
|
|
116
|
+
return {
|
|
117
|
+
light: { ...theme.tokens.light, ...overrides.light },
|
|
118
|
+
dark: { ...theme.tokens.dark, ...overrides.dark },
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function differing(
|
|
123
|
+
values: Readonly<Record<string, string>>,
|
|
124
|
+
against: Readonly<Record<string, string>>,
|
|
125
|
+
): Record<string, string> {
|
|
126
|
+
const out: Record<string, string> = {}
|
|
127
|
+
for (const [name, value] of Object.entries(values)) {
|
|
128
|
+
if (against[name] !== value) {
|
|
129
|
+
assertSafeCssValue(`token "${name}"`, value)
|
|
130
|
+
out[name] = value
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return out
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function restatedForDark(
|
|
137
|
+
light: Readonly<Record<string, string>>,
|
|
138
|
+
dark: Record<string, string>,
|
|
139
|
+
darkValues: Readonly<Record<string, string>>,
|
|
140
|
+
): Record<string, string> {
|
|
141
|
+
const out = { ...dark }
|
|
142
|
+
|
|
143
|
+
for (const name of Object.keys(light)) {
|
|
144
|
+
if (name in out) continue
|
|
145
|
+
const value = darkValues[name]
|
|
146
|
+
if (value === undefined) continue
|
|
147
|
+
assertSafeCssValue(`token "${name}"`, value)
|
|
148
|
+
out[name] = value
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return out
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function schemeBlocks(
|
|
155
|
+
light: Record<string, string>,
|
|
156
|
+
dark: Record<string, string>,
|
|
157
|
+
selector: { light: string; dark: string; media: string },
|
|
158
|
+
): string {
|
|
159
|
+
const lightCss = declarations(light)
|
|
160
|
+
const darkCss = declarations(dark)
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
(lightCss === '' ? '' : `${selector.light}{${lightCss}}`) +
|
|
164
|
+
(darkCss === ''
|
|
165
|
+
? ''
|
|
166
|
+
: `${selector.dark}{${darkCss}}` +
|
|
167
|
+
`@media (prefers-color-scheme: dark){${selector.media}{${darkCss}}}`)
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function renderBoardStyle(input: {
|
|
172
|
+
readonly themes: readonly BoardTheme[]
|
|
173
|
+
readonly defaultKey: string
|
|
174
|
+
readonly baseline: ThemeTokens
|
|
175
|
+
}): ThemeRuntimeStyle {
|
|
176
|
+
const active = input.themes.find((theme) => theme.key === input.defaultKey)
|
|
177
|
+
if (active === undefined) {
|
|
178
|
+
throw new Error(`Theme "${input.defaultKey}" is not among the enabled themes.`)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const activeTokens = effectiveTokens(active)
|
|
182
|
+
|
|
183
|
+
const activeLight = differing(activeTokens.light, input.baseline.light)
|
|
184
|
+
|
|
185
|
+
let css = schemeBlocks(
|
|
186
|
+
activeLight,
|
|
187
|
+
restatedForDark(
|
|
188
|
+
activeLight,
|
|
189
|
+
differing(activeTokens.dark, input.baseline.dark),
|
|
190
|
+
activeTokens.dark,
|
|
191
|
+
),
|
|
192
|
+
{ light: ':root', dark: '.dark', media: ':root:not(.light)' },
|
|
193
|
+
)
|
|
194
|
+
css += validateCustomCss(active.customCss) ?? ''
|
|
195
|
+
|
|
196
|
+
for (const theme of input.themes) {
|
|
197
|
+
if (theme.key === active.key) continue
|
|
198
|
+
assertSafeThemeKey(theme.key)
|
|
199
|
+
|
|
200
|
+
const tokens = effectiveTokens(theme)
|
|
201
|
+
const scope = `[data-theme="${theme.key}"]`
|
|
202
|
+
const light = differing(tokens.light, activeTokens.light)
|
|
203
|
+
|
|
204
|
+
css += schemeBlocks(
|
|
205
|
+
light,
|
|
206
|
+
restatedForDark(light, differing(tokens.dark, activeTokens.dark), tokens.dark),
|
|
207
|
+
{
|
|
208
|
+
light: `:root${scope}`,
|
|
209
|
+
dark: `.dark${scope}`,
|
|
210
|
+
media: `:root${scope}:not(.light)`,
|
|
211
|
+
},
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
const custom = validateCustomCss(theme.customCss)
|
|
215
|
+
if (custom !== null) css += `:root${scope}{${custom}}`
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const light = colorToHex(activeTokens.light.background ?? '')
|
|
219
|
+
const dark = colorToHex(activeTokens.dark.background ?? '')
|
|
220
|
+
if (light === null || dark === null) {
|
|
221
|
+
throw new Error('Theme background tokens must be sRGB hex or OKLCH colours.')
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return { css, browserThemeColor: { light, dark } }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function renderThemeStyle(
|
|
228
|
+
tokens: ThemeTokens,
|
|
229
|
+
rawOverrides: unknown,
|
|
230
|
+
customCss: string | null,
|
|
231
|
+
baseline: ThemeTokens = tokens,
|
|
232
|
+
): ThemeRuntimeStyle {
|
|
233
|
+
return renderBoardStyle({
|
|
234
|
+
themes: [{ key: 'default', tokens, overrides: rawOverrides, customCss }],
|
|
235
|
+
defaultKey: 'default',
|
|
236
|
+
baseline,
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export interface GroupNameColour {
|
|
241
|
+
readonly groupId: number
|
|
242
|
+
readonly light: string | null
|
|
243
|
+
readonly dark: string | null
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function groupNameClass(groupId: number): string {
|
|
247
|
+
return `gname-${groupId}`
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function renderGroupNameStyle(groups: readonly GroupNameColour[]): string {
|
|
251
|
+
if (groups.length === 0) return ''
|
|
252
|
+
|
|
253
|
+
const block = (pick: (group: GroupNameColour) => string | null, prefix: string): string =>
|
|
254
|
+
groups
|
|
255
|
+
.map((group) => {
|
|
256
|
+
const colour = pick(group)
|
|
257
|
+
if (colour === null) return ''
|
|
258
|
+
assertSafeCssValue(`group ${group.groupId} colour`, colour)
|
|
259
|
+
const selector = `.${groupNameClass(group.groupId)}`
|
|
260
|
+
return `${prefix}${selector}${selector}{color:${colour};}`
|
|
261
|
+
})
|
|
262
|
+
.join('')
|
|
263
|
+
|
|
264
|
+
const scheme = (
|
|
265
|
+
pick: (group: GroupNameColour) => string | null,
|
|
266
|
+
chosen: ColourScheme,
|
|
267
|
+
other: ColourScheme,
|
|
268
|
+
): string => {
|
|
269
|
+
const chose = block(pick, `.${chosen} `)
|
|
270
|
+
if (chose === '') return ''
|
|
271
|
+
|
|
272
|
+
const followsTheSystem = block(pick, `:root:not(.${other}) `)
|
|
273
|
+
return `${chose}@media (prefers-color-scheme: ${chosen}){${followsTheSystem}}`
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return (
|
|
277
|
+
scheme((group) => group.light, 'light', 'dark') + scheme((group) => group.dark, 'dark', 'light')
|
|
278
|
+
)
|
|
279
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import forumConfig from '@board/config'
|
|
4
|
+
import { cookies } from 'next/headers'
|
|
5
|
+
import { cache } from 'react'
|
|
6
|
+
|
|
7
|
+
import { assertThemeContract, type ResolvedTheme, resolveTheme } from '@meith/theme-kit'
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
type ColourSchemePreference,
|
|
11
|
+
isColourScheme,
|
|
12
|
+
SCHEME_COOKIE,
|
|
13
|
+
THEME_COOKIE,
|
|
14
|
+
} from '@/view/theme-preference'
|
|
15
|
+
|
|
16
|
+
import { getBoardThemeStyle } from './theme-runtime'
|
|
17
|
+
|
|
18
|
+
const RESOLVED: ReadonlyMap<string, ResolvedTheme> = new Map(
|
|
19
|
+
Object.values(forumConfig.themes)
|
|
20
|
+
.filter((theme) => theme.theme !== undefined)
|
|
21
|
+
.map((theme) => [theme.key, resolveTheme(theme.theme!)]),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
export const buildTheme: ResolvedTheme = RESOLVED.get(forumConfig.defaultTheme)!
|
|
25
|
+
|
|
26
|
+
if (buildTheme === undefined) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`community.config.ts: defaultTheme "${forumConfig.defaultTheme}" fills no slots, so this ` +
|
|
29
|
+
'board cannot render a page. A token-only theme is legitimate as an alternate, ' +
|
|
30
|
+
'never as the theme a build is made of.',
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (const theme of RESOLVED.values()) assertThemeContract(theme)
|
|
35
|
+
|
|
36
|
+
export const currentThemeKey = cache(async (): Promise<string> => {
|
|
37
|
+
const { choices, defaultKey } = await getBoardThemeStyle()
|
|
38
|
+
const chosen = (await cookies()).get(THEME_COOKIE)?.value
|
|
39
|
+
|
|
40
|
+
return chosen !== undefined && choices.some((choice) => choice.key === chosen)
|
|
41
|
+
? chosen
|
|
42
|
+
: defaultKey
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
export const currentTheme = cache(async (): Promise<ResolvedTheme> => {
|
|
46
|
+
return RESOLVED.get(await currentThemeKey()) ?? buildTheme
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
export const currentColourScheme = cache(async (): Promise<ColourSchemePreference> => {
|
|
50
|
+
const chosen = (await cookies()).get(SCHEME_COOKIE)?.value
|
|
51
|
+
return isColourScheme(chosen) ? chosen : 'system'
|
|
52
|
+
})
|