@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,98 @@
|
|
|
1
|
+
import { msg } from '@meith/i18n'
|
|
2
|
+
import 'server-only'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
buildPermissionMatrix,
|
|
6
|
+
type CopyPlan,
|
|
7
|
+
type ForumOverride,
|
|
8
|
+
type MatrixRow,
|
|
9
|
+
planCopyToDescendants,
|
|
10
|
+
} from '@meith/authorization'
|
|
11
|
+
import { ForbiddenError, type PermissionSet } from '@meith/core'
|
|
12
|
+
import { getDb, PostgresForumAdminRepository } from '@meith/db'
|
|
13
|
+
import type { ForumRow } from '@meith/forums'
|
|
14
|
+
|
|
15
|
+
import { getContainer } from './container'
|
|
16
|
+
|
|
17
|
+
export function forumAdminRepository(): PostgresForumAdminRepository | null {
|
|
18
|
+
return getContainer().dataSource === 'postgres' ? new PostgresForumAdminRepository(getDb()) : null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function requireForumAdmin(): PostgresForumAdminRepository {
|
|
22
|
+
const repository = forumAdminRepository()
|
|
23
|
+
if (repository === null) {
|
|
24
|
+
throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-12'))
|
|
25
|
+
}
|
|
26
|
+
return repository
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function ancestorChain(forum: ForumRow): readonly number[] {
|
|
30
|
+
return forum.path
|
|
31
|
+
.split('.')
|
|
32
|
+
.map((segment) => Number(segment))
|
|
33
|
+
.filter((id) => Number.isSafeInteger(id) && id > 0)
|
|
34
|
+
.reverse()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ForumMatrixView {
|
|
38
|
+
readonly forum: ForumRow
|
|
39
|
+
readonly rows: readonly MatrixRow[]
|
|
40
|
+
readonly chain: readonly number[]
|
|
41
|
+
readonly overrides: readonly ForumOverride[]
|
|
42
|
+
readonly groups: readonly { readonly groupId: number; readonly title: string }[]
|
|
43
|
+
readonly descendants: readonly ForumRow[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface GroupRow {
|
|
47
|
+
readonly groupId: number
|
|
48
|
+
readonly title: string
|
|
49
|
+
readonly permissions: PermissionSet
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function allGroups(repository: PostgresForumAdminRepository): Promise<readonly GroupRow[]> {
|
|
53
|
+
const groups = await repository.listGroups()
|
|
54
|
+
const defaults = await getContainer().authorizationSource.groupDefaults(
|
|
55
|
+
groups.map((group) => group.id),
|
|
56
|
+
)
|
|
57
|
+
const byId = new Map(defaults.map((entry) => [entry.groupId, entry.permissions]))
|
|
58
|
+
|
|
59
|
+
return groups.map((group) => ({
|
|
60
|
+
groupId: group.id,
|
|
61
|
+
title: group.title,
|
|
62
|
+
permissions: byId.get(group.id) as PermissionSet,
|
|
63
|
+
}))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function buildForumMatrixView(forumId: number): Promise<ForumMatrixView | null> {
|
|
67
|
+
const repository = forumAdminRepository()
|
|
68
|
+
const { forums } = getContainer()
|
|
69
|
+
if (repository === null) return null
|
|
70
|
+
|
|
71
|
+
const all = await forums.listAll()
|
|
72
|
+
const forum = all.find((row) => row.id === forumId)
|
|
73
|
+
if (forum === undefined) return null
|
|
74
|
+
|
|
75
|
+
const chain = ancestorChain(forum)
|
|
76
|
+
const groups = await allGroups(repository)
|
|
77
|
+
const descendants = all.filter((row) => row.path.startsWith(`${forum.path}.`))
|
|
78
|
+
|
|
79
|
+
const overrides = await repository.readOverrides([...chain, ...descendants.map((row) => row.id)])
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
forum,
|
|
83
|
+
chain,
|
|
84
|
+
overrides,
|
|
85
|
+
descendants,
|
|
86
|
+
groups: groups.map(({ groupId, title }) => ({ groupId, title })),
|
|
87
|
+
rows: buildPermissionMatrix({ chain, groups, overrides }),
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function previewCopy(view: ForumMatrixView): CopyPlan {
|
|
92
|
+
return planCopyToDescendants({
|
|
93
|
+
sourceForumId: view.forum.id,
|
|
94
|
+
descendantIds: view.descendants.map((row) => row.id),
|
|
95
|
+
groupIds: view.groups.map((group) => group.groupId),
|
|
96
|
+
overrides: view.overrides,
|
|
97
|
+
})
|
|
98
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import { revalidatePath } from 'next/cache'
|
|
4
|
+
|
|
5
|
+
import { PERMISSION_FIELDS, ValidationError } from '@meith/core'
|
|
6
|
+
import { permissionsCarryPower } from '@meith/db'
|
|
7
|
+
import type { PromotionRuleInput } from '@meith/groups'
|
|
8
|
+
import { msg } from '@meith/i18n'
|
|
9
|
+
|
|
10
|
+
import { recordAdminAction, requireAdmin, requireFreshAdmin } from './admin'
|
|
11
|
+
import type { FormState } from './auth-form-state'
|
|
12
|
+
import { formStateReporter } from './form-state-reporter'
|
|
13
|
+
import { checkbox, trimmedText } from './form-values'
|
|
14
|
+
import { promotionService, requireGroupAdmin, requirePromotionRules } from './group-admin'
|
|
15
|
+
import { assertSafeCssValue } from './theme-style'
|
|
16
|
+
|
|
17
|
+
const CHUNK = 500
|
|
18
|
+
|
|
19
|
+
function groupId(form: FormData, name = 'groupId'): number {
|
|
20
|
+
const id = Number(trimmedText(form, name))
|
|
21
|
+
if (!Number.isSafeInteger(id) || id <= 0) throw new ValidationError(msg('error.app.such-group'))
|
|
22
|
+
return id
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const toFormState = formStateReporter('group-admin', 'group administration write failed')
|
|
26
|
+
|
|
27
|
+
function refreshGroupScreens(): void {
|
|
28
|
+
revalidatePath('/admin/groups')
|
|
29
|
+
revalidatePath('/admin/groups/[id]', 'page')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function refreshPromotionScreen(): void {
|
|
33
|
+
revalidatePath('/admin/groups/promotions')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ruleId(form: FormData): number {
|
|
37
|
+
const id = Number(trimmedText(form, 'id'))
|
|
38
|
+
if (!Number.isSafeInteger(id) || id <= 0)
|
|
39
|
+
throw new ValidationError(msg('error.app.such-promotion-rule'))
|
|
40
|
+
return id
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function blankOr(form: FormData, name: string): number | null {
|
|
44
|
+
const raw = trimmedText(form, name)
|
|
45
|
+
return raw === '' ? null : Number(raw)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function promotionRuleInput(form: FormData): PromotionRuleInput {
|
|
49
|
+
return {
|
|
50
|
+
title: trimmedText(form, 'title'),
|
|
51
|
+
displayOrder: blankOr(form, 'displayOrder') ?? 0,
|
|
52
|
+
minPostCount: blankOr(form, 'minPostCount'),
|
|
53
|
+
minReputation: blankOr(form, 'minReputation'),
|
|
54
|
+
minDaysRegistered: blankOr(form, 'minDaysRegistered'),
|
|
55
|
+
fromPrimaryGroupId: blankOr(form, 'fromPrimaryGroupId'),
|
|
56
|
+
toPrimaryGroupId: Number(trimmedText(form, 'toPrimaryGroupId')),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function groupColour(form: FormData, field: string): string | null {
|
|
61
|
+
const value = trimmedText(form, field)
|
|
62
|
+
if (value === '') return null
|
|
63
|
+
assertSafeCssValue(`group colour "${field}"`, value)
|
|
64
|
+
return value
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function saveGroupIdentityAction(
|
|
68
|
+
_prev: FormState,
|
|
69
|
+
form: FormData,
|
|
70
|
+
): Promise<FormState> {
|
|
71
|
+
try {
|
|
72
|
+
await requireAdmin()
|
|
73
|
+
const id = groupId(form)
|
|
74
|
+
|
|
75
|
+
const title = trimmedText(form, 'title')
|
|
76
|
+
if (title === '') throw new ValidationError(msg('error.app.group-needs-title'))
|
|
77
|
+
|
|
78
|
+
const order = Number(trimmedText(form, 'displayOrder'))
|
|
79
|
+
if (!Number.isSafeInteger(order) || order < 0) {
|
|
80
|
+
throw new ValidationError(msg('error.app.display-order-must-whole-number'))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const isStaffGroup = checkbox(form, 'isStaffGroup')
|
|
84
|
+
const pluginGrantable = checkbox(form, 'pluginGrantable')
|
|
85
|
+
|
|
86
|
+
if (pluginGrantable) {
|
|
87
|
+
if (isStaffGroup) {
|
|
88
|
+
throw new ValidationError(msg('error.app.staff-group-granted-by-plugins'))
|
|
89
|
+
}
|
|
90
|
+
const repository = requireGroupAdmin()
|
|
91
|
+
const summary = (await repository.list()).find((group) => group.id === id)
|
|
92
|
+
if (summary?.isSystem === true) {
|
|
93
|
+
throw new ValidationError(msg('error.app.system-group-granted-by-plugins'))
|
|
94
|
+
}
|
|
95
|
+
const permissions = await repository.readPermissions(id)
|
|
96
|
+
if (permissions !== null && permissionsCarryPower(permissions)) {
|
|
97
|
+
throw new ValidationError(msg('error.app.group-carries-administrative-moderation-power'))
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await requireGroupAdmin().updateIdentity(id, {
|
|
102
|
+
title,
|
|
103
|
+
description: trimmedText(form, 'description') || null,
|
|
104
|
+
displayOrder: order,
|
|
105
|
+
isStaffGroup,
|
|
106
|
+
pluginGrantable,
|
|
107
|
+
badgeToken: trimmedText(form, 'badgeToken') === '' ? null : trimmedText(form, 'badgeToken'),
|
|
108
|
+
nameColorLight: groupColour(form, 'nameColorLight'),
|
|
109
|
+
nameColorDark: groupColour(form, 'nameColorDark'),
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
refreshGroupScreens()
|
|
113
|
+
await recordAdminAction({ action: 'group.updated', detail: { groupId: id } })
|
|
114
|
+
|
|
115
|
+
return { notice: 'saved' }
|
|
116
|
+
} catch (err) {
|
|
117
|
+
return toFormState(err)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function saveGroupPermissionsAction(
|
|
122
|
+
_prev: FormState,
|
|
123
|
+
form: FormData,
|
|
124
|
+
): Promise<FormState> {
|
|
125
|
+
try {
|
|
126
|
+
await requireAdmin()
|
|
127
|
+
const id = groupId(form)
|
|
128
|
+
|
|
129
|
+
const values: Record<string, boolean | number> = {}
|
|
130
|
+
for (const field of PERMISSION_FIELDS) {
|
|
131
|
+
if (field.kind === 'numeric') {
|
|
132
|
+
const raw = trimmedText(form, field.key)
|
|
133
|
+
const parsed = Number(raw)
|
|
134
|
+
if (raw !== '' && (!Number.isSafeInteger(parsed) || parsed < 0)) {
|
|
135
|
+
throw new ValidationError(`“${field.key}” must be a whole number, or blank for none.`)
|
|
136
|
+
}
|
|
137
|
+
values[field.key] = raw === '' ? field.fallback : parsed
|
|
138
|
+
} else {
|
|
139
|
+
values[field.key] = checkbox(form, field.key)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
await requireGroupAdmin().savePermissions(id, values)
|
|
144
|
+
|
|
145
|
+
refreshGroupScreens()
|
|
146
|
+
await recordAdminAction({ action: 'group.permissions_changed', detail: { groupId: id } })
|
|
147
|
+
|
|
148
|
+
return { notice: 'saved' }
|
|
149
|
+
} catch (err) {
|
|
150
|
+
return toFormState(err)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function createGroupAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
155
|
+
try {
|
|
156
|
+
await requireAdmin()
|
|
157
|
+
|
|
158
|
+
const key = trimmedText(form, 'key')
|
|
159
|
+
if (!/^[a-z][a-z0-9_]*$/.test(key)) {
|
|
160
|
+
throw new ValidationError(msg('error.app.key-contain-lower-case-letters-numbers'))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const title = trimmedText(form, 'title')
|
|
164
|
+
if (title === '') throw new ValidationError(msg('error.app.group-needs-title'))
|
|
165
|
+
|
|
166
|
+
if (trimmedText(form, 'copyFromGroupId') === '') {
|
|
167
|
+
throw new ValidationError(msg('error.app.choose-group-copy-permissions-from'))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const id = await requireGroupAdmin().create({
|
|
171
|
+
key,
|
|
172
|
+
title,
|
|
173
|
+
copyFromGroupId: groupId(form, 'copyFromGroupId'),
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
refreshGroupScreens()
|
|
177
|
+
await recordAdminAction({ action: 'group.created', detail: { groupId: id, key } })
|
|
178
|
+
|
|
179
|
+
return { notice: 'created' }
|
|
180
|
+
} catch (err) {
|
|
181
|
+
return toFormState(err)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function deleteGroupAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
186
|
+
try {
|
|
187
|
+
await requireFreshAdmin()
|
|
188
|
+
const id = groupId(form)
|
|
189
|
+
const moveTo = groupId(form, 'moveMembersTo')
|
|
190
|
+
|
|
191
|
+
await requireGroupAdmin().remove(id, moveTo)
|
|
192
|
+
|
|
193
|
+
refreshGroupScreens()
|
|
194
|
+
await recordAdminAction({
|
|
195
|
+
action: 'group.deleted',
|
|
196
|
+
detail: { groupId: id, movedTo: moveTo },
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
return { notice: 'deleted' }
|
|
200
|
+
} catch (err) {
|
|
201
|
+
return toFormState(err)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function moveMembersAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
206
|
+
try {
|
|
207
|
+
await requireFreshAdmin()
|
|
208
|
+
const from = groupId(form, 'fromGroupId')
|
|
209
|
+
const to = groupId(form, 'toGroupId')
|
|
210
|
+
|
|
211
|
+
const after = Number(trimmedText(form, 'afterUserId') || '0')
|
|
212
|
+
if (!Number.isSafeInteger(after) || after < 0) {
|
|
213
|
+
throw new ValidationError(msg('error.app.lost-track-where-run-had'))
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const chunk = await requireGroupAdmin().moveMembersChunk({
|
|
217
|
+
fromGroupId: from,
|
|
218
|
+
toGroupId: to,
|
|
219
|
+
afterUserId: after,
|
|
220
|
+
limit: CHUNK,
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
refreshGroupScreens()
|
|
224
|
+
await recordAdminAction({
|
|
225
|
+
action: 'group.members_moved',
|
|
226
|
+
detail: { fromGroupId: from, toGroupId: to, moved: chunk.moved },
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
const total = Number(trimmedText(form, 'movedSoFar') || '0') + chunk.moved
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
notice: chunk.nextCursor === null ? 'finished' : 'more',
|
|
233
|
+
values: {
|
|
234
|
+
fromGroupId: String(from),
|
|
235
|
+
toGroupId: String(to),
|
|
236
|
+
afterUserId: String(chunk.nextCursor ?? 0),
|
|
237
|
+
movedSoFar: String(total),
|
|
238
|
+
},
|
|
239
|
+
}
|
|
240
|
+
} catch (err) {
|
|
241
|
+
return toFormState(err)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export async function createPromotionRuleAction(
|
|
246
|
+
_prev: FormState,
|
|
247
|
+
form: FormData,
|
|
248
|
+
): Promise<FormState> {
|
|
249
|
+
try {
|
|
250
|
+
await requireAdmin()
|
|
251
|
+
|
|
252
|
+
const id = await requirePromotionRules().createRule(promotionRuleInput(form))
|
|
253
|
+
|
|
254
|
+
refreshPromotionScreen()
|
|
255
|
+
await recordAdminAction({ action: 'group.promotion_rule_added', detail: { ruleId: id } })
|
|
256
|
+
|
|
257
|
+
return { notice: 'created' }
|
|
258
|
+
} catch (err) {
|
|
259
|
+
return toFormState(err)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export async function updatePromotionRuleAction(
|
|
264
|
+
_prev: FormState,
|
|
265
|
+
form: FormData,
|
|
266
|
+
): Promise<FormState> {
|
|
267
|
+
try {
|
|
268
|
+
await requireAdmin()
|
|
269
|
+
const id = ruleId(form)
|
|
270
|
+
|
|
271
|
+
await requirePromotionRules().updateRule(id, promotionRuleInput(form))
|
|
272
|
+
|
|
273
|
+
refreshPromotionScreen()
|
|
274
|
+
await recordAdminAction({ action: 'group.promotion_rule_changed', detail: { ruleId: id } })
|
|
275
|
+
|
|
276
|
+
return { notice: 'saved' }
|
|
277
|
+
} catch (err) {
|
|
278
|
+
return toFormState(err)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export async function setPromotionRuleEnabledAction(
|
|
283
|
+
_prev: FormState,
|
|
284
|
+
form: FormData,
|
|
285
|
+
): Promise<FormState> {
|
|
286
|
+
try {
|
|
287
|
+
await requireAdmin()
|
|
288
|
+
const id = ruleId(form)
|
|
289
|
+
const enabled = checkbox(form, 'enabled')
|
|
290
|
+
|
|
291
|
+
await requirePromotionRules().setRuleEnabled(id, enabled)
|
|
292
|
+
|
|
293
|
+
refreshPromotionScreen()
|
|
294
|
+
await recordAdminAction({
|
|
295
|
+
action: 'group.promotion_rule_toggled',
|
|
296
|
+
detail: { ruleId: id, enabled },
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
return { notice: enabled ? 'enabled' : 'disabled' }
|
|
300
|
+
} catch (err) {
|
|
301
|
+
return toFormState(err)
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export async function deletePromotionRuleAction(
|
|
306
|
+
_prev: FormState,
|
|
307
|
+
form: FormData,
|
|
308
|
+
): Promise<FormState> {
|
|
309
|
+
try {
|
|
310
|
+
await requireFreshAdmin()
|
|
311
|
+
const id = ruleId(form)
|
|
312
|
+
|
|
313
|
+
await requirePromotionRules().deleteRule(id)
|
|
314
|
+
|
|
315
|
+
refreshPromotionScreen()
|
|
316
|
+
await recordAdminAction({ action: 'group.promotion_rule_removed', detail: { ruleId: id } })
|
|
317
|
+
|
|
318
|
+
return { notice: 'deleted' }
|
|
319
|
+
} catch (err) {
|
|
320
|
+
return toFormState(err)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export async function applyPromotionsAction(_prev: FormState, _form: FormData): Promise<FormState> {
|
|
325
|
+
try {
|
|
326
|
+
await requireFreshAdmin()
|
|
327
|
+
|
|
328
|
+
const result = await promotionService().apply()
|
|
329
|
+
|
|
330
|
+
refreshGroupScreens()
|
|
331
|
+
refreshPromotionScreen()
|
|
332
|
+
await recordAdminAction({
|
|
333
|
+
action: 'group.promotions_applied',
|
|
334
|
+
detail: { promoted: result.outcomes.length, examined: result.examined },
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
return { notice: `promoted:${result.outcomes.length}` }
|
|
338
|
+
} catch (err) {
|
|
339
|
+
return toFormState(err)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { msg } from '@meith/i18n'
|
|
2
|
+
import 'server-only'
|
|
3
|
+
|
|
4
|
+
import type { PermissionField, PermissionSet } from '@meith/core'
|
|
5
|
+
import { ForbiddenError, PERMISSION_FIELDS } from '@meith/core'
|
|
6
|
+
import {
|
|
7
|
+
type GroupSummaryRow,
|
|
8
|
+
getDb,
|
|
9
|
+
PostgresGroupAdminRepository,
|
|
10
|
+
PostgresPromotionRepository,
|
|
11
|
+
} from '@meith/db'
|
|
12
|
+
import { type PromotionRunResult, PromotionService } from '@meith/groups'
|
|
13
|
+
import { defaultPromotionGuards } from '@meith/runtime'
|
|
14
|
+
|
|
15
|
+
import { getContainer } from './container'
|
|
16
|
+
|
|
17
|
+
export function groupAdminRepository(): PostgresGroupAdminRepository | null {
|
|
18
|
+
return getContainer().dataSource === 'postgres' ? new PostgresGroupAdminRepository(getDb()) : null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function requireGroupAdmin(): PostgresGroupAdminRepository {
|
|
22
|
+
const repository = groupAdminRepository()
|
|
23
|
+
if (repository === null) {
|
|
24
|
+
throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-18'))
|
|
25
|
+
}
|
|
26
|
+
return repository
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface GroupPermissionCell {
|
|
30
|
+
readonly key: string
|
|
31
|
+
readonly description: string
|
|
32
|
+
readonly kind: PermissionField['kind']
|
|
33
|
+
readonly scope: PermissionField['scope']
|
|
34
|
+
readonly value: boolean | number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface GroupPermissionView {
|
|
38
|
+
readonly group: GroupSummaryRow
|
|
39
|
+
readonly cells: readonly GroupPermissionCell[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function buildGroupPermissionView(
|
|
43
|
+
groupId: number,
|
|
44
|
+
): Promise<GroupPermissionView | null> {
|
|
45
|
+
const repository = groupAdminRepository()
|
|
46
|
+
if (repository === null) return null
|
|
47
|
+
|
|
48
|
+
const group = (await repository.list()).find((row) => row.id === groupId)
|
|
49
|
+
if (group === undefined) return null
|
|
50
|
+
|
|
51
|
+
const permissions = await repository.readPermissions(groupId)
|
|
52
|
+
if (permissions === null) return null
|
|
53
|
+
|
|
54
|
+
return { group, cells: permissionCells(permissions) }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function permissionCells(permissions: PermissionSet): readonly GroupPermissionCell[] {
|
|
58
|
+
return PERMISSION_FIELDS.map((field) => ({
|
|
59
|
+
key: field.key,
|
|
60
|
+
description: field.description,
|
|
61
|
+
kind: field.kind,
|
|
62
|
+
scope: field.scope,
|
|
63
|
+
value:
|
|
64
|
+
(permissions as unknown as Record<string, boolean | number>)[field.key] ?? field.fallback,
|
|
65
|
+
}))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function promotionRuleRepository(): PostgresPromotionRepository | null {
|
|
69
|
+
return getContainer().dataSource === 'postgres' ? new PostgresPromotionRepository(getDb()) : null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function requirePromotionRules(): PostgresPromotionRepository {
|
|
73
|
+
const repository = promotionRuleRepository()
|
|
74
|
+
if (repository === null) {
|
|
75
|
+
throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-19'))
|
|
76
|
+
}
|
|
77
|
+
return repository
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function previewPromotions(limit = 500): Promise<PromotionRunResult | null> {
|
|
81
|
+
if (getContainer().dataSource !== 'postgres') return null
|
|
82
|
+
|
|
83
|
+
const service = new PromotionService({
|
|
84
|
+
promotions: new PostgresPromotionRepository(getDb()),
|
|
85
|
+
guards: defaultPromotionGuards(),
|
|
86
|
+
})
|
|
87
|
+
return service.preview(limit)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function promotionService(): PromotionService {
|
|
91
|
+
if (getContainer().dataSource !== 'postgres') {
|
|
92
|
+
throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-20'))
|
|
93
|
+
}
|
|
94
|
+
return new PromotionService({
|
|
95
|
+
promotions: new PostgresPromotionRepository(getDb()),
|
|
96
|
+
guards: defaultPromotionGuards(),
|
|
97
|
+
})
|
|
98
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
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 { formStateReporter } from './form-state-reporter'
|
|
9
|
+
import { BADGE_FIELD, removeBadge, saveBadge } from './group-badge'
|
|
10
|
+
import { type ImageScheme, isImageScheme } from './image-upload'
|
|
11
|
+
|
|
12
|
+
const toFormState = formStateReporter('group-badge', 'badge write failed')
|
|
13
|
+
|
|
14
|
+
function target(form: FormData): { groupId: number; scheme: ImageScheme } {
|
|
15
|
+
const raw = form.get('groupId')
|
|
16
|
+
const groupId = typeof raw === 'string' ? Number(raw) : Number.NaN
|
|
17
|
+
if (!Number.isSafeInteger(groupId) || groupId <= 0) {
|
|
18
|
+
throw new ValidationError(msg('error.app.such-group'))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const scheme = form.get('scheme')
|
|
22
|
+
if (!isImageScheme(scheme)) throw new ValidationError(msg('error.app.such-badge'))
|
|
23
|
+
|
|
24
|
+
return { groupId, scheme }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function saveBadgeAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
28
|
+
try {
|
|
29
|
+
await requireAdmin()
|
|
30
|
+
const { groupId, scheme } = target(form)
|
|
31
|
+
|
|
32
|
+
const file = form.get(BADGE_FIELD)
|
|
33
|
+
if (!(file instanceof File) || file.size === 0) {
|
|
34
|
+
throw new ValidationError(msg('error.app.choose-image-first'))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
await saveBadge(groupId, scheme, file)
|
|
38
|
+
await recordAdminAction({
|
|
39
|
+
action: 'group.badge_saved',
|
|
40
|
+
detail: { groupId, scheme, bytes: file.size },
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
return { notice: 'saved' }
|
|
44
|
+
} catch (err) {
|
|
45
|
+
return toFormState(err)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function removeBadgeAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
50
|
+
try {
|
|
51
|
+
await requireAdmin()
|
|
52
|
+
const { groupId, scheme } = target(form)
|
|
53
|
+
|
|
54
|
+
await removeBadge(groupId, scheme)
|
|
55
|
+
await recordAdminAction({ action: 'group.badge_removed', detail: { groupId, scheme } })
|
|
56
|
+
|
|
57
|
+
return { notice: 'removed' }
|
|
58
|
+
} catch (err) {
|
|
59
|
+
return toFormState(err)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { cache } from 'react'
|
|
4
|
+
|
|
5
|
+
import { CacheTags } from '@meith/core'
|
|
6
|
+
import { getDb, PostgresGroupAdminRepository } from '@meith/db'
|
|
7
|
+
import { drivers } from '@meith/drivers'
|
|
8
|
+
|
|
9
|
+
import { getContainer } from './container'
|
|
10
|
+
import { forgetImage, type ImageScheme, storeImage } from './image-upload'
|
|
11
|
+
|
|
12
|
+
export const BADGE_FIELD = 'badge'
|
|
13
|
+
|
|
14
|
+
const KEY_SHAPE = /^group\/(\d+)\/badge-(light|dark)-[a-f0-9-]{36}\.(png|jpg|webp|svg)$/
|
|
15
|
+
|
|
16
|
+
function repository(): PostgresGroupAdminRepository | null {
|
|
17
|
+
return getContainer().dataSource === 'postgres' ? new PostgresGroupAdminRepository(getDb()) : null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function badgeSrc(groupId: number, scheme: ImageScheme, key: string): string {
|
|
21
|
+
return `/group/${groupId}/badge/${scheme}?v=${key.slice(-16, -4)}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const badgeKey = cache(
|
|
25
|
+
async (groupId: number, scheme: ImageScheme): Promise<string | null> => {
|
|
26
|
+
const repo = repository()
|
|
27
|
+
if (repo === null) return null
|
|
28
|
+
|
|
29
|
+
const group = (await repo.list().catch(() => [])).find((row) => row.id === groupId)
|
|
30
|
+
const key = scheme === 'dark' ? group?.badgeImageDark : group?.badgeImageLight
|
|
31
|
+
if (typeof key !== 'string' || !KEY_SHAPE.test(key)) return null
|
|
32
|
+
|
|
33
|
+
return KEY_SHAPE.exec(key)?.[1] === String(groupId) ? key : null
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
export async function saveBadge(groupId: number, scheme: ImageScheme, file: File): Promise<void> {
|
|
38
|
+
const repo = requireGroups()
|
|
39
|
+
const key = await storeImage(`group/${groupId}`, `badge-${scheme}`, file)
|
|
40
|
+
|
|
41
|
+
const previous = await repo.setBadge(groupId, scheme, key)
|
|
42
|
+
await invalidate()
|
|
43
|
+
if (previous !== key) await forgetImage(previous)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function removeBadge(groupId: number, scheme: ImageScheme): Promise<void> {
|
|
47
|
+
const previous = await requireGroups().setBadge(groupId, scheme, null)
|
|
48
|
+
await invalidate()
|
|
49
|
+
await forgetImage(previous)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function requireGroups(): PostgresGroupAdminRepository {
|
|
53
|
+
const repo = repository()
|
|
54
|
+
if (repo === null) {
|
|
55
|
+
throw new Error('This board is running on in-memory sample data, so groups cannot be edited.')
|
|
56
|
+
}
|
|
57
|
+
return repo
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function invalidate(): Promise<void> {
|
|
61
|
+
await drivers().cache.invalidateTags([CacheTags.groups()])
|
|
62
|
+
}
|