@tokenoftrust/storefront-runner 1.4.0 → 1.4.2-rc.0

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