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