Implement task 014 assets and media library workflow

This commit is contained in:
2026-05-21 23:55:15 +03:00
parent c3914bf8be
commit 8243f20b0e
23 changed files with 2423 additions and 22 deletions
@@ -0,0 +1,71 @@
import type { AssetSummary } from "@pipeline/shared";
export type MediaLibraryRow = {
id: string;
sectionId: string | null;
status: string;
type: string;
title: string;
preview: string | null;
altText: string | null;
caption: string | null;
publishReady: boolean;
};
export function buildMediaLibraryRows(assets: readonly AssetSummary[]): MediaLibraryRow[] {
return [...assets]
.map((asset) => ({
id: asset.id,
sectionId: asset.section_id ?? null,
status: asset.status,
type: asset.asset_type,
title: asset.title,
preview: asset.file_url ?? null,
altText: asset.alt_text ?? null,
caption: asset.caption ?? null,
publishReady: asset.status === "APPROVED" && Boolean(asset.file_url),
}))
.sort((left, right) => left.title.localeCompare(right.title));
}
export function resolveDraftPreviewMarkdown(
markdown: string,
assets: readonly AssetSummary[],
): string {
if (!markdown) {
return "";
}
const approvedBySection = approvedAssetMapBySection(assets);
return markdown.replace(
/!\[([^\]]*)\]\(placeholder:\/\/([^/]+)\/(\d+)\)/g,
(_full, alt: string, sectionId: string, indexText: string) => {
const index = Number.parseInt(indexText, 10);
const sectionAssets = approvedBySection.get(sectionId) ?? [];
if (!Number.isFinite(index) || index < 1 || index > sectionAssets.length) {
return _full;
}
const asset = sectionAssets[index - 1];
const resolvedAlt = asset.alt_text?.trim() || asset.title || alt || "asset";
return `![${resolvedAlt}](${asset.file_url})`;
},
);
}
function approvedAssetMapBySection(
assets: readonly AssetSummary[],
): Map<string, AssetSummary[]> {
const map = new Map<string, AssetSummary[]>();
for (const asset of assets) {
if (!asset.section_id || asset.status !== "APPROVED" || !asset.file_url) {
continue;
}
const list = map.get(asset.section_id) ?? [];
list.push(asset);
map.set(asset.section_id, list);
}
for (const [sectionId, list] of map.entries()) {
list.sort((left, right) => left.created_at.localeCompare(right.created_at));
map.set(sectionId, list);
}
return map;
}
@@ -0,0 +1,57 @@
"use client";
import { useMemo } from "react";
import type { AssetSummary } from "@pipeline/shared";
import { buildMediaLibraryRows } from "./model";
type MediaLibraryPanelProps = {
assets: readonly AssetSummary[];
};
export function MediaLibraryPanel({ assets }: MediaLibraryPanelProps) {
const rows = useMemo(() => buildMediaLibraryRows(assets), [assets]);
return (
<section>
<h3>Media library</h3>
<table className="table">
<thead>
<tr>
<th>Title</th>
<th>Type</th>
<th>Status</th>
<th>Preview</th>
<th>Alt text</th>
<th>Caption</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td>{row.title}</td>
<td>{row.type}</td>
<td>{row.status}</td>
<td>
{row.preview ? (
<a href={row.preview} target="_blank" rel="noreferrer">
file
</a>
) : (
"—"
)}
</td>
<td>{row.altText ?? "—"}</td>
<td>{row.caption ?? "—"}</td>
</tr>
))}
{rows.length === 0 ? (
<tr>
<td colSpan={6}>No assets yet.</td>
</tr>
) : null}
</tbody>
</table>
</section>
);
}
@@ -3,8 +3,10 @@
import { useMemo, useState } from "react";
import type { DraftSummary } from "@pipeline/shared";
import type { AssetSummary } from "@pipeline/shared";
import { ApiError, assembleDraft, updateDraft } from "@/shared/pipeline-api";
import { resolveDraftPreviewMarkdown } from "@/features/article-assets/model";
import {
compareDraftVersions,
parseDraftPreview,
@@ -14,6 +16,7 @@ import {
type DraftEditorPanelProps = {
articleId: string;
initialDrafts: readonly DraftSummary[];
assets: readonly AssetSummary[];
};
type DraftEditorState = {
@@ -23,7 +26,7 @@ type DraftEditorState = {
bodyMarkdown: string;
};
export function DraftEditorPanel({ articleId, initialDrafts }: DraftEditorPanelProps) {
export function DraftEditorPanel({ articleId, initialDrafts, assets }: DraftEditorPanelProps) {
const [drafts, setDrafts] = useState<DraftSummary[]>(() => [...initialDrafts]);
const [selectedDraftId, setSelectedDraftId] = useState<string | null>(
initialDrafts[0]?.id ?? null,
@@ -44,8 +47,12 @@ export function DraftEditorPanel({ articleId, initialDrafts }: DraftEditorPanelP
[drafts, compareDraftId],
);
const previewBlocks = useMemo(
() => parseDraftPreview(editor.bodyMarkdown, selectedDraft?.faq_items ?? []),
[editor.bodyMarkdown, selectedDraft],
() =>
parseDraftPreview(
resolveDraftPreviewMarkdown(editor.bodyMarkdown, assets),
selectedDraft?.faq_items ?? [],
),
[assets, editor.bodyMarkdown, selectedDraft],
);
const comparison = useMemo(() => {
if (!selectedDraft || !compareDraft || selectedDraft.id === compareDraft.id) {
@@ -6,6 +6,7 @@ import {
} from "@/features/article-detail/model";
import { ArticleDetailShell } from "@/features/article-detail/ui";
import { DraftEditorPanel } from "@/features/article-draft/ui";
import { MediaLibraryPanel } from "@/features/article-assets/ui";
import { fetchArticleDetail, fetchDrafts } from "@/shared/pipeline-api";
import { ApiError } from "@/shared/pipeline-api";
@@ -35,7 +36,12 @@ export default async function ArticleDetailPage({
<RoleNavigation role="EDITOR" />
<section className="panel">
<ArticleDetailShell summary={summary} />
<DraftEditorPanel articleId={articleId} initialDrafts={draftsResponse.drafts ?? []} />
<MediaLibraryPanel assets={detail.assets ?? []} />
<DraftEditorPanel
articleId={articleId}
initialDrafts={draftsResponse.drafts ?? []}
assets={detail.assets ?? []}
/>
</section>
</main>
);
+64
View File
@@ -1,4 +1,9 @@
import type {
AssetGenerateSpecsResponse,
AssetListResponse,
AssetResponse,
AssetUpdateRequest,
AssetUploadResponse,
ArticleCreateRequest,
ArticleCreateResponse,
ArticleDetailResponse,
@@ -459,3 +464,62 @@ export function updateDraft(
request,
);
}
export function generateAssetSpecs(articleId: string): Promise<AssetGenerateSpecsResponse> {
return apiPost<unknown, AssetGenerateSpecsResponse>(
`/api/articles/${articleId}/assets/generate-specs`,
undefined,
);
}
export function fetchAssets(articleId: string): Promise<AssetListResponse> {
return apiGet<AssetListResponse>(`/api/articles/${articleId}/assets`);
}
export function updateAsset(
articleId: string,
assetId: string,
request: AssetUpdateRequest,
): Promise<AssetResponse> {
return apiPatch<AssetUpdateRequest, AssetResponse>(
`/api/articles/${articleId}/assets/${assetId}`,
request,
);
}
export function approveAsset(articleId: string, assetId: string): Promise<AssetResponse> {
return apiPost<unknown, AssetResponse>(
`/api/articles/${articleId}/assets/${assetId}/approve`,
undefined,
);
}
export function uploadAssetFile(
articleId: string,
assetId: string,
file: File,
): Promise<AssetUploadResponse> {
return file.arrayBuffer().then((buffer) => {
const bytes = new Uint8Array(buffer);
let binary = "";
for (let index = 0; index < bytes.length; index += 1) {
binary += String.fromCharCode(bytes[index]);
}
if (typeof btoa !== "function") {
throw new Error("Base64 encoding is not available in this runtime");
}
const content_base64 = btoa(binary);
return requestJson<AssetUploadResponse>(
`/api/articles/${articleId}/assets/${assetId}/upload`,
{
method: "POST",
body: JSON.stringify({
filename: file.name || "asset.bin",
content_base64,
content_type: file.type || "application/octet-stream",
}),
},
DEMO_EDITOR_EMAIL,
);
});
}
@@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
const testDir = path.dirname(fileURLToPath(import.meta.url));
const sourcePath = path.resolve(testDir, "../src/features/article-assets/model.ts");
const source = readFileSync(sourcePath, "utf8");
const compiled = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
});
const moduleExports = {};
new Function("exports", compiled.outputText)(moduleExports);
const { buildMediaLibraryRows, resolveDraftPreviewMarkdown } = moduleExports;
const assets = [
{
id: "asset-approved",
article_id: "article-1",
section_id: "section-1",
asset_type: "hero_image",
title: "Hero visual",
prompt: "hero prompt",
object_key: "articles/article-1/assets/asset-approved/r2/hero.png",
file_url: "file:///tmp/hero.png",
alt_text: "Approved hero visual",
caption: "Used for section one",
status: "APPROVED",
history: [],
created_at: "2026-05-21T00:00:00Z",
updated_at: "2026-05-21T00:00:00Z",
},
{
id: "asset-rejected",
article_id: "article-1",
section_id: "section-1",
asset_type: "diagram",
title: "Rejected visual",
prompt: "diagram prompt",
object_key: "articles/article-1/assets/asset-rejected/r1/rejected.png",
file_url: "file:///tmp/rejected.png",
alt_text: "Rejected visual",
caption: "Do not publish",
status: "REJECTED",
history: [],
created_at: "2026-05-21T00:00:01Z",
updated_at: "2026-05-21T00:00:01Z",
},
];
const rows = buildMediaLibraryRows(assets);
assert.equal(rows.length, 2);
assert.equal(rows[0].title, "Hero visual");
assert.equal(rows[0].publishReady, true);
assert.equal(rows[1].publishReady, false);
const markdown = [
"## Intro",
"",
"![Visual placeholder: Intro](placeholder://section-1/1)",
"![Visual placeholder: Intro 2](placeholder://section-1/2)",
].join("\n");
const resolved = resolveDraftPreviewMarkdown(markdown, assets);
assert.ok(resolved.includes("file:///tmp/hero.png"));
assert.ok(resolved.includes("placeholder://section-1/2"));
assert.ok(!resolved.includes("file:///tmp/rejected.png"));