@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,878 @@
1
+ ---
2
+ /**
3
+ * Blog detail rail — B2.3 of docs/plans/2026-08-21-blog-platform-workstream.md.
4
+ *
5
+ * A row-select side-out for the admin Blog tab, mirroring the established
6
+ * OrdersWorkspace pattern (components/admin/orders/OrdersWorkspace.astro): the
7
+ * list panel and this sticky `aside` sit in a two-column workspace; one hidden
8
+ * `[data-blog-detail-record]` is pre-rendered per post and the row-select script
9
+ * toggles which one is visible + which row is `.selected`. Records are keyed by
10
+ * `(collection, slug)` — the same `data-collection`/`data-slug` the B2.1 table
11
+ * rows already carry — so this component adds NO markup to the table itself
12
+ * (keeps merge friction with the concurrent B3.4 editor low).
13
+ *
14
+ * The rail shows: status, category/pillar, tags (claim tags), author, last-saved,
15
+ * a preview link, a Draft→Preview→Publish→Live lifecycle timeline, the primary
16
+ * Publish/Unpublish action, and Edit/Duplicate/Delete. Publish/Unpublish/Duplicate/
17
+ * Delete ride the SAME content-only fast lane as the table (POST /api/admin/blog);
18
+ * they open a content-only change to review & promote in the Publish tab. Edit
19
+ * (in-browser authoring) is B3.4 — stubbed disabled here.
20
+ *
21
+ * B3.2 adds a Versions panel: per-post git-backed revision history (list +
22
+ * diff) read through `/api/admin/blog-history` (MCP's session-authenticated
23
+ * `repo_commits` / `repo_diff`, `@/lib/forge`). Restore re-submits a prior
24
+ * revision's raw content through the SAME content-only fast lane (POST to
25
+ * the same route, `action:"restore"`) — no new write path.
26
+ */
27
+ import { categorySlug } from "@/lib/blog/collection.js";
28
+ import { REACTIONS, type Reaction } from "@/lib/blog/reactions.js";
29
+ import type { BlogPostSummary, PostStatus } from "@/lib/blog/types.js";
30
+
31
+ interface Props {
32
+ rows: { collection: string; post: BlogPostSummary }[];
33
+ /**
34
+ * C1 editorial link-out: per-post open candidate PR, keyed `"<collection> <slug>"`.
35
+ * Present only when the post has an OPEN candidate awaiting review; absent ⇒ the
36
+ * post is idle/published and no Discussion link renders.
37
+ */
38
+ discussions?: Record<string, { prNumber: number; url: string }>;
39
+ /**
40
+ * E1.2 reader reaction tallies, keyed `"<collection> <slug>"` (same key as the
41
+ * record). Absent ⇒ no counts fetched (all-zero line renders).
42
+ */
43
+ reactions?: Record<string, Record<Reaction, number>>;
44
+ }
45
+
46
+ const { rows, discussions = {}, reactions = {} } = Astro.props;
47
+
48
+ const REACTION_META: Record<Reaction, { emoji: string; label: string }> = {
49
+ like: { emoji: "👍", label: "Like" },
50
+ insightful: { emoji: "💡", label: "Insightful" },
51
+ celebrate: { emoji: "🎉", label: "Celebrate" },
52
+ };
53
+
54
+ const STATUS_LABEL: Record<PostStatus, string> = {
55
+ published: "Published",
56
+ draft: "Draft",
57
+ scheduled: "Scheduled",
58
+ unlisted: "Unlisted",
59
+ };
60
+ const STATUS_TONE: Record<PostStatus, string> = {
61
+ published: "green",
62
+ draft: "",
63
+ scheduled: "blue",
64
+ unlisted: "amber",
65
+ };
66
+
67
+ // The published lifecycle the timeline visualises. A post's status maps to the
68
+ // stage it currently occupies; earlier stages render "done", the current stage
69
+ // "current", later stages "pending". "Preview" is a transient stage (a candidate
70
+ // change is open) we can't detect from the post's static status alone, so it is
71
+ // shown as passed once a post is live and pending while it is still a draft.
72
+ const STAGES = ["Draft", "Preview", "Publish", "Live"] as const;
73
+ const STAGE_INDEX: Record<PostStatus, number> = {
74
+ draft: 0,
75
+ scheduled: 2,
76
+ unlisted: 3,
77
+ published: 3,
78
+ };
79
+
80
+ const primaryDate = (post: BlogPostSummary) => post.modifiedAt || post.publishedAt;
81
+ const formatDate = (iso: string) => {
82
+ const d = new Date(iso);
83
+ return Number.isNaN(d.getTime())
84
+ ? "—"
85
+ : d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
86
+ };
87
+ // Reader URL for a post: /<collection>/<slug>/ (collection id === route base).
88
+ const previewHref = (collection: string, slug: string) => `/${collection}/${slug}/`;
89
+ ---
90
+
91
+ <aside
92
+ class="panel blog-detail-rail"
93
+ id="blog-detail"
94
+ aria-labelledby="blog-detail-title"
95
+ data-blog-detail-root
96
+ data-selected-collection={rows[0]?.collection ?? ""}
97
+ data-selected-slug={rows[0]?.post.slug ?? ""}
98
+ >
99
+ {rows.map(({ collection, post }, index) => {
100
+ const stage = STAGE_INDEX[post.status];
101
+ const isPublished = post.status === "published";
102
+ const discussion = discussions[`${collection} ${post.slug}`];
103
+ const postReactions = reactions[`${collection} ${post.slug}`];
104
+ return (
105
+ <div
106
+ class="blog-detail-record"
107
+ data-blog-detail-record
108
+ data-collection={collection}
109
+ data-slug={post.slug}
110
+ hidden={index !== 0}
111
+ >
112
+ <div class="blog-detail-head">
113
+ <div class="blog-detail-head-main">
114
+ <span class={`chip blog-status${STATUS_TONE[post.status] ? " " + STATUS_TONE[post.status] : ""}`}>
115
+ {STATUS_LABEL[post.status]}
116
+ </span>
117
+ <h3 id={index === 0 ? "blog-detail-title" : undefined}>{post.title}</h3>
118
+ <p class="blog-detail-slug">
119
+ <code>{collection}/{post.slug}</code>
120
+ </p>
121
+ </div>
122
+ </div>
123
+
124
+ <ol class="blog-lifecycle" aria-label="Publishing lifecycle">
125
+ {STAGES.map((label, i) => (
126
+ <li
127
+ class:list={[
128
+ "blog-lifecycle-step",
129
+ { done: i < stage, current: i === stage, pending: i > stage },
130
+ ]}
131
+ aria-current={i === stage ? "step" : undefined}
132
+ >
133
+ <span class="blog-lifecycle-dot" aria-hidden="true"></span>
134
+ <span class="blog-lifecycle-label">{label}</span>
135
+ </li>
136
+ ))}
137
+ </ol>
138
+
139
+ <div class="blog-detail-grid">
140
+ <div>
141
+ <span>Category</span>
142
+ <strong>{post.categories.length > 0 ? post.categories.join(", ") : "—"}</strong>
143
+ </div>
144
+ <div>
145
+ <span>Author</span>
146
+ <strong>{post.author || "—"}</strong>
147
+ </div>
148
+ <div>
149
+ <span>Last saved</span>
150
+ <strong>{formatDate(primaryDate(post))}</strong>
151
+ </div>
152
+ {post.status === "scheduled" && post.scheduledAt && (
153
+ <div>
154
+ <span>Scheduled</span>
155
+ <strong>{formatDate(post.scheduledAt)}</strong>
156
+ </div>
157
+ )}
158
+ </div>
159
+
160
+ <div class="blog-detail-engagement">
161
+ <span class="blog-detail-engagement-label">Reactions</span>
162
+ <div class="blog-detail-reactions">
163
+ {REACTIONS.map((reaction) => (
164
+ <span class="blog-detail-reaction" title={REACTION_META[reaction].label}>
165
+ <span aria-hidden="true">{REACTION_META[reaction].emoji}</span>
166
+ <strong>{postReactions?.[reaction] ?? 0}</strong>
167
+ </span>
168
+ ))}
169
+ </div>
170
+ </div>
171
+
172
+ {post.tags.length > 0 && (
173
+ <div class="blog-detail-tags">
174
+ <span class="blog-detail-tags-label">Tags</span>
175
+ <div class="blog-detail-tags-list">
176
+ {post.tags.map((tag) => (
177
+ <span class="chip">{tag}</span>
178
+ ))}
179
+ </div>
180
+ </div>
181
+ )}
182
+
183
+ <a
184
+ class="blog-detail-preview"
185
+ href={previewHref(collection, post.slug)}
186
+ target="_blank"
187
+ rel="noopener"
188
+ >
189
+ <svg><use href="#i-file"></use></svg>
190
+ <span>{isPublished ? "View on site" : "Preview (visible once published)"}</span>
191
+ </a>
192
+
193
+ {discussion && (
194
+ <a
195
+ class="blog-detail-discussion"
196
+ href={discussion.url}
197
+ target="_blank"
198
+ rel="noopener"
199
+ >
200
+ <svg><use href="#i-ask"></use></svg>
201
+ <span>Discussion (#{discussion.prNumber})</span>
202
+ <span class="blog-detail-discussion-out" aria-hidden="true">↗</span>
203
+ </a>
204
+ )}
205
+
206
+ <div class="blog-detail-actions">
207
+ {isPublished ? (
208
+ <button
209
+ class="btn"
210
+ type="button"
211
+ data-blog-rail-action="unpublish"
212
+ data-blog-slug={post.slug}
213
+ data-blog-collection={collection}
214
+ >
215
+ Unpublish
216
+ </button>
217
+ ) : (
218
+ <button
219
+ class="btn primary"
220
+ type="button"
221
+ data-blog-rail-action="publish"
222
+ data-blog-slug={post.slug}
223
+ data-blog-collection={collection}
224
+ >
225
+ Publish
226
+ </button>
227
+ )}
228
+ {/* Edit (in-browser authoring) is B3.4 — inert placeholder here. */}
229
+ <button
230
+ class="btn"
231
+ type="button"
232
+ data-blog-rail-action="edit"
233
+ data-blog-slug={post.slug}
234
+ data-blog-collection={collection}
235
+ title="In-browser editing arrives with the markdown editor (B3.4)"
236
+ disabled
237
+ >
238
+ Edit
239
+ </button>
240
+ <button
241
+ class="btn"
242
+ type="button"
243
+ data-blog-rail-action="duplicate"
244
+ data-blog-slug={post.slug}
245
+ data-blog-collection={collection}
246
+ data-blog-title={post.title}
247
+ >
248
+ Duplicate
249
+ </button>
250
+ <button
251
+ class="btn danger"
252
+ type="button"
253
+ data-blog-rail-action="delete"
254
+ data-blog-slug={post.slug}
255
+ data-blog-collection={collection}
256
+ >
257
+ Delete
258
+ </button>
259
+ <button
260
+ class="btn"
261
+ type="button"
262
+ data-blog-rail-action="versions"
263
+ data-blog-slug={post.slug}
264
+ data-blog-collection={collection}
265
+ aria-expanded="false"
266
+ aria-controls={`blog-versions-${collection}-${post.slug}`}
267
+ >
268
+ Versions
269
+ </button>
270
+ </div>
271
+
272
+ <div
273
+ class="blog-versions"
274
+ id={`blog-versions-${collection}-${post.slug}`}
275
+ data-blog-versions
276
+ hidden
277
+ >
278
+ <p class="blog-versions-status" data-blog-versions-status>Loading versions…</p>
279
+ <ol class="blog-versions-list" data-blog-versions-list></ol>
280
+ </div>
281
+ </div>
282
+ );
283
+ })}
284
+ </aside>
285
+
286
+ <style>
287
+ .blog-detail-rail {
288
+ position: sticky;
289
+ top: 86px;
290
+ display: grid;
291
+ gap: 12px;
292
+ align-content: start;
293
+ padding: 14px;
294
+ box-shadow: var(--admin-shadow);
295
+ min-width: 0;
296
+ }
297
+
298
+ .blog-detail-head {
299
+ padding-bottom: 12px;
300
+ border-bottom: 1px solid var(--admin-line);
301
+ }
302
+ .blog-detail-head h3 {
303
+ margin: 8px 0 4px;
304
+ font-size: 20px;
305
+ overflow-wrap: anywhere;
306
+ }
307
+ .blog-detail-slug {
308
+ margin: 0;
309
+ font-size: 12px;
310
+ }
311
+ .blog-detail-slug code {
312
+ color: var(--admin-muted);
313
+ overflow-wrap: anywhere;
314
+ }
315
+
316
+ /* Draft → Preview → Publish → Live mini-timeline. */
317
+ .blog-lifecycle {
318
+ display: grid;
319
+ grid-template-columns: repeat(4, 1fr);
320
+ gap: 0;
321
+ margin: 0;
322
+ padding: 0;
323
+ list-style: none;
324
+ }
325
+ .blog-lifecycle-step {
326
+ display: grid;
327
+ justify-items: center;
328
+ gap: 6px;
329
+ position: relative;
330
+ padding-top: 4px;
331
+ }
332
+ /* Connector line between dots. */
333
+ .blog-lifecycle-step::before {
334
+ content: "";
335
+ position: absolute;
336
+ top: 10px;
337
+ left: -50%;
338
+ width: 100%;
339
+ height: 2px;
340
+ background: var(--admin-line-strong);
341
+ }
342
+ .blog-lifecycle-step:first-child::before {
343
+ display: none;
344
+ }
345
+ .blog-lifecycle-step.done::before,
346
+ .blog-lifecycle-step.current::before {
347
+ background: var(--admin-teal);
348
+ }
349
+ .blog-lifecycle-dot {
350
+ width: 13px;
351
+ height: 13px;
352
+ border-radius: 999px;
353
+ border: 2px solid var(--admin-line-strong);
354
+ background: var(--admin-surface);
355
+ z-index: 1;
356
+ }
357
+ .blog-lifecycle-step.done .blog-lifecycle-dot {
358
+ border-color: var(--admin-teal);
359
+ background: var(--admin-teal);
360
+ }
361
+ .blog-lifecycle-step.current .blog-lifecycle-dot {
362
+ border-color: var(--admin-teal);
363
+ background: var(--admin-surface);
364
+ box-shadow: 0 0 0 3px var(--admin-teal-soft);
365
+ }
366
+ .blog-lifecycle-label {
367
+ font-size: 11px;
368
+ font-weight: 760;
369
+ color: var(--admin-muted);
370
+ }
371
+ .blog-lifecycle-step.current .blog-lifecycle-label,
372
+ .blog-lifecycle-step.done .blog-lifecycle-label {
373
+ color: var(--admin-ink);
374
+ }
375
+
376
+ .blog-detail-grid {
377
+ display: grid;
378
+ grid-template-columns: repeat(2, minmax(0, 1fr));
379
+ gap: 8px;
380
+ }
381
+ .blog-detail-grid > div {
382
+ min-width: 0;
383
+ padding: 10px;
384
+ border: 1px solid var(--admin-line);
385
+ border-radius: var(--admin-radius-sm);
386
+ background: var(--admin-surface-soft);
387
+ }
388
+ .blog-detail-grid span {
389
+ color: var(--admin-muted);
390
+ font-size: 11px;
391
+ font-weight: 760;
392
+ text-transform: uppercase;
393
+ }
394
+ .blog-detail-grid strong {
395
+ display: block;
396
+ margin-top: 4px;
397
+ overflow-wrap: anywhere;
398
+ font-size: 13px;
399
+ }
400
+
401
+ .blog-detail-engagement {
402
+ display: grid;
403
+ gap: 6px;
404
+ }
405
+ .blog-detail-engagement-label {
406
+ color: var(--admin-muted);
407
+ font-size: 11px;
408
+ font-weight: 760;
409
+ text-transform: uppercase;
410
+ }
411
+ .blog-detail-reactions {
412
+ display: flex;
413
+ flex-wrap: wrap;
414
+ gap: 6px;
415
+ }
416
+ .blog-detail-reaction {
417
+ display: inline-flex;
418
+ align-items: center;
419
+ gap: 5px;
420
+ padding: 5px 9px;
421
+ border: 1px solid var(--admin-line);
422
+ border-radius: var(--admin-radius-sm);
423
+ background: var(--admin-surface-soft);
424
+ font-size: 13px;
425
+ }
426
+ .blog-detail-reaction strong {
427
+ font-variant-numeric: tabular-nums;
428
+ }
429
+
430
+ .blog-detail-tags {
431
+ display: grid;
432
+ gap: 6px;
433
+ }
434
+ .blog-detail-tags-label {
435
+ color: var(--admin-muted);
436
+ font-size: 11px;
437
+ font-weight: 760;
438
+ text-transform: uppercase;
439
+ }
440
+ .blog-detail-tags-list {
441
+ display: flex;
442
+ flex-wrap: wrap;
443
+ gap: 6px;
444
+ }
445
+
446
+ .blog-detail-preview {
447
+ display: inline-flex;
448
+ align-items: center;
449
+ gap: 8px;
450
+ padding: 9px 12px;
451
+ border: 1px solid var(--admin-line-strong);
452
+ border-radius: var(--admin-radius-sm);
453
+ background: var(--admin-surface);
454
+ color: var(--admin-ink);
455
+ font-size: 13px;
456
+ font-weight: 700;
457
+ text-decoration: none;
458
+ }
459
+ .blog-detail-preview svg {
460
+ width: 15px;
461
+ height: 15px;
462
+ }
463
+ .blog-detail-preview:hover {
464
+ border-color: var(--admin-teal);
465
+ }
466
+
467
+ .blog-detail-discussion {
468
+ display: inline-flex;
469
+ align-items: center;
470
+ gap: 8px;
471
+ padding: 9px 12px;
472
+ border: 1px solid var(--admin-line-strong);
473
+ border-radius: var(--admin-radius-sm);
474
+ background: var(--admin-surface);
475
+ color: var(--admin-ink);
476
+ font-size: 13px;
477
+ font-weight: 700;
478
+ text-decoration: none;
479
+ }
480
+ .blog-detail-discussion svg {
481
+ width: 15px;
482
+ height: 15px;
483
+ }
484
+ .blog-detail-discussion:hover {
485
+ border-color: var(--admin-teal);
486
+ }
487
+ .blog-detail-discussion-out {
488
+ margin-left: auto;
489
+ color: var(--admin-muted);
490
+ }
491
+
492
+ .blog-detail-actions {
493
+ display: flex;
494
+ flex-wrap: wrap;
495
+ gap: 6px;
496
+ padding-top: 12px;
497
+ border-top: 1px solid var(--admin-line);
498
+ }
499
+ .blog-detail-actions .btn.danger {
500
+ color: var(--admin-danger, #b42318);
501
+ border-color: var(--admin-danger, #b42318);
502
+ }
503
+ .blog-detail-actions .btn.primary {
504
+ flex: 1 1 auto;
505
+ }
506
+
507
+ .blog-status {
508
+ text-transform: none;
509
+ }
510
+
511
+ .blog-versions {
512
+ display: grid;
513
+ gap: 8px;
514
+ padding-top: 12px;
515
+ border-top: 1px solid var(--admin-line);
516
+ }
517
+ .blog-versions-status {
518
+ margin: 0;
519
+ color: var(--admin-muted);
520
+ font-size: 12px;
521
+ }
522
+ .blog-versions-list {
523
+ display: grid;
524
+ gap: 8px;
525
+ margin: 0;
526
+ padding: 0;
527
+ list-style: none;
528
+ }
529
+ .blog-versions-item {
530
+ display: grid;
531
+ gap: 6px;
532
+ padding: 10px;
533
+ border: 1px solid var(--admin-line);
534
+ border-radius: var(--admin-radius-sm);
535
+ background: var(--admin-surface-soft);
536
+ }
537
+ .blog-versions-item-head {
538
+ display: flex;
539
+ align-items: baseline;
540
+ justify-content: space-between;
541
+ gap: 8px;
542
+ flex-wrap: wrap;
543
+ }
544
+ .blog-versions-item-message {
545
+ font-size: 13px;
546
+ font-weight: 700;
547
+ overflow-wrap: anywhere;
548
+ }
549
+ .blog-versions-item-meta {
550
+ color: var(--admin-muted);
551
+ font-size: 11px;
552
+ }
553
+ .blog-versions-item-actions {
554
+ display: flex;
555
+ gap: 6px;
556
+ }
557
+ .blog-versions-item-actions .btn {
558
+ padding: 5px 10px;
559
+ font-size: 12px;
560
+ }
561
+ .blog-versions-diff {
562
+ margin: 0;
563
+ padding: 8px;
564
+ max-height: 260px;
565
+ overflow: auto;
566
+ border: 1px solid var(--admin-line);
567
+ border-radius: var(--admin-radius-sm);
568
+ background: var(--admin-surface);
569
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
570
+ font-size: 11.5px;
571
+ line-height: 1.5;
572
+ white-space: pre-wrap;
573
+ word-break: break-word;
574
+ }
575
+
576
+ @media (max-width: 1180px) {
577
+ .blog-detail-rail {
578
+ position: static;
579
+ }
580
+ }
581
+ @media (max-width: 760px) {
582
+ .blog-detail-grid {
583
+ grid-template-columns: 1fr;
584
+ }
585
+ }
586
+ </style>
587
+
588
+ <script is:inline nonce={Astro.locals.cspNonce}>
589
+ (() => {
590
+ const root = document.getElementById("blog");
591
+ const rail = root?.querySelector("[data-blog-detail-root]");
592
+ if (!root || !rail) return;
593
+
594
+ const rows = Array.from(root.querySelectorAll("[data-blog-row]"));
595
+ const records = Array.from(rail.querySelectorAll("[data-blog-detail-record]"));
596
+
597
+ const keyOf = (el) => (el.dataset.collection || "") + "" + (el.dataset.slug || "");
598
+
599
+ const select = (collection, slug) => {
600
+ const key = collection + "" + slug;
601
+ for (const row of rows) {
602
+ const on = keyOf(row) === key;
603
+ row.classList.toggle("selected", on);
604
+ row.setAttribute("aria-pressed", on ? "true" : "false");
605
+ }
606
+ for (const record of records) {
607
+ record.hidden = keyOf(record) !== key;
608
+ }
609
+ rail.dataset.selectedCollection = collection;
610
+ rail.dataset.selectedSlug = slug;
611
+ };
612
+
613
+ // Make table rows a keyboard-operable "open detail" affordance without editing
614
+ // the B2.1 table markup: promote each row to a button-like control here.
615
+ rows.forEach((row) => {
616
+ row.setAttribute("role", "button");
617
+ row.setAttribute("tabindex", "0");
618
+ row.setAttribute("aria-controls", "blog-detail");
619
+ const open = (event) => {
620
+ // Don't hijack clicks/keys on the row's own action controls.
621
+ if (event.target instanceof Element && event.target.closest("button, a, input, select")) {
622
+ return;
623
+ }
624
+ const { collection, slug } = row.dataset;
625
+ if (collection && slug) select(collection, slug);
626
+ };
627
+ row.addEventListener("click", open);
628
+ row.addEventListener("keydown", (event) => {
629
+ if (event.key === "Enter" || event.key === " ") {
630
+ event.preventDefault();
631
+ open(event);
632
+ }
633
+ });
634
+ });
635
+
636
+ // Rail action buttons ride the same content-only fast lane as the table.
637
+ const msg = root.querySelector("[data-blog-msg]");
638
+ const showMsg = (text, tone) => {
639
+ if (!msg) return;
640
+ msg.textContent = text;
641
+ if (tone) msg.setAttribute("data-tone", tone);
642
+ else msg.removeAttribute("data-tone");
643
+ msg.hidden = !text;
644
+ };
645
+ const post = async (payload) => {
646
+ const res = await fetch("/api/admin/blog", {
647
+ method: "POST",
648
+ headers: { "content-type": "application/json" },
649
+ body: JSON.stringify(payload),
650
+ });
651
+ let data = {};
652
+ try {
653
+ data = await res.json();
654
+ } catch {
655
+ /* non-JSON error body */
656
+ }
657
+ return { ok: res.ok, status: res.status, data };
658
+ };
659
+ const submitted = (data) => {
660
+ const pr = data && data.prNumber ? " (#" + data.prNumber + ")" : "";
661
+ showMsg("Change opened" + pr + " — review & publish it in the Publish tab.", null);
662
+ };
663
+ const failed = (data, status) => {
664
+ const why = (data && data.message) || "request failed (" + status + ")";
665
+ showMsg("Could not complete: " + why, "error");
666
+ };
667
+
668
+ rail.querySelectorAll("[data-blog-rail-action]").forEach((button) => {
669
+ const action = button.dataset.blogRailAction;
670
+ // edit is B3.4 (inert); versions is a panel toggle, wired separately below.
671
+ if (action === "edit" || action === "versions" || button.disabled) return;
672
+ button.addEventListener("click", async () => {
673
+ const collection = button.dataset.blogCollection;
674
+ const slug = button.dataset.blogSlug;
675
+ if (!collection || !slug) return;
676
+
677
+ let payload;
678
+ if (action === "duplicate") {
679
+ // Duplicate = create a fresh draft copy via the existing `new` action.
680
+ const title = ((button.dataset.blogTitle || slug) + " (copy)").trim();
681
+ payload = { action: "new", collection, title };
682
+ } else {
683
+ if (action === "delete" && !confirm('Delete "' + slug + '"? This opens a content-only change.')) {
684
+ return;
685
+ }
686
+ payload = { action, collection, slug };
687
+ }
688
+
689
+ button.disabled = true;
690
+ showMsg("Working…", null);
691
+ try {
692
+ const { ok, status, data } = await post(payload);
693
+ if (ok && data.ok) submitted(data);
694
+ else failed(data, status);
695
+ } catch (err) {
696
+ failed({ message: String(err) }, 0);
697
+ } finally {
698
+ button.disabled = false;
699
+ }
700
+ });
701
+ });
702
+
703
+ // Versions panel (B3.2): lazy-loaded git-backed revision history per post,
704
+ // read via /api/admin/blog-history (repo_commits / repo_diff). Restore rides
705
+ // the SAME content-only fast lane (postHistory below → the route's own
706
+ // getChangeClient().openCandidate, keyed by the same blog-<collection>-<slug>
707
+ // changeId as every other blog action).
708
+ const shortSha = (sha) => String(sha || "").slice(0, 7);
709
+ const versionsLoaded = new Set(); // "collection/slug" already fetched once
710
+
711
+ const fetchJson = async (url) => {
712
+ const res = await fetch(url);
713
+ let data = {};
714
+ try {
715
+ data = await res.json();
716
+ } catch {
717
+ /* non-JSON error body */
718
+ }
719
+ return { ok: res.ok, status: res.status, data };
720
+ };
721
+ const postHistory = async (payload) => {
722
+ const res = await fetch("/api/admin/blog-history", {
723
+ method: "POST",
724
+ headers: { "content-type": "application/json" },
725
+ body: JSON.stringify(payload),
726
+ });
727
+ let data = {};
728
+ try {
729
+ data = await res.json();
730
+ } catch {
731
+ /* non-JSON error body */
732
+ }
733
+ return { ok: res.ok, status: res.status, data };
734
+ };
735
+
736
+ function renderVersions(panel, collection, slug, commits) {
737
+ const list = panel.querySelector("[data-blog-versions-list]");
738
+ const status = panel.querySelector("[data-blog-versions-status]");
739
+ list.innerHTML = "";
740
+ if (!commits.length) {
741
+ status.textContent = "No revision history yet.";
742
+ status.hidden = false;
743
+ return;
744
+ }
745
+ status.hidden = true;
746
+
747
+ commits.forEach((commit, index) => {
748
+ const li = document.createElement("li");
749
+ li.className = "blog-versions-item";
750
+
751
+ const head = document.createElement("div");
752
+ head.className = "blog-versions-item-head";
753
+ const message = document.createElement("span");
754
+ message.className = "blog-versions-item-message";
755
+ message.textContent = commit.message || "(no message)";
756
+ const meta = document.createElement("span");
757
+ meta.className = "blog-versions-item-meta";
758
+ const when = commit.date ? new Date(commit.date).toLocaleString() : "";
759
+ meta.textContent =
760
+ shortSha(commit.sha) + " · " + (commit.author || "unknown") + (when ? " · " + when : "");
761
+ head.append(message, meta);
762
+
763
+ const actions = document.createElement("div");
764
+ actions.className = "blog-versions-item-actions";
765
+
766
+ const diffBtn = document.createElement("button");
767
+ diffBtn.type = "button";
768
+ diffBtn.className = "btn";
769
+ diffBtn.textContent = "Diff";
770
+ const parent = commits[index + 1];
771
+ if (!parent) {
772
+ diffBtn.disabled = true;
773
+ diffBtn.title = "No earlier loaded revision to diff against";
774
+ }
775
+
776
+ const restoreBtn = document.createElement("button");
777
+ restoreBtn.type = "button";
778
+ restoreBtn.className = "btn";
779
+ restoreBtn.textContent = "Restore";
780
+
781
+ actions.append(diffBtn, restoreBtn);
782
+ li.append(head, actions);
783
+
784
+ const diffPre = document.createElement("pre");
785
+ diffPre.className = "blog-versions-diff";
786
+ diffPre.hidden = true;
787
+ li.append(diffPre);
788
+
789
+ diffBtn.addEventListener("click", async () => {
790
+ if (!diffPre.hidden) {
791
+ diffPre.hidden = true;
792
+ return;
793
+ }
794
+ if (!parent) return;
795
+ diffBtn.disabled = true;
796
+ diffPre.hidden = false;
797
+ diffPre.textContent = "Loading diff…";
798
+ const url =
799
+ "/api/admin/blog-history?collection=" + encodeURIComponent(collection) +
800
+ "&slug=" + encodeURIComponent(slug) +
801
+ "&base=" + encodeURIComponent(parent.sha) +
802
+ "&head=" + encodeURIComponent(commit.sha);
803
+ const { ok, data } = await fetchJson(url);
804
+ diffBtn.disabled = false;
805
+ if (!ok || !data.ok) {
806
+ diffPre.textContent = "Could not load diff: " + ((data && data.message) || "request failed");
807
+ return;
808
+ }
809
+ const files = data.files || [];
810
+ diffPre.textContent = files.length
811
+ ? files.map((f) => f.patch).join("\n\n")
812
+ : "No changes between these revisions.";
813
+ });
814
+
815
+ restoreBtn.addEventListener("click", async () => {
816
+ const ok0 = confirm(
817
+ 'Restore "' + slug + '" to revision ' + shortSha(commit.sha) +
818
+ '? This opens a content-only change.',
819
+ );
820
+ if (!ok0) return;
821
+ restoreBtn.disabled = true;
822
+ showMsg("Working…", null);
823
+ try {
824
+ const { ok, status: code, data } = await postHistory({
825
+ action: "restore", collection, slug, sha: commit.sha,
826
+ });
827
+ if (ok && data.ok) submitted(data);
828
+ else failed(data, code);
829
+ } catch (err) {
830
+ failed({ message: String(err) }, 0);
831
+ } finally {
832
+ restoreBtn.disabled = false;
833
+ }
834
+ });
835
+
836
+ list.append(li);
837
+ });
838
+ }
839
+
840
+ rail.querySelectorAll('[data-blog-rail-action="versions"]').forEach((button) => {
841
+ button.addEventListener("click", async () => {
842
+ const collection = button.dataset.blogCollection;
843
+ const slug = button.dataset.blogSlug;
844
+ const controls = button.getAttribute("aria-controls");
845
+ if (!collection || !slug || !controls) return;
846
+ const panel = document.getElementById(controls);
847
+ if (!panel) return;
848
+
849
+ const wasHidden = panel.hidden;
850
+ panel.hidden = !wasHidden;
851
+ button.setAttribute("aria-expanded", wasHidden ? "true" : "false");
852
+ if (!wasHidden) return; // just closed — nothing to load
853
+
854
+ const key = collection + "/" + slug;
855
+ if (versionsLoaded.has(key)) return; // already fetched once this page load
856
+
857
+ const status = panel.querySelector("[data-blog-versions-status]");
858
+ const url =
859
+ "/api/admin/blog-history?collection=" + encodeURIComponent(collection) +
860
+ "&slug=" + encodeURIComponent(slug);
861
+ const { ok, data } = await fetchJson(url);
862
+ if (!ok || !data.ok) {
863
+ status.textContent = "Could not load versions: " + ((data && data.message) || "request failed");
864
+ status.hidden = false;
865
+ return; // leave un-cached so re-opening retries
866
+ }
867
+ versionsLoaded.add(key);
868
+ renderVersions(panel, collection, slug, data.commits || []);
869
+ });
870
+ });
871
+
872
+ // Default selection: the first row (its record is the one rendered visible).
873
+ if (rows[0]) {
874
+ const { collection, slug } = rows[0].dataset;
875
+ if (collection && slug) select(collection, slug);
876
+ }
877
+ })();
878
+ </script>