Implement task 014 assets and media library workflow
This commit is contained in:
@@ -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 ``;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
].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"));
|
||||
Reference in New Issue
Block a user