@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.
- package/apps/storefront/astro.config.mjs +17 -1
- package/apps/storefront/integrations/materialize-guard.mjs +56 -0
- package/apps/storefront/migrations-apps/0001_woozy_lyja.sql +22 -0
- package/apps/storefront/migrations-apps/0002_good_switch.sql +26 -0
- package/apps/storefront/migrations-apps/0003_mute_marauders.sql +12 -0
- package/apps/storefront/migrations-apps/meta/0001_snapshot.json +990 -0
- package/apps/storefront/migrations-apps/meta/0002_snapshot.json +1160 -0
- package/apps/storefront/migrations-apps/meta/0003_snapshot.json +1235 -0
- package/apps/storefront/migrations-apps/meta/_journal.json +21 -0
- package/apps/storefront/package.json +0 -1
- package/apps/storefront/public/js/dashboard-team.js +38 -2
- package/apps/storefront/src/components/Seo.astro +65 -1
- package/apps/storefront/src/components/admin/AdminBlogDetailRail.astro +878 -0
- package/apps/storefront/src/components/admin/AdminBlogTab.astro +1432 -0
- package/apps/storefront/src/components/admin/AdminCustomersTab.astro +3 -7
- package/apps/storefront/src/components/admin/AdminPublishTab.astro +1756 -1382
- package/apps/storefront/src/components/admin/AdminSubscriptionsTab.astro +1 -3
- package/apps/storefront/src/components/blog/BlogPaywall.astro +87 -0
- package/apps/storefront/src/components/blog/PostAccessBadge.astro +43 -0
- package/apps/storefront/src/components/blog/PostGrid.astro +57 -0
- package/apps/storefront/src/components/blog/ReactionBar.astro +214 -0
- package/apps/storefront/src/layouts/Layout.astro +48 -3
- package/apps/storefront/src/lib/activity/alerts.ts +426 -0
- package/apps/storefront/src/lib/activity/changeActorAttribution.ts +85 -0
- package/apps/storefront/src/lib/activity/deployVersion.ts +120 -0
- package/apps/storefront/src/lib/activity/ingest.ts +188 -0
- package/apps/storefront/src/lib/activity/ingestAuth.ts +105 -0
- package/apps/storefront/src/lib/activity/killSwitch.ts +80 -0
- package/apps/storefront/src/lib/activity/query.ts +403 -0
- package/apps/storefront/src/lib/activity/recordActivity.ts +101 -0
- package/apps/storefront/src/lib/activity/store.ts +120 -0
- package/apps/storefront/src/lib/activity/uiActor.ts +150 -0
- package/apps/storefront/src/lib/activity/workerCommit.ts +70 -0
- package/apps/storefront/src/lib/admin/adminShell.ts +290 -0
- package/apps/storefront/src/lib/admin/ai/approvalMode.ts +2 -3
- package/apps/storefront/src/lib/admin/ai/audit.ts +3 -5
- package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +7 -7
- package/apps/storefront/src/lib/admin/checkoutStyleTarget.ts +3 -3
- package/apps/storefront/src/lib/admin/orderClient.ts +3 -5
- package/apps/storefront/src/lib/admin/services/orders.ts +17 -19
- package/apps/storefront/src/lib/admin/viewmodels/aiWorkflows.ts +4 -6
- package/apps/storefront/src/lib/admin/viewmodels/settings.ts +1 -1
- package/apps/storefront/src/lib/apps/adminService.ts +4 -4
- package/apps/storefront/src/lib/apps/adminSession.ts +53 -44
- package/apps/storefront/src/lib/apps/apiAuth.ts +2 -2
- package/apps/storefront/src/lib/apps/apiRoute.ts +2 -2
- package/apps/storefront/src/lib/apps/catalogMapper.ts +1 -1
- package/apps/storefront/src/lib/apps/credentials.ts +2 -2
- package/apps/storefront/src/lib/apps/gatewayKeys.ts +4 -4
- package/apps/storefront/src/lib/apps/healthAggregate.ts +5 -5
- package/apps/storefront/src/lib/apps/orders/attributionService.ts +2 -2
- package/apps/storefront/src/lib/apps/orders/customerHash.ts +1 -1
- package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +12 -12
- package/apps/storefront/src/lib/apps/orders/idempotency.ts +7 -7
- package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +1 -1
- package/apps/storefront/src/lib/apps/orders/orderMapper.ts +2 -2
- package/apps/storefront/src/lib/apps/orders/ordersStore.ts +6 -6
- package/apps/storefront/src/lib/apps/registryService.ts +6 -6
- package/apps/storefront/src/lib/apps/scopes.ts +2 -2
- package/apps/storefront/src/lib/apps/tokenIssuer.ts +4 -4
- package/apps/storefront/src/lib/apps/tokenVerifier.ts +6 -6
- package/apps/storefront/src/lib/apps/widgets/eligibility.ts +2 -2
- package/apps/storefront/src/lib/apps/widgets/frameProps.ts +3 -3
- package/apps/storefront/src/lib/apps/widgets/launchToken.ts +4 -4
- package/apps/storefront/src/lib/apps/widgets/placements.ts +2 -2
- package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +1 -1
- package/apps/storefront/src/lib/auth/adminEntry.ts +9 -9
- package/apps/storefront/src/lib/auth/identityToken.ts +6 -14
- package/apps/storefront/src/lib/auth/loginGate.ts +75 -13
- package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +6 -4
- package/apps/storefront/src/lib/auth/operatorSession.ts +113 -0
- package/apps/storefront/src/lib/auth/route.ts +44 -4
- package/apps/storefront/src/lib/auth/session.ts +7 -8
- package/apps/storefront/src/lib/auth/totAccessClient.ts +8 -18
- package/apps/storefront/src/lib/basePath.ts +9 -2
- package/apps/storefront/src/lib/blog/access.ts +29 -0
- package/apps/storefront/src/lib/blog/collection.ts +313 -0
- package/apps/storefront/src/lib/blog/markdown.ts +174 -0
- package/apps/storefront/src/lib/blog/provider.ts +5 -0
- package/apps/storefront/src/lib/blog/reactions.ts +87 -0
- package/apps/storefront/src/lib/blog/rss.ts +92 -0
- package/apps/storefront/src/lib/blog/teaser.ts +83 -0
- package/apps/storefront/src/lib/blog/types.ts +34 -0
- package/apps/storefront/src/lib/blog/viewCounts.ts +243 -0
- package/apps/storefront/src/lib/d1/catalog.ts +2 -2
- package/apps/storefront/src/lib/d1/schema-apps.ts +147 -14
- package/apps/storefront/src/lib/dev/activityIngestToken.ts +2 -4
- package/apps/storefront/src/lib/dev/activityStore.ts +3 -5
- package/apps/storefront/src/lib/dev/apiBase.ts +1 -4
- package/apps/storefront/src/lib/dev/cockpitStore.ts +1 -9
- package/apps/storefront/src/lib/dev/devDraft.ts +1 -1
- package/apps/storefront/src/lib/dev/previewStatus.ts +9 -21
- package/apps/storefront/src/lib/dev/rendezvousBroker.ts +9 -24
- package/apps/storefront/src/lib/edgeCache.ts +17 -0
- package/apps/storefront/src/lib/env.ts +51 -0
- package/apps/storefront/src/lib/http/fetchTransport.ts +63 -0
- package/apps/storefront/src/lib/i18n.ts +25 -0
- package/apps/storefront/src/lib/jsonld.ts +118 -0
- package/apps/storefront/src/lib/monitoring/manifest.ts +11 -11
- package/apps/storefront/src/lib/newsletter/confirmationEmail.ts +30 -0
- package/apps/storefront/src/lib/newsletter/consent.ts +23 -0
- package/apps/storefront/src/lib/newsletter/digest.ts +117 -0
- package/apps/storefront/src/lib/newsletter/resendClient.ts +79 -0
- package/apps/storefront/src/lib/newsletter/send.ts +265 -0
- package/apps/storefront/src/lib/newsletter/subscribers.ts +156 -0
- package/apps/storefront/src/lib/publish/adminPublishTab.ts +3519 -0
- package/apps/storefront/src/lib/publish/apex-readiness.ts +41 -18
- package/apps/storefront/src/lib/publish/dispatchHealth.ts +5 -7
- package/apps/storefront/src/lib/publish/domainState.ts +22 -13
- package/apps/storefront/src/lib/publish/panelPolling.ts +78 -0
- package/apps/storefront/src/lib/publish/shipWorkspace.ts +100 -42
- package/apps/storefront/src/lib/publish/stageBundleUpload.ts +215 -0
- package/apps/storefront/src/lib/rate/kvWindow.ts +53 -0
- package/apps/storefront/src/lib/search/index.ts +2 -2
- package/apps/storefront/src/lib/seo/alternates.ts +42 -0
- package/apps/storefront/src/lib/seo/meta.ts +65 -0
- package/apps/storefront/src/lib/social/golive.ts +212 -0
- package/apps/storefront/src/lib/social/notify.ts +32 -0
- package/apps/storefront/src/lib/social/tokenStore.ts +171 -0
- package/apps/storefront/src/lib/the-build/provider.ts +27 -0
- package/apps/storefront/src/lib/the-build/readingTime.ts +35 -0
- package/apps/storefront/src/lib/the-build/toc.ts +95 -0
- package/apps/storefront/src/lib/tot/ToTClient.ts +1 -1
- package/apps/storefront/src/lib/tot/d1Client.ts +2 -2
- package/apps/storefront/src/lib/tot/totClientInterface.ts +3 -4
- package/apps/storefront/src/lib/webhooks/deliveryStore.ts +16 -17
- package/apps/storefront/src/middleware/index.ts +130 -2
- package/apps/storefront/src/pages/admin/ops-timeline.astro +420 -0
- package/apps/storefront/src/pages/admin.astro +52 -1206
- package/apps/storefront/src/pages/api/activity.ts +137 -0
- package/apps/storefront/src/pages/api/admin/activity-alerts.ts +73 -0
- package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +27 -1
- package/apps/storefront/src/pages/api/apps/admin/install.ts +26 -1
- package/apps/storefront/src/pages/api/apps/admin/resume.ts +24 -1
- package/apps/storefront/src/pages/api/apps/admin/suspend.ts +24 -1
- package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +25 -1
- package/apps/storefront/src/pages/api/apps/admin/update.ts +24 -1
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +11 -0
- package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +11 -0
- package/apps/storefront/src/pages/api/apps/v1/attribution.ts +20 -0
- package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +12 -0
- package/apps/storefront/src/pages/api/blog/[collection]/[slug]/react.ts +104 -0
- package/apps/storefront/src/pages/api/cache-purge.ts +11 -0
- package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +30 -0
- package/apps/storefront/src/pages/api/newsletter/[collection]/subscribe.ts +146 -0
- package/apps/storefront/src/pages/api/newsletter/confirm.ts +75 -0
- package/apps/storefront/src/pages/api/newsletter/unsubscribe.ts +90 -0
- package/apps/storefront/src/pages/api/publish/stage-bundle.ts +70 -0
- package/apps/storefront/src/pages/api/rum/vitals.ts +21 -2
- package/apps/storefront/src/pages/auth/login.astro +5 -4
- package/apps/storefront/src/pages/blog/[slug].astro +96 -16
- package/apps/storefront/src/pages/blog/author/[author].astro +140 -0
- package/apps/storefront/src/pages/blog/category/[category].astro +67 -0
- package/apps/storefront/src/pages/blog/index.astro +2 -0
- package/apps/storefront/src/pages/blog/tag/[tag].astro +65 -0
- package/apps/storefront/src/pages/cockpit.astro +74 -12
- package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +15 -11
- package/apps/storefront/src/pages/search.astro +1 -1
- package/apps/storefront/src/pages/sitemap.xml.ts +20 -0
- package/apps/storefront/src/pages/the-build/[slug].astro +129 -0
- package/apps/storefront/src/pages/the-build/index.astro +146 -0
- package/apps/storefront/src/pages/the-build/rss.xml.ts +33 -0
- package/apps/storefront/src/styles/admin.css +1080 -0
- package/apps/storefront/tsconfig.json +5 -0
- package/package.json +1 -1
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/src/activity/README.md +146 -0
- package/packages/public-runtime/src/activity/catalog.ts +553 -0
- package/packages/public-runtime/src/activity/event.ts +168 -0
- package/packages/public-runtime/src/activity/index.ts +16 -0
- package/packages/public-runtime/src/activity/redaction.ts +263 -0
- package/packages/public-runtime/src/candidate-index.ts +13 -0
- package/packages/public-runtime/src/catalog-d1.ts +1 -1
- package/packages/public-runtime/src/customization-preview.ts +25 -2
- package/packages/public-runtime/src/customization-runtime.ts +125 -3
- package/packages/public-runtime/src/customization-versioning.ts +10 -0
- package/packages/public-runtime/src/index.ts +7 -0
- package/packages/public-runtime/src/static-bundle.ts +357 -0
- package/packages/public-runtime/src/tenant.ts +36 -8
- package/scripts/build/copy-tenant-assets.mjs +14 -4
- package/scripts/dev/ai-edit.mjs +1 -1
- package/scripts/dev/checkout-watch.mjs +2 -2
- package/scripts/dev/file-browser.mjs +2 -4
- package/scripts/dev/file-writer.mjs +1 -1
- package/scripts/dev/git-status.mjs +2 -2
- package/scripts/dev/locate-handler.mjs +3 -3
- package/scripts/dev/port-check.mjs +3 -3
- package/scripts/dev/publish.mjs +5 -9
- package/scripts/dev/shot.mjs +1 -1
- package/scripts/dev/unified-diff.mjs +8 -3
- package/scripts/tot-dev.mjs +6 -9
- package/apps/storefront/src/lib/webhooks/signing.ts +0 -29
- 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>
|