@tokenoftrust/storefront-runner 1.4.1 → 2.0.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/apps/storefront/astro.config.mjs +17 -1
- package/apps/storefront/env.d.ts +54 -5
- package/apps/storefront/integrations/materialize-guard.mjs +54 -0
- package/apps/storefront/migrations-apps/0002_good_switch.sql +26 -0
- package/apps/storefront/migrations-apps/0003_mute_marauders.sql +12 -0
- package/apps/storefront/migrations-apps/0004_deep_morph.sql +36 -0
- package/apps/storefront/migrations-apps/0005_common_mystique.sql +23 -0
- package/apps/storefront/migrations-apps/0006_broad_microchip.sql +16 -0
- package/apps/storefront/migrations-apps/0007_brave_outlaw_kid.sql +15 -0
- package/apps/storefront/migrations-apps/0008_blue_dazzler.sql +11 -0
- package/apps/storefront/migrations-apps/0009_bored_shinobi_shaw.sql +15 -0
- package/apps/storefront/migrations-apps/0010_ws4_verified_provider_measurement.sql +35 -0
- package/apps/storefront/migrations-apps/meta/0002_snapshot.json +1160 -0
- package/apps/storefront/migrations-apps/meta/0003_snapshot.json +1235 -0
- package/apps/storefront/migrations-apps/meta/0004_snapshot.json +1474 -0
- package/apps/storefront/migrations-apps/meta/0005_snapshot.json +1617 -0
- package/apps/storefront/migrations-apps/meta/0006_snapshot.json +1716 -0
- package/apps/storefront/migrations-apps/meta/0007_snapshot.json +1812 -0
- package/apps/storefront/migrations-apps/meta/0008_snapshot.json +1881 -0
- package/apps/storefront/migrations-apps/meta/0009_snapshot.json +1979 -0
- package/apps/storefront/migrations-apps/meta/_journal.json +56 -0
- package/apps/storefront/public/apps/tot.demo.analytics/0.1.0/entry.js +42 -0
- package/apps/storefront/public/js/dashboard-team.js +38 -2
- package/apps/storefront/public/platform/amplitude-http/v1/entry.mjs +19 -0
- package/apps/storefront/public/platform/google-measurement/v1/entry.mjs +19 -0
- package/apps/storefront/public/shared/commerce-chrome.css +20 -2
- package/apps/storefront/scripts/build-v84-clean-clone-standalone.mjs +68 -0
- package/apps/storefront/scripts/deny-v84-clean-clone-standalone-capabilities.mjs +111 -0
- package/apps/storefront/scripts/deny-v84-dogfood-capabilities.mjs +2 -0
- package/apps/storefront/scripts/deny-v84-lifecycle-capabilities.mjs +247 -0
- package/apps/storefront/scripts/dogfood-v84-nonportable-routes.ts +73 -0
- package/apps/storefront/scripts/fixed-v84-native-install-producer.ts +79 -0
- package/apps/storefront/scripts/generated/v84-clean-clone-replay.bundle.mjs +10982 -0
- package/apps/storefront/scripts/import-v84-through-ws2.ts +38 -0
- package/apps/storefront/scripts/initialize-v84-lifecycle-tooling-dependency.ts +2 -0
- package/apps/storefront/scripts/initialize-v84-lifecycle-tooling.ts +4 -0
- package/apps/storefront/scripts/install-v84-native-through-ws1.ts +185 -0
- package/apps/storefront/scripts/prove-clone-isolation.ts +506 -0
- package/apps/storefront/scripts/replay-v84-clean-clone-pass.ts +148 -0
- package/apps/storefront/scripts/replay-v84-clean-clone-standalone.ts +35 -0
- package/apps/storefront/scripts/replay-v84-clean-clone.ts +214 -0
- package/apps/storefront/scripts/run-fixed-v84-native-install-producer.mjs +18 -0
- package/apps/storefront/scripts/run-v84-clone-isolation-proof.mjs +18 -0
- package/apps/storefront/scripts/run-v84-clone-lifecycle-proof.mjs +18 -0
- package/apps/storefront/scripts/run-v84-import-through-ws2-proof.mjs +17 -0
- package/apps/storefront/scripts/run-v84-nonportable-route-dogfood.mjs +15 -0
- package/apps/storefront/scripts/sanitize-v84-proof-environment.mjs +21 -0
- package/apps/storefront/scripts/v84CleanCloneGraphPin.ts +42 -0
- package/apps/storefront/scripts/v84CleanCloneSourceGraph.ts +145 -0
- package/apps/storefront/scripts/validate-v84-clone-lifecycle.ts +18 -0
- package/apps/storefront/src/components/Img.astro +1 -1
- package/apps/storefront/src/components/Seo.astro +65 -1
- package/apps/storefront/src/components/admin/AdminBlogDetailRail.astro +878 -0
- package/apps/storefront/src/components/admin/AdminBlogTab.astro +1435 -0
- package/apps/storefront/src/components/admin/AdminCustomersTab.astro +3 -7
- package/apps/storefront/src/components/admin/AdminIntegrationsTab.astro +105 -0
- package/apps/storefront/src/components/admin/AdminPublishTab.astro +1980 -2564
- package/apps/storefront/src/components/admin/AdminSettingsTab.astro +2 -2
- package/apps/storefront/src/components/admin/AdminSubscriptionsTab.astro +1 -3
- package/apps/storefront/src/components/admin/orders/OrdersWorkspace.astro +5 -5
- package/apps/storefront/src/components/blog/BlogPaywall.astro +87 -0
- package/apps/storefront/src/components/blog/PostAccessBadge.astro +43 -0
- package/apps/storefront/src/components/blog/PostGrid.astro +57 -0
- package/apps/storefront/src/components/blog/ReactionBar.astro +214 -0
- package/apps/storefront/src/components/chrome/NavDropdown.astro +12 -0
- package/apps/storefront/src/components/chrome/SiteFooter.astro +7 -2
- package/apps/storefront/src/components/chrome/SiteHeader.astro +46 -10
- package/apps/storefront/src/components/compliance/AdultSignatureNotice.astro +1 -1
- package/apps/storefront/src/components/compliance/ComplianceNotice.astro +1 -2
- package/apps/storefront/src/components/compliance/ExciseTaxNotice.astro +1 -2
- package/apps/storefront/src/components/compliance/PactActNotice.astro +1 -1
- package/apps/storefront/src/components/compliance/Prop65Warning.astro +1 -2
- package/apps/storefront/src/components/compliance/PurchaseLimitNotice.astro +2 -3
- package/apps/storefront/src/components/compliance/ShippingRestrictionNotice.astro +2 -2
- package/apps/storefront/src/components/compliance/StateEligibilityNotice.astro +2 -3
- package/apps/storefront/src/components/content/TrustStrip.astro +4 -4
- package/apps/storefront/src/components/islands/MobileNav.tsx +26 -1
- package/apps/storefront/src/components/islands/VariantSelector.tsx +27 -2
- package/apps/storefront/src/components/islands/useCheckoutHandoffCorrelation.ts +75 -0
- package/apps/storefront/src/components/membership/IllustrativeLabel.astro +1 -2
- package/apps/storefront/src/components/membership/TierCards.astro +2 -2
- package/apps/storefront/src/components/membership/TierComparisonTable.astro +2 -2
- package/apps/storefront/src/components/subscription/SavingsExplainer.astro +2 -2
- package/apps/storefront/src/config/adminTenantLookup.ts +35 -0
- package/apps/storefront/src/config/devTenantSeed.ts +9 -9
- package/apps/storefront/src/config/resolver.ts +4 -4
- package/apps/storefront/src/layouts/Layout.astro +146 -21
- package/apps/storefront/src/lib/activity/alerts.ts +17 -19
- package/apps/storefront/src/lib/activity/candidateSubmitLease.ts +87 -0
- package/apps/storefront/src/lib/activity/changeActorAttribution.ts +89 -9
- package/apps/storefront/src/lib/activity/deployVersion.ts +20 -27
- package/apps/storefront/src/lib/activity/ingest.ts +8 -8
- package/apps/storefront/src/lib/activity/ingestAuth.ts +1 -1
- package/apps/storefront/src/lib/activity/killSwitch.ts +6 -6
- package/apps/storefront/src/lib/activity/query.ts +9 -9
- package/apps/storefront/src/lib/activity/recordActivity.ts +11 -15
- package/apps/storefront/src/lib/activity/store.ts +4 -6
- package/apps/storefront/src/lib/activity/uiActor.ts +12 -12
- package/apps/storefront/src/lib/activity/workerCommit.ts +6 -6
- package/apps/storefront/src/lib/admin/adminShell.ts +292 -0
- package/apps/storefront/src/lib/admin/ai/approvalMode.ts +2 -3
- package/apps/storefront/src/lib/admin/ai/audit.ts +3 -5
- package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +7 -7
- package/apps/storefront/src/lib/admin/checkoutStyleTarget.ts +3 -3
- package/apps/storefront/src/lib/admin/envelope.ts +1 -2
- package/apps/storefront/src/lib/admin/orderClient.ts +3 -5
- package/apps/storefront/src/lib/admin/services/orders.ts +17 -19
- package/apps/storefront/src/lib/admin/viewmodels/aiWorkflows.ts +4 -6
- package/apps/storefront/src/lib/admin/viewmodels/settings.ts +1 -1
- package/apps/storefront/src/lib/apps/adminService.ts +14 -4
- package/apps/storefront/src/lib/apps/adminSession.ts +54 -45
- package/apps/storefront/src/lib/apps/apiAuth.ts +2 -2
- package/apps/storefront/src/lib/apps/apiRoute.ts +2 -2
- package/apps/storefront/src/lib/apps/appVersionStore.ts +281 -0
- package/apps/storefront/src/lib/apps/catalogMapper.ts +1 -1
- package/apps/storefront/src/lib/apps/consentConfigStore.ts +228 -0
- package/apps/storefront/src/lib/apps/credentials.ts +2 -2
- package/apps/storefront/src/lib/apps/gatewayKeys.ts +4 -4
- package/apps/storefront/src/lib/apps/gtmMigrationInstall.ts +447 -0
- package/apps/storefront/src/lib/apps/gtmMigrationReport.ts +367 -0
- package/apps/storefront/src/lib/apps/gtmMigrationTab.ts +315 -0
- package/apps/storefront/src/lib/apps/healthAggregate.ts +5 -5
- package/apps/storefront/src/lib/apps/integrationLifecycle.ts +614 -0
- package/apps/storefront/src/lib/apps/integrationLifecycleController.ts +125 -0
- package/apps/storefront/src/lib/apps/integrationReadModel.ts +563 -0
- package/apps/storefront/src/lib/apps/integrationsTab.ts +547 -0
- package/apps/storefront/src/lib/apps/integrationsView.ts +106 -0
- package/apps/storefront/src/lib/apps/managedAnalyticsService.ts +599 -0
- package/apps/storefront/src/lib/apps/orders/attributionService.ts +2 -2
- package/apps/storefront/src/lib/apps/orders/customerHash.ts +1 -1
- package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +12 -12
- package/apps/storefront/src/lib/apps/orders/idempotency.ts +7 -7
- package/apps/storefront/src/lib/apps/orders/orderConversionD1.ts +150 -0
- package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +9 -1
- package/apps/storefront/src/lib/apps/orders/orderMapper.ts +2 -2
- package/apps/storefront/src/lib/apps/orders/ordersStore.ts +7 -7
- package/apps/storefront/src/lib/apps/orders/verifiedProviderMeasurement.ts +117 -0
- package/apps/storefront/src/lib/apps/registryService.ts +270 -16
- package/apps/storefront/src/lib/apps/scopes.ts +2 -2
- package/apps/storefront/src/lib/apps/tenantIntegrationMaterialization.ts +170 -0
- package/apps/storefront/src/lib/apps/tokenIssuer.ts +4 -4
- package/apps/storefront/src/lib/apps/tokenVerifier.ts +6 -6
- package/apps/storefront/src/lib/apps/v84CleanCloneReplayContext.ts +16 -0
- package/apps/storefront/src/lib/apps/v84CloneLifecycleCacheEvidence.ts +105 -0
- package/apps/storefront/src/lib/apps/v84CloneLifecycleEvidence.ts +177 -0
- package/apps/storefront/src/lib/apps/v84CloneLifecycleMaterialization.ts +251 -0
- package/apps/storefront/src/lib/apps/v84CloneLifecycleSemantics.ts +241 -0
- package/apps/storefront/src/lib/apps/v84CloneMigrationEvidence.ts +211 -0
- package/apps/storefront/src/lib/apps/v84CloneNativeInstallEvidence.ts +409 -0
- package/apps/storefront/src/lib/apps/v84NonportableCodeCoaching.ts +368 -0
- package/apps/storefront/src/lib/apps/v84NonportableRouteContract.ts +188 -0
- package/apps/storefront/src/lib/apps/v84NonportableRouteDogfood.ts +291 -0
- package/apps/storefront/src/lib/apps/widgets/eligibility.ts +2 -2
- package/apps/storefront/src/lib/apps/widgets/frameProps.ts +3 -3
- package/apps/storefront/src/lib/apps/widgets/launchToken.ts +4 -4
- package/apps/storefront/src/lib/apps/widgets/placements.ts +2 -2
- package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +1 -1
- package/apps/storefront/src/lib/apps/ws1ManagedAnalyticsClient.ts +95 -0
- package/apps/storefront/src/lib/auth/adminApiGuard.ts +66 -0
- package/apps/storefront/src/lib/auth/adminEntry.ts +11 -11
- package/apps/storefront/src/lib/auth/adminLanding.ts +88 -0
- package/apps/storefront/src/lib/auth/identityToken.ts +6 -14
- package/apps/storefront/src/lib/auth/loginGate.ts +75 -13
- package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +2 -4
- package/apps/storefront/src/lib/auth/operatorSession.ts +113 -0
- package/apps/storefront/src/lib/auth/session.ts +7 -8
- package/apps/storefront/src/lib/auth/totAccessClient.ts +8 -18
- package/apps/storefront/src/lib/basePath.ts +9 -2
- package/apps/storefront/src/lib/blog/access.ts +29 -0
- package/apps/storefront/src/lib/blog/collection.ts +400 -0
- package/apps/storefront/src/lib/blog/markdown.ts +174 -0
- package/apps/storefront/src/lib/blog/provider.ts +62 -3
- package/apps/storefront/src/lib/blog/reactions.ts +87 -0
- package/apps/storefront/src/lib/blog/rss.ts +92 -0
- package/apps/storefront/src/lib/blog/teaser.ts +83 -0
- package/apps/storefront/src/lib/blog/types.ts +34 -0
- package/apps/storefront/src/lib/blog/viewCounts.ts +243 -0
- package/apps/storefront/src/lib/chrome/examples/tokenoftrust.chrome-assignments.example.json +6 -0
- package/apps/storefront/src/lib/chrome/examples/tokenoftrust.chrome.example.json +79 -0
- package/apps/storefront/src/lib/chrome/model.ts +48 -253
- package/apps/storefront/src/lib/chrome/navCurrent.ts +29 -0
- package/apps/storefront/src/lib/chrome/parseConfig.ts +18 -32
- package/apps/storefront/src/lib/chrome/variant.ts +39 -0
- package/apps/storefront/src/lib/commerce/checkoutHandoffCorrelation.ts +105 -0
- package/apps/storefront/src/lib/compliance/tamper-sentinel-boot.ts +152 -0
- package/apps/storefront/src/lib/compliance/tamper-sentinel.ts +327 -0
- package/apps/storefront/src/lib/content/index.ts +5 -5
- package/apps/storefront/src/lib/content-edit/applyDiff.ts +1 -1
- package/apps/storefront/src/lib/content-edit/route.ts +1 -1
- package/apps/storefront/src/lib/d1/catalog.ts +14 -2
- package/apps/storefront/src/lib/d1/schema-apps.ts +372 -17
- package/apps/storefront/src/lib/dev/activityIngestToken.ts +2 -4
- package/apps/storefront/src/lib/dev/activityStore.ts +3 -5
- package/apps/storefront/src/lib/dev/apiBase.ts +1 -4
- package/apps/storefront/src/lib/dev/cockpit/ask.ts +49 -0
- package/apps/storefront/src/lib/dev/cockpit/clickToSource.ts +71 -0
- package/apps/storefront/src/lib/dev/cockpit/copyCommand.ts +227 -0
- package/apps/storefront/src/lib/dev/cockpit/files.ts +249 -0
- package/apps/storefront/src/lib/dev/cockpit/hostedActivity.ts +500 -0
- package/apps/storefront/src/lib/dev/cockpit/infoPopover.ts +12 -0
- package/apps/storefront/src/lib/dev/cockpit/mcpCopy.ts +34 -0
- package/apps/storefront/src/lib/dev/cockpit/monitor.ts +107 -0
- package/apps/storefront/src/lib/dev/cockpit/stepper.ts +116 -0
- package/apps/storefront/src/lib/dev/cockpit/themeToggle.ts +23 -0
- package/apps/storefront/src/lib/dev/cockpitStore.ts +1 -9
- package/apps/storefront/src/lib/dev/devDraft.ts +1 -1
- package/apps/storefront/src/lib/dev/previewStatus.ts +9 -21
- package/apps/storefront/src/lib/dev/rendezvousBroker.ts +9 -24
- package/apps/storefront/src/lib/dev/runtimeStore.ts +1 -1
- package/apps/storefront/src/lib/edgeCache.ts +34 -5
- package/apps/storefront/src/lib/email/candidateConflictEmail.ts +80 -0
- package/apps/storefront/src/lib/http/fetchTransport.ts +63 -0
- package/apps/storefront/src/lib/i18n.ts +25 -0
- package/apps/storefront/src/lib/integration/browserEnvironment.ts +137 -0
- package/apps/storefront/src/lib/integration/managedInstallDeclarations.ts +72 -0
- package/apps/storefront/src/lib/integration/nonceStamp.ts +49 -0
- package/apps/storefront/src/lib/integration/tenantDeclarations.ts +116 -0
- package/apps/storefront/src/lib/jsonld.ts +118 -0
- package/apps/storefront/src/lib/membership/eligibility.ts +1 -1
- package/apps/storefront/src/lib/membership/model.ts +1 -2
- package/apps/storefront/src/lib/monitoring/manifest.ts +11 -11
- package/apps/storefront/src/lib/newsletter/confirmationEmail.ts +30 -0
- package/apps/storefront/src/lib/newsletter/consent.ts +23 -0
- package/apps/storefront/src/lib/newsletter/digest.ts +117 -0
- package/apps/storefront/src/lib/newsletter/resendClient.ts +79 -0
- package/apps/storefront/src/lib/newsletter/send.ts +265 -0
- package/apps/storefront/src/lib/newsletter/subscribers.ts +156 -0
- package/apps/storefront/src/lib/previewReload.ts +57 -1
- package/apps/storefront/src/lib/publish/adminPublishTab.ts +4625 -0
- package/apps/storefront/src/lib/publish/apex-readiness.ts +41 -18
- package/apps/storefront/src/lib/publish/buildStream.ts +269 -0
- package/apps/storefront/src/lib/publish/collapsePersistence.ts +72 -0
- package/apps/storefront/src/lib/publish/dispatchHealth.ts +5 -7
- package/apps/storefront/src/lib/publish/domainState.ts +22 -13
- package/apps/storefront/src/lib/publish/lifecycleState.ts +369 -0
- package/apps/storefront/src/lib/publish/liveReleaseSummary.ts +29 -0
- package/apps/storefront/src/lib/publish/mergeDoctorOnFailure.ts +78 -0
- package/apps/storefront/src/lib/publish/mergeHealthBanner.ts +62 -0
- package/apps/storefront/src/lib/publish/mergeRowFindings.ts +124 -0
- package/apps/storefront/src/lib/publish/mergeSupportEscalation.ts +75 -0
- package/apps/storefront/src/lib/publish/panelPolling.ts +78 -0
- package/apps/storefront/src/lib/publish/publish-guard.ts +88 -0
- package/apps/storefront/src/lib/publish/publishFormat.ts +165 -0
- package/apps/storefront/src/lib/publish/publishHero.ts +177 -0
- package/apps/storefront/src/lib/publish/reviewRelease/changeState.ts +66 -0
- package/apps/storefront/src/lib/publish/reviewRelease/copy.ts +141 -0
- package/apps/storefront/src/lib/publish/reviewRelease/index.ts +41 -0
- package/apps/storefront/src/lib/publish/reviewRelease/permissions.ts +47 -0
- package/apps/storefront/src/lib/publish/reviewRelease/releaseState.ts +42 -0
- package/apps/storefront/src/lib/publish/reviewRelease/types.ts +59 -0
- package/apps/storefront/src/lib/publish/reviewRelease/viewModel.ts +440 -0
- package/apps/storefront/src/lib/publish/reviewReleaseWorkspace/actions.ts +161 -0
- package/apps/storefront/src/lib/publish/reviewReleaseWorkspace/controller.ts +325 -0
- package/apps/storefront/src/lib/publish/reviewReleaseWorkspace/render.ts +241 -0
- package/apps/storefront/src/lib/publish/shipWorkspace.ts +121 -51
- package/apps/storefront/src/lib/publish/sseRoute.ts +119 -0
- package/apps/storefront/src/lib/publish/stageBundleUpload.ts +215 -0
- package/apps/storefront/src/lib/publish/streamLogConsole.ts +142 -0
- package/apps/storefront/src/lib/publish/versionNumber.ts +52 -0
- package/apps/storefront/src/lib/rate/kvWindow.ts +53 -0
- package/apps/storefront/src/lib/rawChrome.ts +125 -47
- package/apps/storefront/src/lib/rawMarketingHtml.ts +74 -3
- package/apps/storefront/src/lib/search/index.ts +2 -2
- package/apps/storefront/src/lib/seo/alternates.ts +42 -0
- package/apps/storefront/src/lib/seo/meta.ts +65 -0
- package/apps/storefront/src/lib/social/golive.ts +212 -0
- package/apps/storefront/src/lib/social/notify.ts +32 -0
- package/apps/storefront/src/lib/social/tokenStore.ts +171 -0
- package/apps/storefront/src/lib/storyblok/content-model.ts +7 -33
- package/apps/storefront/src/lib/storyblok/provider.ts +168 -60
- package/apps/storefront/src/lib/subscription/model.ts +4 -4
- package/apps/storefront/src/lib/the-build/provider.ts +32 -0
- package/apps/storefront/src/lib/the-build/readingTime.ts +35 -0
- package/apps/storefront/src/lib/the-build/toc.ts +95 -0
- package/apps/storefront/src/lib/tot/ToTClient.ts +1 -1
- package/apps/storefront/src/lib/tot/d1Client.ts +2 -2
- package/apps/storefront/src/lib/tot/totClientInterface.ts +3 -4
- package/apps/storefront/src/lib/webhooks/deliveryStore.ts +16 -17
- package/apps/storefront/src/middleware/index.ts +385 -52
- package/apps/storefront/src/pages/[...slug].astro +9 -4
- package/apps/storefront/src/pages/admin/review-release.astro +265 -0
- package/apps/storefront/src/pages/admin/select.astro +96 -0
- package/apps/storefront/src/pages/admin.astro +25 -1313
- package/apps/storefront/src/pages/api/activity.ts +8 -8
- package/apps/storefront/src/pages/api/admin/activity-alerts.ts +5 -20
- package/apps/storefront/src/pages/api/admin/orders/[id].ts +7 -2
- package/apps/storefront/src/pages/api/admin/orders.ts +8 -2
- package/apps/storefront/src/pages/api/apps/admin/gtm-migration-install.ts +174 -0
- package/apps/storefront/src/pages/api/apps/admin/gtm-migration.ts +96 -0
- package/apps/storefront/src/pages/api/apps/admin/integration-lifecycle.ts +118 -0
- package/apps/storefront/src/pages/api/apps/admin/integrations.ts +89 -0
- package/apps/storefront/src/pages/api/apps/admin/platform-adapters.ts +85 -0
- package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +74 -2
- package/apps/storefront/src/pages/api/blog/[collection]/[slug]/react.ts +104 -0
- package/apps/storefront/src/pages/api/commerce/checkout-handoff.ts +77 -0
- package/apps/storefront/src/pages/api/compliance/tamper.ts +88 -0
- package/apps/storefront/src/pages/api/integrations/v1/[operation].ts +108 -0
- package/apps/storefront/src/pages/api/newsletter/[collection]/subscribe.ts +146 -0
- package/apps/storefront/src/pages/api/newsletter/confirm.ts +75 -0
- package/apps/storefront/src/pages/api/newsletter/unsubscribe.ts +90 -0
- package/apps/storefront/src/pages/api/publish/stage-bundle.ts +70 -0
- package/apps/storefront/src/pages/api/rum/vitals.ts +20 -2
- package/apps/storefront/src/pages/blog/[slug].astro +99 -19
- package/apps/storefront/src/pages/blog/author/[author].astro +140 -0
- package/apps/storefront/src/pages/blog/category/[category].astro +67 -0
- package/apps/storefront/src/pages/blog/index.astro +4 -2
- package/apps/storefront/src/pages/blog/tag/[tag].astro +65 -0
- package/apps/storefront/src/pages/cockpit.astro +77 -1353
- package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +15 -11
- package/apps/storefront/src/pages/index.astro +10 -5
- package/apps/storefront/src/pages/llms.txt.ts +1 -1
- package/apps/storefront/src/pages/products/[handle].astro +7 -6
- package/apps/storefront/src/pages/search.astro +1 -1
- package/apps/storefront/src/pages/sitemap.xml.ts +21 -1
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +31 -27
- package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +1 -1
- package/apps/storefront/src/pages/tenants/[id]/[...path].ts +38 -2
- package/apps/storefront/src/pages/the-build/[slug].astro +129 -0
- package/apps/storefront/src/pages/the-build/index.astro +146 -0
- package/apps/storefront/src/pages/the-build/rss.xml.ts +33 -0
- package/apps/storefront/src/styles/admin.css +1106 -0
- package/apps/storefront/tsconfig.json +6 -1
- package/package.json +1 -1
- package/packages/public-runtime/package.json +3 -1
- package/packages/public-runtime/schemas/managed-analytics/v1/adapter-definition.schema.json +427 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/create-integration-install-request.schema.json +161 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/create-migration-draft-request.schema.json +157 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/delete-integration-install-request.schema.json +84 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/delete-migration-draft-request.schema.json +84 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/error-envelope.schema.json +180 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/get-adapter-definition-request.schema.json +52 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/get-integration-install-request.schema.json +52 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/get-integration-revision-request.schema.json +79 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/get-migration-draft-request.schema.json +52 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/integration-install.schema.json +167 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/integration-revision.schema.json +208 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/list-adapter-definitions-request.schema.json +53 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/list-integration-installs-request.schema.json +53 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/list-integration-revisions-request.schema.json +82 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/migration-draft.schema.json +218 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/mutation-request.schema.json +52 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/mutation-success-envelope.schema.json +49 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/read-request.schema.json +20 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/read-success-envelope.schema.json +45 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/revise-integration-install-request.schema.json +156 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/set-integration-install-status-request.schema.json +92 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/update-migration-draft-request.schema.json +184 -0
- package/packages/public-runtime/scripts/generate-managed-analytics-api-v1-schemas.mjs +13 -0
- package/packages/public-runtime/src/activity/catalog.ts +89 -33
- package/packages/public-runtime/src/activity/event.ts +6 -6
- package/packages/public-runtime/src/activity/index.ts +2 -2
- package/packages/public-runtime/src/activity/redaction.ts +4 -4
- package/packages/public-runtime/src/candidate-index.ts +42 -8
- package/packages/public-runtime/src/catalog-d1.ts +1 -1
- package/packages/public-runtime/src/checkout-orders.ts +2 -2
- package/packages/public-runtime/src/checkout-style-publish.ts +2 -2
- package/packages/public-runtime/src/checkout-style.ts +7 -7
- package/packages/public-runtime/src/checkout.ts +3 -3
- package/packages/public-runtime/src/chrome.ts +777 -0
- package/packages/public-runtime/src/compliance-obligations.ts +310 -0
- package/packages/public-runtime/src/csp.ts +99 -0
- package/packages/public-runtime/src/customization-preview.ts +25 -2
- package/packages/public-runtime/src/customization-runtime.ts +156 -3
- package/packages/public-runtime/src/customization-versioning.ts +10 -0
- package/packages/public-runtime/src/destination-webhook/contract.ts +353 -0
- package/packages/public-runtime/src/embed-catalog.d.mts +9 -0
- package/packages/public-runtime/src/embed-catalog.mjs +40 -0
- package/packages/public-runtime/src/evidence.ts +288 -0
- package/packages/public-runtime/src/gtm-migration/bounds.ts +138 -0
- package/packages/public-runtime/src/gtm-migration/disposition-rules.ts +361 -0
- package/packages/public-runtime/src/gtm-migration/dispositions.ts +181 -0
- package/packages/public-runtime/src/gtm-migration/draft-generator.ts +400 -0
- package/packages/public-runtime/src/gtm-migration/graph.ts +468 -0
- package/packages/public-runtime/src/gtm-migration/index.ts +95 -0
- package/packages/public-runtime/src/gtm-migration/parse.ts +410 -0
- package/packages/public-runtime/src/gtm-migration/references.ts +266 -0
- package/packages/public-runtime/src/gtm-migration/types.ts +86 -0
- package/packages/public-runtime/src/index.ts +17 -1
- package/packages/public-runtime/src/integration/assurance-decision.ts +259 -0
- package/packages/public-runtime/src/integration/assurance.ts +228 -0
- package/packages/public-runtime/src/integration/candidate-evidence.ts +377 -0
- package/packages/public-runtime/src/integration/commerce-broker.ts +497 -0
- package/packages/public-runtime/src/integration/consent-broker.ts +337 -0
- package/packages/public-runtime/src/integration/consent-cmp-demand.ts +115 -0
- package/packages/public-runtime/src/integration/consent-config.ts +260 -0
- package/packages/public-runtime/src/integration/consent-preview.ts +125 -0
- package/packages/public-runtime/src/integration/correlation-token.ts +444 -0
- package/packages/public-runtime/src/integration/csp-compiler.ts +267 -0
- package/packages/public-runtime/src/integration/csp.ts +105 -0
- package/packages/public-runtime/src/integration/digest.ts +43 -0
- package/packages/public-runtime/src/integration/environment.ts +228 -0
- package/packages/public-runtime/src/integration/events-catalog.ts +186 -0
- package/packages/public-runtime/src/integration/events.ts +701 -0
- package/packages/public-runtime/src/integration/g2-action-boundary.ts +106 -0
- package/packages/public-runtime/src/integration/index.ts +90 -0
- package/packages/public-runtime/src/integration/interim-bridge.ts +138 -0
- package/packages/public-runtime/src/integration/lifecycle-boot.ts +317 -0
- package/packages/public-runtime/src/integration/lifecycle-runtime.ts +393 -0
- package/packages/public-runtime/src/integration/lifecycle.ts +94 -0
- package/packages/public-runtime/src/integration/managed-analytics-api-v1-safety.d.mts +2 -0
- package/packages/public-runtime/src/integration/managed-analytics-api-v1-safety.mjs +10 -0
- package/packages/public-runtime/src/integration/managed-analytics-api-v1.schemas.ts +382 -0
- package/packages/public-runtime/src/integration/managed-analytics-api-v1.ts +684 -0
- package/packages/public-runtime/src/integration/managed-analytics-bridge.ts +383 -0
- package/packages/public-runtime/src/integration/managed-analytics-value-safety.ts +14 -0
- package/packages/public-runtime/src/integration/managed-analytics.ts +277 -0
- package/packages/public-runtime/src/integration/manifest.ts +470 -0
- package/packages/public-runtime/src/integration/native-amplitude.ts +362 -0
- package/packages/public-runtime/src/integration/native-ga4.ts +352 -0
- package/packages/public-runtime/src/integration/observability.ts +451 -0
- package/packages/public-runtime/src/integration/order-conversion.ts +0 -0
- package/packages/public-runtime/src/integration/phases.ts +182 -0
- package/packages/public-runtime/src/integration/plan.ts +112 -0
- package/packages/public-runtime/src/integration/platform-adapter-runtime.ts +146 -0
- package/packages/public-runtime/src/integration/platform-adapters.ts +671 -0
- package/packages/public-runtime/src/integration/promotion-gate.ts +451 -0
- package/packages/public-runtime/src/integration/provider-checkout-extension.ts +425 -0
- package/packages/public-runtime/src/integration/receipt-measurement.ts +370 -0
- package/packages/public-runtime/src/integration/regulated-parent-cert.ts +311 -0
- package/packages/public-runtime/src/integration/render-phases.ts +267 -0
- package/packages/public-runtime/src/integration/resolve.ts +465 -0
- package/packages/public-runtime/src/integration/result.ts +239 -0
- package/packages/public-runtime/src/integration/secrets.ts +92 -0
- package/packages/public-runtime/src/integration/tamper-evidence.ts +205 -0
- package/packages/public-runtime/src/integration/tamper-recovery.ts +127 -0
- package/packages/public-runtime/src/integration/version.ts +92 -0
- package/packages/public-runtime/src/integration-capabilities.ts +520 -0
- package/packages/public-runtime/src/membership.ts +2 -2
- package/packages/public-runtime/src/raw-html-policy.ts +2 -4
- package/packages/public-runtime/src/regulated-commerce/broker/consent-dedupe.ts +144 -0
- package/packages/public-runtime/src/regulated-commerce/correlation/contract.ts +177 -0
- package/packages/public-runtime/src/regulated-commerce/diagnostics/local-dispatch.ts +24 -0
- package/packages/public-runtime/src/regulated-commerce/diagnostics/read-model.ts +69 -0
- package/packages/public-runtime/src/regulated-commerce/dispatch/core.ts +134 -0
- package/packages/public-runtime/src/regulated-commerce/events/contract.ts +113 -0
- package/packages/public-runtime/src/regulated-commerce/ledger/core.ts +81 -0
- package/packages/public-runtime/src/regulated-commerce/mappings/ga4-ads.ts +87 -0
- package/packages/public-runtime/src/regulated-commerce/provider-order/bridge.ts +52 -0
- package/packages/public-runtime/src/regulated-commerce/purchase-truth/contract.ts +252 -0
- package/packages/public-runtime/src/regulated-commerce/sources/accepted-browser-operation.ts +67 -0
- package/packages/public-runtime/src/regulated-commerce/testing/fake-destination.ts +93 -0
- package/packages/public-runtime/src/static-bundle.ts +357 -0
- package/packages/public-runtime/src/tenant-assets.ts +143 -4
- package/packages/public-runtime/src/tenant.ts +81 -17
- package/packages/public-runtime/tests/fixtures/integration/browser-environment.example.json +8 -0
- package/packages/public-runtime/tests/fixtures/integration/commerce-event.example.json +44 -0
- package/packages/public-runtime/tests/fixtures/integration/commerce-order-event.example.json +44 -0
- package/packages/public-runtime/tests/fixtures/integration/compliance-assurance-record.example.json +50 -0
- package/packages/public-runtime/tests/fixtures/integration/integration-manifest-v2.example.json +120 -0
- package/packages/public-runtime/tests/fixtures/integration/managed-analytics-v1/fake-adapter-catalog.json +35 -0
- package/packages/public-runtime/tests/fixtures/integration/tenant-action-registry.example.json +11 -0
- package/packages/public-runtime/tests/fixtures/integration/tenant-local-example/README.md +22 -0
- package/packages/public-runtime/tests/fixtures/integration/tenant-local-example/entry.js +30 -0
- package/packages/public-runtime/tests/fixtures/integration/tenant-local-example/manifest.json +75 -0
- package/packages/public-runtime/tests/fixtures/integration/tenant-local-example/public-config.json +3 -0
- package/packages/public-runtime/tests/fixtures/regulated-commerce/fake-destinations.ts +38 -0
- package/packages/public-runtime/tests/gtm-migration/fixtures/gtm-sample-container-v84.provenance.json +29 -0
- package/packages/public-runtime/tests/gtm-migration/fixtures/gtm-sample-container-v84.redacted.json +3397 -0
- package/scripts/build/copy-tenant-assets.mjs +14 -4
- package/scripts/dev/ai-edit.mjs +1 -1
- package/scripts/dev/checkout-watch.mjs +2 -2
- package/scripts/dev/file-browser.mjs +4 -6
- package/scripts/dev/file-writer.mjs +3 -3
- package/scripts/dev/git-status.mjs +2 -2
- package/scripts/dev/integration-guardrails.mjs +498 -0
- package/scripts/dev/locate-handler.mjs +3 -3
- package/scripts/dev/port-check.mjs +3 -3
- package/scripts/dev/publish.mjs +5 -9
- package/scripts/dev/safe-path.mjs +2 -2
- package/scripts/dev/shot.mjs +1 -1
- package/scripts/dev/tenant-source-root.mjs +1 -1
- package/scripts/dev/unified-diff.mjs +8 -3
- package/scripts/tot-dev.mjs +8 -11
- package/apps/storefront/src/lib/dev/vcBinding.ts +0 -80
|
@@ -0,0 +1,1435 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Blog tab — read-only management list for the resolved tenant's blog content
|
|
4
|
+
* (docs/plans/2026-08-21-blog-platform-workstream.md). Rides the same
|
|
5
|
+
* per-tenant × multi-collection engine the reader routes use
|
|
6
|
+
* (src/lib/blog/collection.ts): every post — draft, published, unlisted, and
|
|
7
|
+
* scheduled — is listed via `listPosts(tenantId, collection, { includeUnpublished })`
|
|
8
|
+
* across the collections that actually have content for THIS tenant
|
|
9
|
+
* (`listCollections`). No tenant or collection is hardcoded.
|
|
10
|
+
*
|
|
11
|
+
* READ-ONLY: list + client-side status/category/collection filter + title
|
|
12
|
+
* search. Row actions are inert placeholders here — add/publish/unpublish/delete
|
|
13
|
+
* (the content-only fast lane) builds directly on this DOM. The
|
|
14
|
+
* row data hooks (`data-blog-row`, `data-slug`, `data-collection`, `data-status`)
|
|
15
|
+
* and the disabled action buttons (`data-blog-action`) are the contract the row
|
|
16
|
+
* actions wire to.
|
|
17
|
+
*/
|
|
18
|
+
import { listCollections, listPosts, categorySlug } from "@/lib/blog/collection.js";
|
|
19
|
+
import type { BlogPostSummary, PostStatus } from "@/lib/blog/types.js";
|
|
20
|
+
import { getTopPosts, type DailyViews } from "@/lib/blog/viewCounts.js";
|
|
21
|
+
import { type Reaction } from "@/lib/blog/reactions.js";
|
|
22
|
+
import { formatDate } from "@/lib/format";
|
|
23
|
+
import AdminBlogDetailRail from "./AdminBlogDetailRail.astro";
|
|
24
|
+
|
|
25
|
+
const { tenant, content } = Astro.locals;
|
|
26
|
+
|
|
27
|
+
const collections = await listCollections(tenant.tenant_id, content);
|
|
28
|
+
const rows = (
|
|
29
|
+
await Promise.all(
|
|
30
|
+
collections.map(async (collection) =>
|
|
31
|
+
(await listPosts(tenant.tenant_id, collection, {
|
|
32
|
+
includeUnpublished: true,
|
|
33
|
+
content,
|
|
34
|
+
})).map((post) => ({
|
|
35
|
+
collection,
|
|
36
|
+
post,
|
|
37
|
+
})),
|
|
38
|
+
),
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
.flat()
|
|
42
|
+
// Newest-edited first across every collection, so the freshest work is on top.
|
|
43
|
+
.sort((a, b) => {
|
|
44
|
+
const at = a.post.modifiedAt || a.post.publishedAt;
|
|
45
|
+
const bt = b.post.modifiedAt || b.post.publishedAt;
|
|
46
|
+
return at < bt ? 1 : at > bt ? -1 : 0;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Editorial "Discussion" link-out: every post's edits ride ONE candidate PR
|
|
50
|
+
// with a deterministic changeId `blog-<collection>-<slug>` (branch
|
|
51
|
+
// `candidate/<changeId>`), whose OPEN forge PR carries a web `url` we link to.
|
|
52
|
+
//
|
|
53
|
+
// This resolution is DELIBERATELY NOT done here: a live `repo_pr_list` in this
|
|
54
|
+
// frontmatter ran during the SSR of EVERY admin tab (admin.astro renders them all)
|
|
55
|
+
// and blocked the whole document stream on slow Gitea (seconds → tens of seconds).
|
|
56
|
+
// The links are a best-effort enhancement, so SSR ships an EMPTY map (the rail omits
|
|
57
|
+
// the links) and the reliable path is a lazy client-side load — resolved only when
|
|
58
|
+
// the Blog tab is viewed, where Gitea latency is an invisible async fetch instead of
|
|
59
|
+
// a render-blocking stall. TODO(lazy-discussion-links): wire that client fetch.
|
|
60
|
+
const discussions: Record<string, { prNumber: number; url: string }> = {};
|
|
61
|
+
|
|
62
|
+
const multiCollection = collections.length > 1;
|
|
63
|
+
|
|
64
|
+
// The four post statuses the engine can surface, including "scheduled".
|
|
65
|
+
const STATUS_ORDER: PostStatus[] = ["published", "draft", "scheduled", "unlisted"];
|
|
66
|
+
const STATUS_LABEL: Record<PostStatus, string> = {
|
|
67
|
+
published: "Published",
|
|
68
|
+
draft: "Draft",
|
|
69
|
+
scheduled: "Scheduled",
|
|
70
|
+
unlisted: "Unlisted",
|
|
71
|
+
};
|
|
72
|
+
// Status → admin chip tone (see admin.astro's .chip.* palette).
|
|
73
|
+
const STATUS_TONE: Record<PostStatus, string> = {
|
|
74
|
+
published: "green",
|
|
75
|
+
draft: "",
|
|
76
|
+
scheduled: "blue",
|
|
77
|
+
unlisted: "amber",
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const statusCounts = Object.fromEntries(
|
|
81
|
+
STATUS_ORDER.map((status) => [status, rows.filter((r) => r.post.status === status).length]),
|
|
82
|
+
) as Record<PostStatus, number>;
|
|
83
|
+
|
|
84
|
+
// Category filter options: the union of categories actually present, so the
|
|
85
|
+
// dropdown never offers a pillar that matches nothing in this list.
|
|
86
|
+
const categoryOptions = [
|
|
87
|
+
...new Map(
|
|
88
|
+
rows.flatMap((r) => r.post.categories).map((name) => [categorySlug(name), name]),
|
|
89
|
+
).entries(),
|
|
90
|
+
]
|
|
91
|
+
.map(([slug, label]) => ({ slug, label }))
|
|
92
|
+
.sort((a, b) => a.label.localeCompare(b.label));
|
|
93
|
+
|
|
94
|
+
const columnCount = multiCollection ? 7 : 6;
|
|
95
|
+
|
|
96
|
+
// ---- Language-variant chips ----
|
|
97
|
+
// Group the rows that share a `translationGroupId` so each grouped article shows
|
|
98
|
+
// its full variant set at a glance ("EN live · ES draft"). Pure read over
|
|
99
|
+
// `translationGroupId` + each variant's `inLanguage`+`status`. Singletons (no
|
|
100
|
+
// group id) render no chips.
|
|
101
|
+
interface LangVariant {
|
|
102
|
+
lang: string;
|
|
103
|
+
status: PostStatus;
|
|
104
|
+
slug: string;
|
|
105
|
+
collection: string;
|
|
106
|
+
}
|
|
107
|
+
const translationGroups = new Map<string, LangVariant[]>();
|
|
108
|
+
for (const { collection, post } of rows) {
|
|
109
|
+
if (!post.translationGroupId) continue;
|
|
110
|
+
const variant: LangVariant = {
|
|
111
|
+
lang: post.inLanguage ?? "en",
|
|
112
|
+
status: post.status,
|
|
113
|
+
slug: post.slug,
|
|
114
|
+
collection,
|
|
115
|
+
};
|
|
116
|
+
const existing = translationGroups.get(post.translationGroupId);
|
|
117
|
+
if (existing) existing.push(variant);
|
|
118
|
+
else translationGroups.set(post.translationGroupId, [variant]);
|
|
119
|
+
}
|
|
120
|
+
// Stable per-group ordering by language tag so the chip row doesn't reshuffle.
|
|
121
|
+
for (const variants of translationGroups.values()) {
|
|
122
|
+
variants.sort((a, b) => a.lang.localeCompare(b.lang));
|
|
123
|
+
}
|
|
124
|
+
// A compact language label for a chip — the tag upper-cased ("es" -> "ES",
|
|
125
|
+
// "pt-BR" -> "PT-BR").
|
|
126
|
+
const langLabel = (code: string) => code.toUpperCase();
|
|
127
|
+
// A one-word status for the chip, tighter than the full STATUS_LABEL.
|
|
128
|
+
const STATUS_SHORT: Record<PostStatus, string> = {
|
|
129
|
+
published: "live",
|
|
130
|
+
draft: "draft",
|
|
131
|
+
scheduled: "queued",
|
|
132
|
+
unlisted: "unlisted",
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const primaryDate = (post: BlogPostSummary) => post.modifiedAt || post.publishedAt;
|
|
136
|
+
const rowSearch = (r: { collection: string; post: BlogPostSummary }) =>
|
|
137
|
+
[r.post.title, r.post.slug, r.post.author, r.collection].join(" ").toLowerCase();
|
|
138
|
+
|
|
139
|
+
// ---- Engagement: top posts by views (rides the RUM-beacon view counters) ----
|
|
140
|
+
// DELIBERATELY NOT computed in SSR. getTopPosts fans out a 30-day KV view series PER
|
|
141
|
+
// post and getReactionCounts reads per-reaction PER post — MEASURED at 1,425 KV reads
|
|
142
|
+
// for this tenant (98% of the entire admin document's KV I/O), and it GROWS with post
|
|
143
|
+
// count. Because admin.astro renders every tab server-side, that fan-out dominated the
|
|
144
|
+
// whole admin document render (7-11s of KV wait, ~450ms CPU). A Promise.race budget did
|
|
145
|
+
// NOT help: it caps the awaited value but the 1,425 reads still fire. So SSR ships the
|
|
146
|
+
// empty/zero state (identical to KV-absent local dev, which the panels already handle),
|
|
147
|
+
// and engagement must load LAZILY client-side when the Blog tab is viewed — ideally from
|
|
148
|
+
// ONE precomputed ranking, never a per-post live fan-out.
|
|
149
|
+
// TODO(lazy-blog-analytics): wire the client fetch + a precomputed aggregate.
|
|
150
|
+
const ANALYTICS_WINDOW_DAYS = 30;
|
|
151
|
+
const topPosts: (Awaited<ReturnType<typeof getTopPosts>>[number] & { last7: number })[] = [];
|
|
152
|
+
const hasEngagement = false;
|
|
153
|
+
const totalWindowViews = 0;
|
|
154
|
+
const reactions: Record<string, Record<Reaction, number>> = {};
|
|
155
|
+
|
|
156
|
+
// A sparkline polyline (points string) for a post's day series, scaled to fit.
|
|
157
|
+
const sparkPoints = (series: DailyViews[] | undefined, w = 108, h = 26): string => {
|
|
158
|
+
const vals = (series ?? []).map((d) => d.views);
|
|
159
|
+
if (vals.length === 0) return `0,${h} ${w},${h}`;
|
|
160
|
+
const max = Math.max(1, ...vals);
|
|
161
|
+
if (vals.length === 1) return `0,${h - (vals[0]! / max) * h} ${w},${h - (vals[0]! / max) * h}`;
|
|
162
|
+
return vals
|
|
163
|
+
.map((v, i) => `${((i / (vals.length - 1)) * w).toFixed(1)},${(h - (v / max) * h).toFixed(1)}`)
|
|
164
|
+
.join(" ");
|
|
165
|
+
};
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
<section
|
|
169
|
+
id="blog"
|
|
170
|
+
class="band blog-tab"
|
|
171
|
+
aria-labelledby="blog-title"
|
|
172
|
+
data-admin-panel
|
|
173
|
+
data-agent-scope="blog"
|
|
174
|
+
role="tabpanel"
|
|
175
|
+
data-multi-collection={multiCollection ? "true" : "false"}
|
|
176
|
+
data-single-collection={multiCollection ? undefined : (collections[0] ?? "blog")}
|
|
177
|
+
data-app-domain={tenant.appDomain}
|
|
178
|
+
>
|
|
179
|
+
<div class="section-title">
|
|
180
|
+
<div>
|
|
181
|
+
<h2 id="blog-title">Blog</h2>
|
|
182
|
+
<p>
|
|
183
|
+
Every article across this store's {multiCollection ? "collections" : "blog"} — drafts,
|
|
184
|
+
scheduled, and published in one place.
|
|
185
|
+
</p>
|
|
186
|
+
</div>
|
|
187
|
+
<div class="actions">
|
|
188
|
+
<button class="btn primary" type="button" data-blog-new-toggle aria-expanded="false">
|
|
189
|
+
<svg><use href="#i-file"></use></svg>New article
|
|
190
|
+
</button>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
<p class="note blog-msg" data-blog-msg hidden></p>
|
|
195
|
+
|
|
196
|
+
<form class="panel blog-new-form" data-blog-new-form hidden>
|
|
197
|
+
<div class="panel-body grid">
|
|
198
|
+
<p class="note">
|
|
199
|
+
Creates a <b>draft</b> post as a content-only change. Review & publish it from the
|
|
200
|
+
<a href="#publish" data-admin-tab-link>Publish</a> tab.
|
|
201
|
+
</p>
|
|
202
|
+
<div class="blog-new-grid">
|
|
203
|
+
{multiCollection && (
|
|
204
|
+
<label class="blog-field">
|
|
205
|
+
<span class="blog-field-label">Collection</span>
|
|
206
|
+
<select data-blog-new-collection>
|
|
207
|
+
{collections.map((collection) => (
|
|
208
|
+
<option value={collection}>{collection}</option>
|
|
209
|
+
))}
|
|
210
|
+
</select>
|
|
211
|
+
</label>
|
|
212
|
+
)}
|
|
213
|
+
<label class="blog-field blog-field-grow">
|
|
214
|
+
<span class="blog-field-label">Title</span>
|
|
215
|
+
<input type="text" placeholder="Article title" autocomplete="off" data-blog-new-title required />
|
|
216
|
+
</label>
|
|
217
|
+
<label class="blog-field">
|
|
218
|
+
<span class="blog-field-label">Category</span>
|
|
219
|
+
<input type="text" placeholder="e.g. Category Insurance" autocomplete="off" list="blog-category-list" data-blog-new-category />
|
|
220
|
+
<datalist id="blog-category-list">
|
|
221
|
+
{categoryOptions.map((category) => (
|
|
222
|
+
<option value={category.label}></option>
|
|
223
|
+
))}
|
|
224
|
+
</datalist>
|
|
225
|
+
</label>
|
|
226
|
+
<label class="blog-field">
|
|
227
|
+
<span class="blog-field-label">Author</span>
|
|
228
|
+
<input type="text" placeholder="Byline" autocomplete="off" data-blog-new-author />
|
|
229
|
+
</label>
|
|
230
|
+
</div>
|
|
231
|
+
<div class="blog-new-actions">
|
|
232
|
+
<button class="btn primary" type="submit" data-blog-new-submit>Create draft</button>
|
|
233
|
+
<button class="btn" type="button" data-blog-new-cancel>Cancel</button>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
</form>
|
|
237
|
+
|
|
238
|
+
<div class="grid cols-4 blog-summary">
|
|
239
|
+
<div class="metric">
|
|
240
|
+
<span>All posts</span>
|
|
241
|
+
<strong>{rows.length}</strong>
|
|
242
|
+
<small>Across {collections.length} {collections.length === 1 ? "collection" : "collections"}</small>
|
|
243
|
+
</div>
|
|
244
|
+
{STATUS_ORDER.map((status) => (
|
|
245
|
+
<div class="metric">
|
|
246
|
+
<span>{STATUS_LABEL[status]}</span>
|
|
247
|
+
<strong>{statusCounts[status]}</strong>
|
|
248
|
+
<small>{status === "published" ? "Live on the site" : status === "scheduled" ? "Queued to publish" : status === "unlisted" ? "Reachable, not listed" : "Not yet published"}</small>
|
|
249
|
+
</div>
|
|
250
|
+
))}
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
{rows.length > 0 && (
|
|
254
|
+
<div class="panel blog-analytics" data-blog-analytics>
|
|
255
|
+
<div class="panel-head">
|
|
256
|
+
<h3><svg><use href="#i-chart"></use></svg>Engagement</h3>
|
|
257
|
+
<span class="chip">Top posts · last {ANALYTICS_WINDOW_DAYS} days</span>
|
|
258
|
+
</div>
|
|
259
|
+
<div class="panel-body grid">
|
|
260
|
+
{hasEngagement ? (
|
|
261
|
+
<>
|
|
262
|
+
<p class="note blog-analytics-lede">
|
|
263
|
+
{totalWindowViews.toLocaleString("en-US")} views in the last {ANALYTICS_WINDOW_DAYS} days
|
|
264
|
+
across your most-read articles. Counts ride the site's page-view beacon — no cookies, no
|
|
265
|
+
per-visitor tracking.
|
|
266
|
+
</p>
|
|
267
|
+
<div class="table-wrap">
|
|
268
|
+
<table class="blog-table blog-analytics-table">
|
|
269
|
+
<thead>
|
|
270
|
+
<tr>
|
|
271
|
+
<th class="rank">#</th>
|
|
272
|
+
<th>Article</th>
|
|
273
|
+
<th class="num">7d</th>
|
|
274
|
+
<th class="num">{ANALYTICS_WINDOW_DAYS}d</th>
|
|
275
|
+
<th class="num">Lifetime</th>
|
|
276
|
+
<th class="trend">Trend</th>
|
|
277
|
+
</tr>
|
|
278
|
+
</thead>
|
|
279
|
+
<tbody>
|
|
280
|
+
{topPosts.map((post, index) => (
|
|
281
|
+
<tr>
|
|
282
|
+
<td class="rank">{index + 1}</td>
|
|
283
|
+
<td class="blog-analytics-article">
|
|
284
|
+
<b>{post.title}</b>
|
|
285
|
+
<span class="subtle">{multiCollection ? `${post.collection}/` : ""}{post.slug}</span>
|
|
286
|
+
</td>
|
|
287
|
+
<td class="num">{post.last7.toLocaleString("en-US")}</td>
|
|
288
|
+
<td class="num"><b>{post.views.toLocaleString("en-US")}</b></td>
|
|
289
|
+
<td class="num">{post.lifetime.toLocaleString("en-US")}</td>
|
|
290
|
+
<td class="trend">
|
|
291
|
+
<svg class="blog-spark" viewBox="0 0 108 26" preserveAspectRatio="none" aria-hidden="true">
|
|
292
|
+
<polyline points={sparkPoints(post.series)} fill="none" />
|
|
293
|
+
</svg>
|
|
294
|
+
</td>
|
|
295
|
+
</tr>
|
|
296
|
+
))}
|
|
297
|
+
</tbody>
|
|
298
|
+
</table>
|
|
299
|
+
</div>
|
|
300
|
+
</>
|
|
301
|
+
) : (
|
|
302
|
+
<p class="note">
|
|
303
|
+
No views recorded yet. As readers visit your published articles, the most-read ones will
|
|
304
|
+
rank here — views over the last 7 and {ANALYTICS_WINDOW_DAYS} days, with a trend sparkline.
|
|
305
|
+
</p>
|
|
306
|
+
)}
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
)}
|
|
310
|
+
|
|
311
|
+
{rows.length > 0 && (
|
|
312
|
+
<div
|
|
313
|
+
class="panel blog-newsletter"
|
|
314
|
+
data-newsletter-panel
|
|
315
|
+
data-single-collection={multiCollection ? undefined : (collections[0] ?? "blog")}
|
|
316
|
+
>
|
|
317
|
+
<div class="panel-head">
|
|
318
|
+
<h3><svg><use href="#i-export"></use></svg>Newsletter</h3>
|
|
319
|
+
<span class="chip">Weekly digest</span>
|
|
320
|
+
</div>
|
|
321
|
+
<div class="panel-body grid">
|
|
322
|
+
<p class="note">
|
|
323
|
+
Email the weekly digest of new posts to confirmed subscribers. You'll see a preview —
|
|
324
|
+
how many posts and recipients — before anything is sent.
|
|
325
|
+
</p>
|
|
326
|
+
<div class="blog-newsletter-actions">
|
|
327
|
+
{multiCollection && (
|
|
328
|
+
<label class="blog-field">
|
|
329
|
+
<span class="blog-field-label">Collection</span>
|
|
330
|
+
<select data-newsletter-collection>
|
|
331
|
+
{collections.map((collection) => (
|
|
332
|
+
<option value={collection}>{collection}</option>
|
|
333
|
+
))}
|
|
334
|
+
</select>
|
|
335
|
+
</label>
|
|
336
|
+
)}
|
|
337
|
+
<button class="btn primary" type="button" data-newsletter-send>
|
|
338
|
+
<svg><use href="#i-export"></use></svg>Send digest…
|
|
339
|
+
</button>
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
</div>
|
|
343
|
+
)}
|
|
344
|
+
|
|
345
|
+
<dialog class="blog-newsletter-dialog" data-newsletter-dialog aria-labelledby="newsletter-dialog-title">
|
|
346
|
+
<form method="dialog" class="blog-newsletter-dialog-form">
|
|
347
|
+
<h3 id="newsletter-dialog-title">Send weekly digest</h3>
|
|
348
|
+
<p class="note" data-newsletter-dialog-status>Calculating…</p>
|
|
349
|
+
<div class="blog-newsletter-preview" data-newsletter-preview hidden>
|
|
350
|
+
<div class="metric">
|
|
351
|
+
<span>New posts</span>
|
|
352
|
+
<strong data-newsletter-post-count>0</strong>
|
|
353
|
+
</div>
|
|
354
|
+
<div class="metric">
|
|
355
|
+
<span>Recipients</span>
|
|
356
|
+
<strong data-newsletter-recipient-count>0</strong>
|
|
357
|
+
</div>
|
|
358
|
+
</div>
|
|
359
|
+
<p class="note blog-newsletter-warn" data-newsletter-dialog-warn hidden></p>
|
|
360
|
+
<div class="blog-newsletter-dialog-actions">
|
|
361
|
+
<button class="btn primary" type="button" data-newsletter-confirm hidden>Send now</button>
|
|
362
|
+
<button class="btn" type="button" data-newsletter-cancel>Cancel</button>
|
|
363
|
+
</div>
|
|
364
|
+
</form>
|
|
365
|
+
</dialog>
|
|
366
|
+
|
|
367
|
+
<div class="blog-workspace" data-has-rail={rows.length > 0 ? "true" : "false"}>
|
|
368
|
+
<div class="panel blog-list-panel">
|
|
369
|
+
<div class="panel-head">
|
|
370
|
+
<h3><svg><use href="#i-file"></use></svg>Posts</h3>
|
|
371
|
+
<span class="chip" data-blog-visible-count>{rows.length} shown</span>
|
|
372
|
+
</div>
|
|
373
|
+
<div class="panel-body grid">
|
|
374
|
+
{rows.length === 0 ? (
|
|
375
|
+
<p class="note">
|
|
376
|
+
This store has no blog content yet. Articles live in
|
|
377
|
+
<code>tenants/<store>/content/<collection>/posts/</code>.
|
|
378
|
+
</p>
|
|
379
|
+
) : (
|
|
380
|
+
<>
|
|
381
|
+
<div class="blog-filters">
|
|
382
|
+
<div class="tabs blog-status-seg" role="group" aria-label="Filter by status">
|
|
383
|
+
<button class="tab active" type="button" data-blog-status-filter="all">
|
|
384
|
+
All <span class="blog-seg-count">{rows.length}</span>
|
|
385
|
+
</button>
|
|
386
|
+
{STATUS_ORDER.filter((status) => statusCounts[status] > 0).map((status) => (
|
|
387
|
+
<button class="tab" type="button" data-blog-status-filter={status}>
|
|
388
|
+
{STATUS_LABEL[status]} <span class="blog-seg-count">{statusCounts[status]}</span>
|
|
389
|
+
</button>
|
|
390
|
+
))}
|
|
391
|
+
</div>
|
|
392
|
+
|
|
393
|
+
<div class="blog-filter-controls">
|
|
394
|
+
{multiCollection && (
|
|
395
|
+
<label class="blog-field">
|
|
396
|
+
<span class="blog-field-label">Collection</span>
|
|
397
|
+
<select data-blog-collection-filter>
|
|
398
|
+
<option value="all">All collections</option>
|
|
399
|
+
{collections.map((collection) => (
|
|
400
|
+
<option value={collection}>{collection}</option>
|
|
401
|
+
))}
|
|
402
|
+
</select>
|
|
403
|
+
</label>
|
|
404
|
+
)}
|
|
405
|
+
<label class="blog-field">
|
|
406
|
+
<span class="blog-field-label">Category</span>
|
|
407
|
+
<select data-blog-category-filter>
|
|
408
|
+
<option value="all">All categories</option>
|
|
409
|
+
{categoryOptions.map((category) => (
|
|
410
|
+
<option value={category.slug}>{category.label}</option>
|
|
411
|
+
))}
|
|
412
|
+
</select>
|
|
413
|
+
</label>
|
|
414
|
+
<label class="blog-field blog-field-search">
|
|
415
|
+
<span class="blog-field-label">Search</span>
|
|
416
|
+
<input
|
|
417
|
+
type="search"
|
|
418
|
+
placeholder="Search title, slug, author…"
|
|
419
|
+
autocomplete="off"
|
|
420
|
+
spellcheck="false"
|
|
421
|
+
data-blog-search
|
|
422
|
+
/>
|
|
423
|
+
</label>
|
|
424
|
+
</div>
|
|
425
|
+
</div>
|
|
426
|
+
|
|
427
|
+
<div class="table-wrap">
|
|
428
|
+
<table class="blog-table">
|
|
429
|
+
<thead>
|
|
430
|
+
<tr>
|
|
431
|
+
<th>Article</th>
|
|
432
|
+
{multiCollection && <th>Collection</th>}
|
|
433
|
+
<th>Status</th>
|
|
434
|
+
<th>Category</th>
|
|
435
|
+
<th>Author</th>
|
|
436
|
+
<th>Updated</th>
|
|
437
|
+
<th class="action">Actions</th>
|
|
438
|
+
</tr>
|
|
439
|
+
</thead>
|
|
440
|
+
<tbody data-blog-body>
|
|
441
|
+
{rows.map(({ collection, post }) => (
|
|
442
|
+
<tr
|
|
443
|
+
data-blog-row
|
|
444
|
+
data-slug={post.slug}
|
|
445
|
+
data-collection={collection}
|
|
446
|
+
data-status={post.status}
|
|
447
|
+
data-categories={post.categories.map((c) => categorySlug(c)).join(" ")}
|
|
448
|
+
data-search={rowSearch({ collection, post })}
|
|
449
|
+
>
|
|
450
|
+
<td class="blog-article" data-status={post.status}>
|
|
451
|
+
<b>{post.title}</b>
|
|
452
|
+
<span class="subtle">{post.slug}</span>
|
|
453
|
+
{post.translationGroupId && (translationGroups.get(post.translationGroupId)?.length ?? 0) > 0 && (
|
|
454
|
+
<div class="blog-langs" aria-label="Language variants">
|
|
455
|
+
{translationGroups.get(post.translationGroupId)!.map((variant) => (
|
|
456
|
+
<span
|
|
457
|
+
class={`blog-lang-chip${variant.slug === post.slug && variant.collection === collection ? " current" : ""}`}
|
|
458
|
+
data-status={variant.status}
|
|
459
|
+
title={`${langLabel(variant.lang)} — ${STATUS_LABEL[variant.status]}`}
|
|
460
|
+
>
|
|
461
|
+
<b>{langLabel(variant.lang)}</b> {STATUS_SHORT[variant.status]}
|
|
462
|
+
</span>
|
|
463
|
+
))}
|
|
464
|
+
</div>
|
|
465
|
+
)}
|
|
466
|
+
</td>
|
|
467
|
+
{multiCollection && <td>{collection}</td>}
|
|
468
|
+
<td>
|
|
469
|
+
<span class={`chip blog-status${STATUS_TONE[post.status] ? " " + STATUS_TONE[post.status] : ""}`}>
|
|
470
|
+
{STATUS_LABEL[post.status]}
|
|
471
|
+
</span>
|
|
472
|
+
</td>
|
|
473
|
+
<td>{post.categories.length > 0 ? post.categories.join(", ") : "—"}</td>
|
|
474
|
+
<td>{post.author || "—"}</td>
|
|
475
|
+
<td>
|
|
476
|
+
<time datetime={primaryDate(post)}>{formatDate(primaryDate(post)) || "—"}</time>
|
|
477
|
+
</td>
|
|
478
|
+
<td class="action">
|
|
479
|
+
<div class="blog-row-actions">
|
|
480
|
+
{post.status === "published" ? (
|
|
481
|
+
<button
|
|
482
|
+
class="btn"
|
|
483
|
+
type="button"
|
|
484
|
+
data-blog-action="unpublish"
|
|
485
|
+
data-blog-slug={post.slug}
|
|
486
|
+
data-blog-collection={collection}
|
|
487
|
+
>
|
|
488
|
+
Unpublish
|
|
489
|
+
</button>
|
|
490
|
+
) : (
|
|
491
|
+
<button
|
|
492
|
+
class="btn primary"
|
|
493
|
+
type="button"
|
|
494
|
+
data-blog-action="publish"
|
|
495
|
+
data-blog-slug={post.slug}
|
|
496
|
+
data-blog-collection={collection}
|
|
497
|
+
>
|
|
498
|
+
Publish
|
|
499
|
+
</button>
|
|
500
|
+
)}
|
|
501
|
+
{/* Edit (in-browser markdown authoring). */}
|
|
502
|
+
<button
|
|
503
|
+
class="btn"
|
|
504
|
+
type="button"
|
|
505
|
+
data-blog-action="edit"
|
|
506
|
+
data-blog-slug={post.slug}
|
|
507
|
+
data-blog-collection={collection}
|
|
508
|
+
>
|
|
509
|
+
Edit
|
|
510
|
+
</button>
|
|
511
|
+
{/* Translate — scaffold a draft sibling language variant. */}
|
|
512
|
+
<button
|
|
513
|
+
class="btn"
|
|
514
|
+
type="button"
|
|
515
|
+
data-blog-action="translate"
|
|
516
|
+
data-blog-slug={post.slug}
|
|
517
|
+
data-blog-collection={collection}
|
|
518
|
+
>
|
|
519
|
+
Translate
|
|
520
|
+
</button>
|
|
521
|
+
<button
|
|
522
|
+
class="btn danger"
|
|
523
|
+
type="button"
|
|
524
|
+
data-blog-action="delete"
|
|
525
|
+
data-blog-slug={post.slug}
|
|
526
|
+
data-blog-collection={collection}
|
|
527
|
+
>
|
|
528
|
+
Delete
|
|
529
|
+
</button>
|
|
530
|
+
</div>
|
|
531
|
+
</td>
|
|
532
|
+
</tr>
|
|
533
|
+
))}
|
|
534
|
+
</tbody>
|
|
535
|
+
</table>
|
|
536
|
+
</div>
|
|
537
|
+
<p class="note" data-blog-empty hidden>No posts match these filters.</p>
|
|
538
|
+
</>
|
|
539
|
+
)}
|
|
540
|
+
</div>
|
|
541
|
+
</div>
|
|
542
|
+
|
|
543
|
+
{rows.length > 0 && <AdminBlogDetailRail rows={rows} discussions={discussions} reactions={reactions} />}
|
|
544
|
+
</div>
|
|
545
|
+
|
|
546
|
+
{/*
|
|
547
|
+
Markdown editor v1 — a plain textarea for title/excerpt/category/author/body.
|
|
548
|
+
Serializes to the post JSON (contentHtml rendered from markdown server-side) via
|
|
549
|
+
the SAME content-only fast lane the row actions use (POST /api/admin/blog action=edit),
|
|
550
|
+
then surfaces the resulting candidate in the preview plane (the shared
|
|
551
|
+
/<tenant>/preview/pr/<N> route the Publish tab iframes). No block editor, no image
|
|
552
|
+
upload — those are Phase 4.
|
|
553
|
+
*/}
|
|
554
|
+
<div class="blog-editor-overlay" data-blog-editor hidden aria-hidden="true">
|
|
555
|
+
<div class="blog-editor-modal" role="dialog" aria-modal="true" aria-labelledby="blog-editor-title">
|
|
556
|
+
<div class="blog-editor-head">
|
|
557
|
+
<h3 id="blog-editor-title">Edit article</h3>
|
|
558
|
+
<button class="btn" type="button" data-blog-editor-close aria-label="Close editor">Close</button>
|
|
559
|
+
</div>
|
|
560
|
+
<div class="blog-editor-grid">
|
|
561
|
+
<form class="blog-editor-form" data-blog-editor-form>
|
|
562
|
+
<input type="hidden" data-editor-slug />
|
|
563
|
+
<input type="hidden" data-editor-collection />
|
|
564
|
+
<label class="blog-field">
|
|
565
|
+
<span class="blog-field-label">Title</span>
|
|
566
|
+
<input type="text" autocomplete="off" data-editor-title required />
|
|
567
|
+
</label>
|
|
568
|
+
<label class="blog-field">
|
|
569
|
+
<span class="blog-field-label">Excerpt</span>
|
|
570
|
+
<textarea rows="2" data-editor-excerpt placeholder="Short summary for listings & SEO"></textarea>
|
|
571
|
+
</label>
|
|
572
|
+
<div class="blog-editor-meta">
|
|
573
|
+
<label class="blog-field">
|
|
574
|
+
<span class="blog-field-label">Category</span>
|
|
575
|
+
<input type="text" autocomplete="off" list="blog-category-list" data-editor-category />
|
|
576
|
+
</label>
|
|
577
|
+
<label class="blog-field">
|
|
578
|
+
<span class="blog-field-label">Author</span>
|
|
579
|
+
<input type="text" autocomplete="off" data-editor-author />
|
|
580
|
+
</label>
|
|
581
|
+
<label class="blog-field">
|
|
582
|
+
<span class="blog-field-label">Access</span>
|
|
583
|
+
<select data-editor-access>
|
|
584
|
+
<option value="public">Public</option>
|
|
585
|
+
<option value="authenticated">Signed-in</option>
|
|
586
|
+
<option value="members">Members</option>
|
|
587
|
+
</select>
|
|
588
|
+
</label>
|
|
589
|
+
</div>
|
|
590
|
+
<label class="blog-field blog-field-grow">
|
|
591
|
+
<span class="blog-field-label">Body (Markdown)</span>
|
|
592
|
+
<textarea rows="16" class="blog-editor-textarea" data-editor-body spellcheck="true" placeholder="# Heading Write your article in Markdown…"></textarea>
|
|
593
|
+
</label>
|
|
594
|
+
<p class="note" data-editor-htmlonly hidden>
|
|
595
|
+
This post has no Markdown source yet — its existing HTML stays unless you write new Markdown here.
|
|
596
|
+
</p>
|
|
597
|
+
<div class="blog-editor-actions">
|
|
598
|
+
<button class="btn primary" type="submit" data-editor-save>Save & preview</button>
|
|
599
|
+
<button class="btn" type="button" data-blog-editor-close>Cancel</button>
|
|
600
|
+
<span class="note blog-editor-status" data-editor-status></span>
|
|
601
|
+
</div>
|
|
602
|
+
</form>
|
|
603
|
+
<div class="blog-editor-preview">
|
|
604
|
+
<div class="blog-editor-preview-head">
|
|
605
|
+
<span class="blog-field-label">Preview plane</span>
|
|
606
|
+
<a class="btn" data-editor-preview-link target="_blank" rel="noopener" hidden>Open ↗</a>
|
|
607
|
+
</div>
|
|
608
|
+
<p class="note blog-editor-preview-empty" data-editor-preview-empty>
|
|
609
|
+
Save to open a content-only change and build its preview here.
|
|
610
|
+
</p>
|
|
611
|
+
<iframe class="blog-editor-preview-frame" data-editor-preview-frame title="Change preview" hidden></iframe>
|
|
612
|
+
</div>
|
|
613
|
+
</div>
|
|
614
|
+
</div>
|
|
615
|
+
</div>
|
|
616
|
+
</section>
|
|
617
|
+
|
|
618
|
+
<style>
|
|
619
|
+
/* Left status stripe on the first cell — a quiet at-a-glance signal that
|
|
620
|
+
doubles the status pill. Tones mirror admin.astro's .chip.* palette. */
|
|
621
|
+
.blog-article {
|
|
622
|
+
border-left: 3px solid var(--admin-line-strong);
|
|
623
|
+
}
|
|
624
|
+
.blog-article[data-status="published"] {
|
|
625
|
+
border-left-color: var(--admin-green);
|
|
626
|
+
}
|
|
627
|
+
.blog-article[data-status="scheduled"] {
|
|
628
|
+
border-left-color: var(--admin-blue);
|
|
629
|
+
}
|
|
630
|
+
.blog-article[data-status="unlisted"] {
|
|
631
|
+
border-left-color: var(--admin-amber);
|
|
632
|
+
}
|
|
633
|
+
.blog-article[data-status="draft"] {
|
|
634
|
+
border-left-color: var(--admin-muted-2);
|
|
635
|
+
}
|
|
636
|
+
.blog-article b {
|
|
637
|
+
display: block;
|
|
638
|
+
font-size: 13px;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/* Two-column workspace: post list + detail rail (mirrors OrdersWorkspace).
|
|
642
|
+
Collapses to one column when there's no rail, and on narrow viewports. */
|
|
643
|
+
.blog-workspace[data-has-rail="true"] {
|
|
644
|
+
display: grid;
|
|
645
|
+
grid-template-columns: minmax(0, 1fr) minmax(340px, 0.42fr);
|
|
646
|
+
gap: 14px;
|
|
647
|
+
align-items: start;
|
|
648
|
+
}
|
|
649
|
+
.blog-list-panel {
|
|
650
|
+
min-width: 0;
|
|
651
|
+
}
|
|
652
|
+
.blog-table tbody tr {
|
|
653
|
+
cursor: pointer;
|
|
654
|
+
}
|
|
655
|
+
.blog-table tbody tr.selected {
|
|
656
|
+
background: var(--admin-teal-soft);
|
|
657
|
+
box-shadow: inset 3px 0 0 var(--admin-teal);
|
|
658
|
+
}
|
|
659
|
+
@media (max-width: 1180px) {
|
|
660
|
+
.blog-workspace[data-has-rail="true"] {
|
|
661
|
+
grid-template-columns: 1fr;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
.blog-filters {
|
|
666
|
+
display: flex;
|
|
667
|
+
flex-wrap: wrap;
|
|
668
|
+
align-items: flex-end;
|
|
669
|
+
justify-content: space-between;
|
|
670
|
+
gap: 12px;
|
|
671
|
+
}
|
|
672
|
+
.blog-status-seg {
|
|
673
|
+
border-bottom: 0;
|
|
674
|
+
}
|
|
675
|
+
.blog-status-seg .tab {
|
|
676
|
+
border: 1px solid var(--admin-line);
|
|
677
|
+
border-radius: 999px;
|
|
678
|
+
padding: 0 12px;
|
|
679
|
+
min-height: 32px;
|
|
680
|
+
}
|
|
681
|
+
.blog-status-seg .tab.active {
|
|
682
|
+
border-color: var(--admin-teal);
|
|
683
|
+
background: var(--admin-teal-soft);
|
|
684
|
+
color: var(--admin-ink);
|
|
685
|
+
}
|
|
686
|
+
.blog-seg-count {
|
|
687
|
+
margin-left: 4px;
|
|
688
|
+
color: var(--admin-muted);
|
|
689
|
+
font-weight: 700;
|
|
690
|
+
}
|
|
691
|
+
.blog-status-seg .tab.active .blog-seg-count {
|
|
692
|
+
color: var(--admin-teal-dark);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
.blog-filter-controls {
|
|
696
|
+
display: flex;
|
|
697
|
+
flex-wrap: wrap;
|
|
698
|
+
align-items: flex-end;
|
|
699
|
+
gap: 10px;
|
|
700
|
+
}
|
|
701
|
+
.blog-field {
|
|
702
|
+
display: grid;
|
|
703
|
+
gap: 3px;
|
|
704
|
+
min-width: 0;
|
|
705
|
+
}
|
|
706
|
+
.blog-field-label {
|
|
707
|
+
color: var(--admin-muted);
|
|
708
|
+
font-size: 11px;
|
|
709
|
+
font-weight: 760;
|
|
710
|
+
text-transform: uppercase;
|
|
711
|
+
}
|
|
712
|
+
.blog-field select,
|
|
713
|
+
.blog-field input {
|
|
714
|
+
min-height: 34px;
|
|
715
|
+
padding: 0 10px;
|
|
716
|
+
border: 1px solid var(--admin-line-strong);
|
|
717
|
+
border-radius: var(--admin-radius-sm);
|
|
718
|
+
background: var(--admin-surface);
|
|
719
|
+
color: var(--admin-ink);
|
|
720
|
+
}
|
|
721
|
+
.blog-field-search input {
|
|
722
|
+
min-width: 200px;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
.blog-status {
|
|
726
|
+
text-transform: none;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/* ---- I4 language-variant chips (in the Article cell) ---- */
|
|
730
|
+
.blog-langs {
|
|
731
|
+
display: flex;
|
|
732
|
+
flex-wrap: wrap;
|
|
733
|
+
gap: 4px;
|
|
734
|
+
margin-top: 5px;
|
|
735
|
+
}
|
|
736
|
+
.blog-lang-chip {
|
|
737
|
+
display: inline-flex;
|
|
738
|
+
align-items: baseline;
|
|
739
|
+
gap: 3px;
|
|
740
|
+
padding: 1px 6px;
|
|
741
|
+
border: 1px solid var(--admin-line);
|
|
742
|
+
border-radius: 999px;
|
|
743
|
+
font-size: 10.5px;
|
|
744
|
+
line-height: 1.5;
|
|
745
|
+
color: var(--admin-muted);
|
|
746
|
+
background: var(--admin-surface);
|
|
747
|
+
white-space: nowrap;
|
|
748
|
+
}
|
|
749
|
+
.blog-lang-chip b {
|
|
750
|
+
font-size: 10.5px;
|
|
751
|
+
font-weight: 780;
|
|
752
|
+
letter-spacing: 0.02em;
|
|
753
|
+
color: var(--admin-ink);
|
|
754
|
+
}
|
|
755
|
+
.blog-lang-chip.current {
|
|
756
|
+
border-color: var(--admin-teal);
|
|
757
|
+
background: var(--admin-teal-soft);
|
|
758
|
+
}
|
|
759
|
+
.blog-lang-chip[data-status="published"] b {
|
|
760
|
+
color: var(--admin-green);
|
|
761
|
+
}
|
|
762
|
+
.blog-lang-chip[data-status="scheduled"] b {
|
|
763
|
+
color: var(--admin-blue);
|
|
764
|
+
}
|
|
765
|
+
.blog-lang-chip[data-status="unlisted"] b {
|
|
766
|
+
color: var(--admin-amber);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
.blog-row-actions {
|
|
770
|
+
display: flex;
|
|
771
|
+
flex-wrap: wrap;
|
|
772
|
+
gap: 6px;
|
|
773
|
+
justify-content: flex-end;
|
|
774
|
+
}
|
|
775
|
+
.blog-row-actions .btn.danger {
|
|
776
|
+
color: var(--admin-danger, #b42318);
|
|
777
|
+
border-color: var(--admin-danger, #b42318);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
.blog-msg {
|
|
781
|
+
margin: 0;
|
|
782
|
+
}
|
|
783
|
+
.blog-msg[data-tone="error"] {
|
|
784
|
+
color: var(--admin-danger, #b42318);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
.blog-new-form {
|
|
788
|
+
margin-top: 0;
|
|
789
|
+
}
|
|
790
|
+
.blog-new-grid {
|
|
791
|
+
display: flex;
|
|
792
|
+
flex-wrap: wrap;
|
|
793
|
+
align-items: flex-end;
|
|
794
|
+
gap: 10px;
|
|
795
|
+
}
|
|
796
|
+
.blog-field-grow {
|
|
797
|
+
flex: 1 1 220px;
|
|
798
|
+
}
|
|
799
|
+
.blog-new-actions {
|
|
800
|
+
display: flex;
|
|
801
|
+
gap: 8px;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/* ---- Engagement / top-posts analytics panel ---- */
|
|
805
|
+
.blog-analytics-lede {
|
|
806
|
+
margin: 0;
|
|
807
|
+
}
|
|
808
|
+
.blog-analytics-table th.num,
|
|
809
|
+
.blog-analytics-table td.num {
|
|
810
|
+
text-align: right;
|
|
811
|
+
white-space: nowrap;
|
|
812
|
+
font-variant-numeric: tabular-nums;
|
|
813
|
+
}
|
|
814
|
+
.blog-analytics-table th.rank,
|
|
815
|
+
.blog-analytics-table td.rank {
|
|
816
|
+
width: 1%;
|
|
817
|
+
color: var(--admin-muted);
|
|
818
|
+
font-variant-numeric: tabular-nums;
|
|
819
|
+
}
|
|
820
|
+
.blog-analytics-article b {
|
|
821
|
+
display: block;
|
|
822
|
+
font-size: 13px;
|
|
823
|
+
}
|
|
824
|
+
.blog-analytics-table th.trend,
|
|
825
|
+
.blog-analytics-table td.trend {
|
|
826
|
+
width: 120px;
|
|
827
|
+
}
|
|
828
|
+
.blog-spark {
|
|
829
|
+
display: block;
|
|
830
|
+
width: 108px;
|
|
831
|
+
height: 26px;
|
|
832
|
+
overflow: visible;
|
|
833
|
+
}
|
|
834
|
+
.blog-spark polyline {
|
|
835
|
+
stroke: var(--admin-teal);
|
|
836
|
+
stroke-width: 2;
|
|
837
|
+
stroke-linecap: round;
|
|
838
|
+
stroke-linejoin: round;
|
|
839
|
+
vector-effect: non-scaling-stroke;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/* ---- B3.4 markdown editor modal ---- */
|
|
843
|
+
.blog-editor-overlay {
|
|
844
|
+
position: fixed;
|
|
845
|
+
inset: 0;
|
|
846
|
+
z-index: 60;
|
|
847
|
+
display: flex;
|
|
848
|
+
align-items: stretch;
|
|
849
|
+
justify-content: center;
|
|
850
|
+
padding: 24px;
|
|
851
|
+
background: color-mix(in srgb, var(--admin-ink) 55%, transparent);
|
|
852
|
+
}
|
|
853
|
+
.blog-editor-overlay[hidden] {
|
|
854
|
+
display: none;
|
|
855
|
+
}
|
|
856
|
+
.blog-editor-modal {
|
|
857
|
+
display: flex;
|
|
858
|
+
flex-direction: column;
|
|
859
|
+
width: min(1080px, 100%);
|
|
860
|
+
max-height: 100%;
|
|
861
|
+
background: var(--admin-surface);
|
|
862
|
+
border: 1px solid var(--admin-line-strong);
|
|
863
|
+
border-radius: var(--admin-radius);
|
|
864
|
+
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.28);
|
|
865
|
+
overflow: hidden;
|
|
866
|
+
}
|
|
867
|
+
.blog-editor-head {
|
|
868
|
+
display: flex;
|
|
869
|
+
align-items: center;
|
|
870
|
+
justify-content: space-between;
|
|
871
|
+
gap: 12px;
|
|
872
|
+
padding: 14px 18px;
|
|
873
|
+
border-bottom: 1px solid var(--admin-line);
|
|
874
|
+
}
|
|
875
|
+
.blog-editor-head h3 {
|
|
876
|
+
margin: 0;
|
|
877
|
+
font-size: 15px;
|
|
878
|
+
}
|
|
879
|
+
.blog-editor-grid {
|
|
880
|
+
display: grid;
|
|
881
|
+
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
|
882
|
+
gap: 0;
|
|
883
|
+
min-height: 0;
|
|
884
|
+
flex: 1;
|
|
885
|
+
}
|
|
886
|
+
.blog-editor-form {
|
|
887
|
+
display: flex;
|
|
888
|
+
flex-direction: column;
|
|
889
|
+
gap: 12px;
|
|
890
|
+
padding: 16px 18px;
|
|
891
|
+
overflow-y: auto;
|
|
892
|
+
border-right: 1px solid var(--admin-line);
|
|
893
|
+
}
|
|
894
|
+
.blog-editor-meta {
|
|
895
|
+
display: grid;
|
|
896
|
+
grid-template-columns: 1fr 1fr;
|
|
897
|
+
gap: 10px;
|
|
898
|
+
}
|
|
899
|
+
.blog-editor-form textarea {
|
|
900
|
+
padding: 8px 10px;
|
|
901
|
+
border: 1px solid var(--admin-line-strong);
|
|
902
|
+
border-radius: var(--admin-radius-sm);
|
|
903
|
+
background: var(--admin-surface);
|
|
904
|
+
color: var(--admin-ink);
|
|
905
|
+
font: inherit;
|
|
906
|
+
resize: vertical;
|
|
907
|
+
}
|
|
908
|
+
.blog-editor-textarea {
|
|
909
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
910
|
+
font-size: 13px;
|
|
911
|
+
line-height: 1.5;
|
|
912
|
+
min-height: 260px;
|
|
913
|
+
}
|
|
914
|
+
.blog-editor-actions {
|
|
915
|
+
display: flex;
|
|
916
|
+
flex-wrap: wrap;
|
|
917
|
+
align-items: center;
|
|
918
|
+
gap: 8px;
|
|
919
|
+
}
|
|
920
|
+
.blog-editor-status[data-tone="error"] {
|
|
921
|
+
color: var(--admin-danger, #b42318);
|
|
922
|
+
}
|
|
923
|
+
.blog-editor-preview {
|
|
924
|
+
display: flex;
|
|
925
|
+
flex-direction: column;
|
|
926
|
+
gap: 8px;
|
|
927
|
+
padding: 16px 18px;
|
|
928
|
+
min-height: 0;
|
|
929
|
+
}
|
|
930
|
+
.blog-editor-preview-head {
|
|
931
|
+
display: flex;
|
|
932
|
+
align-items: center;
|
|
933
|
+
justify-content: space-between;
|
|
934
|
+
gap: 8px;
|
|
935
|
+
}
|
|
936
|
+
.blog-editor-preview-frame {
|
|
937
|
+
flex: 1;
|
|
938
|
+
width: 100%;
|
|
939
|
+
min-height: 320px;
|
|
940
|
+
border: 1px solid var(--admin-line);
|
|
941
|
+
border-radius: var(--admin-radius-sm);
|
|
942
|
+
background: var(--admin-surface);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
@media (max-width: 760px) {
|
|
946
|
+
.blog-filters {
|
|
947
|
+
flex-direction: column;
|
|
948
|
+
align-items: stretch;
|
|
949
|
+
}
|
|
950
|
+
.blog-filter-controls {
|
|
951
|
+
flex-direction: column;
|
|
952
|
+
align-items: stretch;
|
|
953
|
+
}
|
|
954
|
+
.blog-field-search input {
|
|
955
|
+
min-width: 0;
|
|
956
|
+
}
|
|
957
|
+
.blog-editor-grid {
|
|
958
|
+
grid-template-columns: 1fr;
|
|
959
|
+
}
|
|
960
|
+
.blog-editor-form {
|
|
961
|
+
border-right: 0;
|
|
962
|
+
border-bottom: 1px solid var(--admin-line);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/* ---- Newsletter send panel + confirm dialog ---- */
|
|
967
|
+
.blog-newsletter-actions {
|
|
968
|
+
display: flex;
|
|
969
|
+
flex-wrap: wrap;
|
|
970
|
+
align-items: flex-end;
|
|
971
|
+
gap: 12px;
|
|
972
|
+
}
|
|
973
|
+
.blog-newsletter-dialog {
|
|
974
|
+
border: 1px solid var(--admin-line);
|
|
975
|
+
border-radius: 12px;
|
|
976
|
+
padding: 0;
|
|
977
|
+
max-width: 24rem;
|
|
978
|
+
width: calc(100vw - 2rem);
|
|
979
|
+
color: var(--admin-ink, inherit);
|
|
980
|
+
background: var(--admin-surface, #fff);
|
|
981
|
+
}
|
|
982
|
+
.blog-newsletter-dialog::backdrop {
|
|
983
|
+
background: rgba(15, 23, 20, 0.45);
|
|
984
|
+
}
|
|
985
|
+
.blog-newsletter-dialog-form {
|
|
986
|
+
display: grid;
|
|
987
|
+
gap: 12px;
|
|
988
|
+
padding: 16px 18px;
|
|
989
|
+
}
|
|
990
|
+
.blog-newsletter-dialog-form h3 {
|
|
991
|
+
margin: 0;
|
|
992
|
+
font-size: 15px;
|
|
993
|
+
}
|
|
994
|
+
.blog-newsletter-preview {
|
|
995
|
+
display: flex;
|
|
996
|
+
gap: 16px;
|
|
997
|
+
}
|
|
998
|
+
.blog-newsletter-preview .metric strong {
|
|
999
|
+
font-size: 22px;
|
|
1000
|
+
}
|
|
1001
|
+
.blog-newsletter-warn {
|
|
1002
|
+
color: var(--admin-amber, #a15c00);
|
|
1003
|
+
}
|
|
1004
|
+
.blog-newsletter-dialog-actions {
|
|
1005
|
+
display: flex;
|
|
1006
|
+
justify-content: flex-end;
|
|
1007
|
+
gap: 8px;
|
|
1008
|
+
}
|
|
1009
|
+
[data-newsletter-dialog-status][data-tone="error"] {
|
|
1010
|
+
color: var(--admin-red, #b42318);
|
|
1011
|
+
}
|
|
1012
|
+
</style>
|
|
1013
|
+
|
|
1014
|
+
<script is:inline nonce={Astro.locals.cspNonce}>
|
|
1015
|
+
(() => {
|
|
1016
|
+
const root = document.getElementById("blog");
|
|
1017
|
+
if (!root) return;
|
|
1018
|
+
|
|
1019
|
+
// ---- Action fast lane: post/publish/unpublish/delete/new -> /api/admin/blog ----
|
|
1020
|
+
const msg = root.querySelector("[data-blog-msg]");
|
|
1021
|
+
const showMsg = (text, tone) => {
|
|
1022
|
+
if (!msg) return;
|
|
1023
|
+
msg.textContent = text;
|
|
1024
|
+
if (tone) msg.setAttribute("data-tone", tone);
|
|
1025
|
+
else msg.removeAttribute("data-tone");
|
|
1026
|
+
msg.hidden = !text;
|
|
1027
|
+
};
|
|
1028
|
+
|
|
1029
|
+
const post = async (payload) => {
|
|
1030
|
+
const res = await fetch("/api/admin/blog", {
|
|
1031
|
+
method: "POST",
|
|
1032
|
+
headers: { "content-type": "application/json" },
|
|
1033
|
+
body: JSON.stringify(payload),
|
|
1034
|
+
});
|
|
1035
|
+
let data = {};
|
|
1036
|
+
try {
|
|
1037
|
+
data = await res.json();
|
|
1038
|
+
} catch {
|
|
1039
|
+
/* non-JSON error body */
|
|
1040
|
+
}
|
|
1041
|
+
return { ok: res.ok, status: res.status, data };
|
|
1042
|
+
};
|
|
1043
|
+
|
|
1044
|
+
const submitted = (data) => {
|
|
1045
|
+
const pr = data && data.prNumber ? " (#" + data.prNumber + ")" : "";
|
|
1046
|
+
showMsg(
|
|
1047
|
+
"Change opened" + pr + " — review & publish it in the Publish tab.",
|
|
1048
|
+
null,
|
|
1049
|
+
);
|
|
1050
|
+
};
|
|
1051
|
+
const failed = (data, status) => {
|
|
1052
|
+
const why = (data && data.message) || "request failed (" + status + ")";
|
|
1053
|
+
showMsg("Could not complete: " + why, "error");
|
|
1054
|
+
};
|
|
1055
|
+
|
|
1056
|
+
// Row actions (publish / unpublish / delete / edit).
|
|
1057
|
+
root.querySelectorAll('[data-blog-action]').forEach((button) => {
|
|
1058
|
+
const action = button.dataset.blogAction;
|
|
1059
|
+
button.addEventListener("click", async () => {
|
|
1060
|
+
const slug = button.dataset.blogSlug;
|
|
1061
|
+
const collection = button.dataset.blogCollection;
|
|
1062
|
+
if (!slug || !collection) return;
|
|
1063
|
+
if (action === "edit") {
|
|
1064
|
+
window.openEditor?.(collection, slug);
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
let extra = {};
|
|
1068
|
+
if (action === "translate") {
|
|
1069
|
+
const targetLanguage = (
|
|
1070
|
+
prompt(
|
|
1071
|
+
'Translate "' + slug + '" into which language?\nEnter a language code (e.g. es, fr, pt-BR).',
|
|
1072
|
+
) || ""
|
|
1073
|
+
).trim();
|
|
1074
|
+
if (!targetLanguage) return;
|
|
1075
|
+
extra = { targetLanguage };
|
|
1076
|
+
}
|
|
1077
|
+
if (action === "delete" && !confirm('Delete "' + slug + '"? This opens a content-only change.')) {
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
button.disabled = true;
|
|
1081
|
+
showMsg("Working…", null);
|
|
1082
|
+
try {
|
|
1083
|
+
const { ok, status, data } = await post({ action, collection, slug, ...extra });
|
|
1084
|
+
if (ok && data.ok) submitted(data);
|
|
1085
|
+
else failed(data, status);
|
|
1086
|
+
} catch (err) {
|
|
1087
|
+
failed({ message: String(err) }, 0);
|
|
1088
|
+
} finally {
|
|
1089
|
+
button.disabled = false;
|
|
1090
|
+
}
|
|
1091
|
+
});
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
// ---- Markdown editor modal ----
|
|
1095
|
+
const editor = root.querySelector("[data-blog-editor]");
|
|
1096
|
+
const appDomain = root.dataset.appDomain || "";
|
|
1097
|
+
const previewUrlFor = (pr) =>
|
|
1098
|
+
pr != null ? "/" + encodeURIComponent(appDomain) + "/preview/pr/" + pr : null;
|
|
1099
|
+
|
|
1100
|
+
if (editor) {
|
|
1101
|
+
const form = editor.querySelector("[data-blog-editor-form]");
|
|
1102
|
+
const f = {
|
|
1103
|
+
slug: editor.querySelector("[data-editor-slug]"),
|
|
1104
|
+
collection: editor.querySelector("[data-editor-collection]"),
|
|
1105
|
+
title: editor.querySelector("[data-editor-title]"),
|
|
1106
|
+
excerpt: editor.querySelector("[data-editor-excerpt]"),
|
|
1107
|
+
category: editor.querySelector("[data-editor-category]"),
|
|
1108
|
+
author: editor.querySelector("[data-editor-author]"),
|
|
1109
|
+
access: editor.querySelector("[data-editor-access]"),
|
|
1110
|
+
body: editor.querySelector("[data-editor-body]"),
|
|
1111
|
+
};
|
|
1112
|
+
const htmlOnly = editor.querySelector("[data-editor-htmlonly]");
|
|
1113
|
+
const status = editor.querySelector("[data-editor-status]");
|
|
1114
|
+
const previewLink = editor.querySelector("[data-editor-preview-link]");
|
|
1115
|
+
const previewEmpty = editor.querySelector("[data-editor-preview-empty]");
|
|
1116
|
+
const previewFrame = editor.querySelector("[data-editor-preview-frame]");
|
|
1117
|
+
const save = editor.querySelector("[data-editor-save]");
|
|
1118
|
+
|
|
1119
|
+
const editorStatus = (text, tone) => {
|
|
1120
|
+
if (!status) return;
|
|
1121
|
+
status.textContent = text || "";
|
|
1122
|
+
if (tone) status.setAttribute("data-tone", tone);
|
|
1123
|
+
else status.removeAttribute("data-tone");
|
|
1124
|
+
};
|
|
1125
|
+
const resetPreview = () => {
|
|
1126
|
+
if (previewLink) previewLink.hidden = true;
|
|
1127
|
+
if (previewFrame) {
|
|
1128
|
+
previewFrame.hidden = true;
|
|
1129
|
+
previewFrame.removeAttribute("src");
|
|
1130
|
+
}
|
|
1131
|
+
if (previewEmpty) previewEmpty.hidden = false;
|
|
1132
|
+
};
|
|
1133
|
+
const closeEditor = () => {
|
|
1134
|
+
editor.hidden = true;
|
|
1135
|
+
editor.setAttribute("aria-hidden", "true");
|
|
1136
|
+
};
|
|
1137
|
+
editor.querySelectorAll("[data-blog-editor-close]").forEach((b) =>
|
|
1138
|
+
b.addEventListener("click", closeEditor),
|
|
1139
|
+
);
|
|
1140
|
+
editor.addEventListener("click", (event) => {
|
|
1141
|
+
if (event.target === editor) closeEditor();
|
|
1142
|
+
});
|
|
1143
|
+
document.addEventListener("keydown", (event) => {
|
|
1144
|
+
if (event.key === "Escape" && !editor.hidden) closeEditor();
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
window.openEditor = async (collection, slug) => {
|
|
1148
|
+
f.slug.value = slug;
|
|
1149
|
+
f.collection.value = collection;
|
|
1150
|
+
f.title.value = "";
|
|
1151
|
+
f.excerpt.value = "";
|
|
1152
|
+
f.category.value = "";
|
|
1153
|
+
f.author.value = "";
|
|
1154
|
+
f.access.value = "public";
|
|
1155
|
+
f.body.value = "";
|
|
1156
|
+
if (htmlOnly) htmlOnly.hidden = true;
|
|
1157
|
+
resetPreview();
|
|
1158
|
+
editorStatus("Loading…", null);
|
|
1159
|
+
editor.hidden = false;
|
|
1160
|
+
editor.setAttribute("aria-hidden", "false");
|
|
1161
|
+
try {
|
|
1162
|
+
const res = await fetch(
|
|
1163
|
+
"/api/admin/blog?collection=" +
|
|
1164
|
+
encodeURIComponent(collection) +
|
|
1165
|
+
"&slug=" +
|
|
1166
|
+
encodeURIComponent(slug),
|
|
1167
|
+
);
|
|
1168
|
+
const data = await res.json();
|
|
1169
|
+
if (!res.ok || !data.ok) {
|
|
1170
|
+
editorStatus((data && data.message) || "Could not load this post.", "error");
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
const p = data.post;
|
|
1174
|
+
f.title.value = p.title || "";
|
|
1175
|
+
f.excerpt.value = p.excerpt || "";
|
|
1176
|
+
f.category.value = p.category || "";
|
|
1177
|
+
f.author.value = p.author || "";
|
|
1178
|
+
f.access.value = p.access || "public";
|
|
1179
|
+
f.body.value = p.bodyMarkdown || "";
|
|
1180
|
+
if (htmlOnly) htmlOnly.hidden = !p.hasHtmlOnly;
|
|
1181
|
+
editorStatus("", null);
|
|
1182
|
+
f.title.focus();
|
|
1183
|
+
} catch (err) {
|
|
1184
|
+
editorStatus("Could not load this post: " + String(err), "error");
|
|
1185
|
+
}
|
|
1186
|
+
};
|
|
1187
|
+
|
|
1188
|
+
form?.addEventListener("submit", async (event) => {
|
|
1189
|
+
event.preventDefault();
|
|
1190
|
+
const title = (f.title.value || "").trim();
|
|
1191
|
+
if (!title) {
|
|
1192
|
+
editorStatus("A title is required.", "error");
|
|
1193
|
+
f.title.focus();
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
if (save) save.disabled = true;
|
|
1197
|
+
editorStatus("Saving…", null);
|
|
1198
|
+
try {
|
|
1199
|
+
const { ok, status: httpStatus, data } = await post({
|
|
1200
|
+
action: "edit",
|
|
1201
|
+
collection: f.collection.value,
|
|
1202
|
+
slug: f.slug.value,
|
|
1203
|
+
title,
|
|
1204
|
+
excerpt: (f.excerpt.value || "").trim(),
|
|
1205
|
+
category: (f.category.value || "").trim(),
|
|
1206
|
+
author: (f.author.value || "").trim(),
|
|
1207
|
+
access: f.access.value || "public",
|
|
1208
|
+
body: f.body.value || "",
|
|
1209
|
+
});
|
|
1210
|
+
if (!ok || !data.ok) {
|
|
1211
|
+
editorStatus("Could not save: " + ((data && data.message) || httpStatus), "error");
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
const pr = data.prNumber;
|
|
1215
|
+
const url = previewUrlFor(pr);
|
|
1216
|
+
editorStatus("Change opened" + (pr ? " (#" + pr + ")" : "") + " — preview building.", null);
|
|
1217
|
+
submitted(data); // also surface it on the tab-level message strip
|
|
1218
|
+
if (url && previewLink && previewFrame && previewEmpty) {
|
|
1219
|
+
previewLink.href = url;
|
|
1220
|
+
previewLink.hidden = false;
|
|
1221
|
+
previewFrame.src = url;
|
|
1222
|
+
previewFrame.hidden = false;
|
|
1223
|
+
previewEmpty.hidden = true;
|
|
1224
|
+
}
|
|
1225
|
+
} catch (err) {
|
|
1226
|
+
editorStatus("Could not save: " + String(err), "error");
|
|
1227
|
+
} finally {
|
|
1228
|
+
if (save) save.disabled = false;
|
|
1229
|
+
}
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// New-article form.
|
|
1234
|
+
const newToggle = root.querySelector("[data-blog-new-toggle]");
|
|
1235
|
+
const newForm = root.querySelector("[data-blog-new-form]");
|
|
1236
|
+
if (newToggle && newForm) {
|
|
1237
|
+
const setOpen = (open) => {
|
|
1238
|
+
newForm.hidden = !open;
|
|
1239
|
+
newToggle.setAttribute("aria-expanded", open ? "true" : "false");
|
|
1240
|
+
if (open) newForm.querySelector("[data-blog-new-title]")?.focus();
|
|
1241
|
+
};
|
|
1242
|
+
newToggle.addEventListener("click", () => setOpen(newForm.hidden));
|
|
1243
|
+
newForm.querySelector("[data-blog-new-cancel]")?.addEventListener("click", () => setOpen(false));
|
|
1244
|
+
newForm.addEventListener("submit", async (event) => {
|
|
1245
|
+
event.preventDefault();
|
|
1246
|
+
const collectionEl = newForm.querySelector("[data-blog-new-collection]");
|
|
1247
|
+
const collection = collectionEl ? collectionEl.value : root.dataset.singleCollection;
|
|
1248
|
+
const title = (newForm.querySelector("[data-blog-new-title]")?.value || "").trim();
|
|
1249
|
+
const category = (newForm.querySelector("[data-blog-new-category]")?.value || "").trim();
|
|
1250
|
+
const author = (newForm.querySelector("[data-blog-new-author]")?.value || "").trim();
|
|
1251
|
+
if (!collection || !title) {
|
|
1252
|
+
showMsg("A title is required.", "error");
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
const submit = newForm.querySelector("[data-blog-new-submit]");
|
|
1256
|
+
if (submit) submit.disabled = true;
|
|
1257
|
+
showMsg("Creating draft…", null);
|
|
1258
|
+
try {
|
|
1259
|
+
const { ok, status, data } = await post({ action: "new", collection, title, category, author });
|
|
1260
|
+
if (ok && data.ok) {
|
|
1261
|
+
submitted(data);
|
|
1262
|
+
setOpen(false);
|
|
1263
|
+
newForm.reset();
|
|
1264
|
+
} else {
|
|
1265
|
+
failed(data, status);
|
|
1266
|
+
}
|
|
1267
|
+
} catch (err) {
|
|
1268
|
+
failed({ message: String(err) }, 0);
|
|
1269
|
+
} finally {
|
|
1270
|
+
if (submit) submit.disabled = false;
|
|
1271
|
+
}
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// ---- Client-side list filtering (only when there are rows) ----
|
|
1276
|
+
const body = root.querySelector("[data-blog-body]");
|
|
1277
|
+
if (!body) return; // empty state — no filter controls rendered
|
|
1278
|
+
|
|
1279
|
+
const rows = Array.from(body.querySelectorAll("[data-blog-row]"));
|
|
1280
|
+
const statusButtons = Array.from(root.querySelectorAll("[data-blog-status-filter]"));
|
|
1281
|
+
const collectionSelect = root.querySelector("[data-blog-collection-filter]");
|
|
1282
|
+
const categorySelect = root.querySelector("[data-blog-category-filter]");
|
|
1283
|
+
const searchInput = root.querySelector("[data-blog-search]");
|
|
1284
|
+
const visibleCount = root.querySelector("[data-blog-visible-count]");
|
|
1285
|
+
const emptyNote = root.querySelector("[data-blog-empty]");
|
|
1286
|
+
|
|
1287
|
+
const state = { status: "all", collection: "all", category: "all", search: "" };
|
|
1288
|
+
|
|
1289
|
+
const apply = () => {
|
|
1290
|
+
let shown = 0;
|
|
1291
|
+
const term = state.search.trim().toLowerCase();
|
|
1292
|
+
for (const row of rows) {
|
|
1293
|
+
const ds = row.dataset;
|
|
1294
|
+
const categories = (ds.categories || "").split(" ").filter(Boolean);
|
|
1295
|
+
const match =
|
|
1296
|
+
(state.status === "all" || ds.status === state.status) &&
|
|
1297
|
+
(state.collection === "all" || ds.collection === state.collection) &&
|
|
1298
|
+
(state.category === "all" || categories.includes(state.category)) &&
|
|
1299
|
+
(term === "" || (ds.search || "").includes(term));
|
|
1300
|
+
row.hidden = !match;
|
|
1301
|
+
if (match) shown += 1;
|
|
1302
|
+
}
|
|
1303
|
+
if (visibleCount) visibleCount.textContent = shown + " shown";
|
|
1304
|
+
if (emptyNote) emptyNote.hidden = shown !== 0;
|
|
1305
|
+
};
|
|
1306
|
+
|
|
1307
|
+
statusButtons.forEach((button) => {
|
|
1308
|
+
button.addEventListener("click", () => {
|
|
1309
|
+
state.status = button.dataset.blogStatusFilter || "all";
|
|
1310
|
+
statusButtons.forEach((b) => b.classList.toggle("active", b === button));
|
|
1311
|
+
apply();
|
|
1312
|
+
});
|
|
1313
|
+
});
|
|
1314
|
+
collectionSelect?.addEventListener("change", (event) => {
|
|
1315
|
+
state.collection = event.target.value;
|
|
1316
|
+
apply();
|
|
1317
|
+
});
|
|
1318
|
+
categorySelect?.addEventListener("change", (event) => {
|
|
1319
|
+
state.category = event.target.value;
|
|
1320
|
+
apply();
|
|
1321
|
+
});
|
|
1322
|
+
searchInput?.addEventListener("input", (event) => {
|
|
1323
|
+
state.search = event.target.value;
|
|
1324
|
+
apply();
|
|
1325
|
+
});
|
|
1326
|
+
|
|
1327
|
+
apply();
|
|
1328
|
+
|
|
1329
|
+
// ---- Newsletter: dry-run preview -> confirm -> real send ----
|
|
1330
|
+
(() => {
|
|
1331
|
+
const panel = root.querySelector("[data-newsletter-panel]");
|
|
1332
|
+
const dialog = root.querySelector("[data-newsletter-dialog]");
|
|
1333
|
+
if (!panel || !dialog) return;
|
|
1334
|
+
const collectionSel = panel.querySelector("[data-newsletter-collection]");
|
|
1335
|
+
const singleCollection = panel.getAttribute("data-single-collection");
|
|
1336
|
+
const sendBtn = panel.querySelector("[data-newsletter-send]");
|
|
1337
|
+
const statusEl = dialog.querySelector("[data-newsletter-dialog-status]");
|
|
1338
|
+
const previewEl = dialog.querySelector("[data-newsletter-preview]");
|
|
1339
|
+
const postCountEl = dialog.querySelector("[data-newsletter-post-count]");
|
|
1340
|
+
const recipientCountEl = dialog.querySelector("[data-newsletter-recipient-count]");
|
|
1341
|
+
const warnEl = dialog.querySelector("[data-newsletter-dialog-warn]");
|
|
1342
|
+
const confirmBtn = dialog.querySelector("[data-newsletter-confirm]");
|
|
1343
|
+
const cancelBtn = dialog.querySelector("[data-newsletter-cancel]");
|
|
1344
|
+
|
|
1345
|
+
const currentCollection = () => (collectionSel ? collectionSel.value : singleCollection) || "";
|
|
1346
|
+
|
|
1347
|
+
const postSend = async (payload) => {
|
|
1348
|
+
const res = await fetch("/api/admin/newsletter/send", {
|
|
1349
|
+
method: "POST",
|
|
1350
|
+
headers: { "content-type": "application/json" },
|
|
1351
|
+
body: JSON.stringify(payload),
|
|
1352
|
+
});
|
|
1353
|
+
let data = {};
|
|
1354
|
+
try {
|
|
1355
|
+
data = await res.json();
|
|
1356
|
+
} catch {
|
|
1357
|
+
/* non-JSON error body */
|
|
1358
|
+
}
|
|
1359
|
+
return { ok: res.ok, status: res.status, data };
|
|
1360
|
+
};
|
|
1361
|
+
|
|
1362
|
+
const SKIP_TEXT = {
|
|
1363
|
+
"already-sent-this-period": "This week's digest was already sent — nothing to send.",
|
|
1364
|
+
"no-new-posts": "No new posts since the last send — nothing to send.",
|
|
1365
|
+
"no-subscribers": "No confirmed subscribers yet — nothing to send.",
|
|
1366
|
+
};
|
|
1367
|
+
|
|
1368
|
+
const closeDialog = () => {
|
|
1369
|
+
if (dialog.open) dialog.close();
|
|
1370
|
+
};
|
|
1371
|
+
|
|
1372
|
+
let plannedCollection = "";
|
|
1373
|
+
|
|
1374
|
+
sendBtn?.addEventListener("click", async () => {
|
|
1375
|
+
const collection = currentCollection();
|
|
1376
|
+
if (!collection) return;
|
|
1377
|
+
plannedCollection = collection;
|
|
1378
|
+
previewEl.hidden = true;
|
|
1379
|
+
warnEl.hidden = true;
|
|
1380
|
+
confirmBtn.hidden = true;
|
|
1381
|
+
statusEl.textContent = "Calculating what would be sent…";
|
|
1382
|
+
statusEl.removeAttribute("data-tone");
|
|
1383
|
+
if (typeof dialog.showModal === "function") dialog.showModal();
|
|
1384
|
+
else dialog.setAttribute("open", "");
|
|
1385
|
+
|
|
1386
|
+
const { ok, status, data } = await postSend({ collection, dryRun: true });
|
|
1387
|
+
if (!ok || !data || data.ok !== true) {
|
|
1388
|
+
statusEl.textContent =
|
|
1389
|
+
"Couldn't build a preview: " + ((data && data.message) || "request failed (" + status + ")");
|
|
1390
|
+
statusEl.setAttribute("data-tone", "error");
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
postCountEl.textContent = String(data.postCount ?? 0);
|
|
1394
|
+
recipientCountEl.textContent = String(data.recipientCount ?? 0);
|
|
1395
|
+
previewEl.hidden = false;
|
|
1396
|
+
if (data.skip) {
|
|
1397
|
+
statusEl.textContent = SKIP_TEXT[data.skip] || "Nothing to send.";
|
|
1398
|
+
} else if (data.notReady) {
|
|
1399
|
+
statusEl.textContent = "Almost ready — one thing to configure first:";
|
|
1400
|
+
warnEl.textContent = data.notReady;
|
|
1401
|
+
warnEl.hidden = false;
|
|
1402
|
+
} else {
|
|
1403
|
+
statusEl.textContent = "Digest " + (data.digestId || "") + " is ready to send.";
|
|
1404
|
+
confirmBtn.hidden = false;
|
|
1405
|
+
}
|
|
1406
|
+
});
|
|
1407
|
+
|
|
1408
|
+
confirmBtn?.addEventListener("click", async () => {
|
|
1409
|
+
if (!plannedCollection) return;
|
|
1410
|
+
confirmBtn.disabled = true;
|
|
1411
|
+
statusEl.textContent = "Sending…";
|
|
1412
|
+
statusEl.removeAttribute("data-tone");
|
|
1413
|
+
const { ok, status, data } = await postSend({ collection: plannedCollection, dryRun: false });
|
|
1414
|
+
confirmBtn.disabled = false;
|
|
1415
|
+
if (ok && data && data.ok === true) {
|
|
1416
|
+
const r = data.result || {};
|
|
1417
|
+
if (r.skipped) {
|
|
1418
|
+
statusEl.textContent = SKIP_TEXT[r.reason] || "Nothing was sent.";
|
|
1419
|
+
} else {
|
|
1420
|
+
statusEl.textContent =
|
|
1421
|
+
"Sent to " + (r.sent ?? 0) + " " + (r.sent === 1 ? "recipient" : "recipients") + ".";
|
|
1422
|
+
}
|
|
1423
|
+
confirmBtn.hidden = true;
|
|
1424
|
+
setTimeout(closeDialog, 1500);
|
|
1425
|
+
} else {
|
|
1426
|
+
statusEl.textContent =
|
|
1427
|
+
"Send failed: " + ((data && data.message) || "request failed (" + status + ")");
|
|
1428
|
+
statusEl.setAttribute("data-tone", "error");
|
|
1429
|
+
}
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
cancelBtn?.addEventListener("click", closeDialog);
|
|
1433
|
+
})();
|
|
1434
|
+
})();
|
|
1435
|
+
</script>
|