Files
content-factory/apps/frontend/src/features/article-draft/ui.tsx
T

300 lines
9.5 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
import type { DraftSummary } from "@pipeline/shared";
import { ApiError, assembleDraft, updateDraft } from "@/shared/pipeline-api";
import {
compareDraftVersions,
parseDraftPreview,
selectDraftById,
} from "./model";
type DraftEditorPanelProps = {
articleId: string;
initialDrafts: readonly DraftSummary[];
};
type DraftEditorState = {
title: string;
metaTitle: string;
metaDescription: string;
bodyMarkdown: string;
};
export function DraftEditorPanel({ articleId, initialDrafts }: DraftEditorPanelProps) {
const [drafts, setDrafts] = useState<DraftSummary[]>(() => [...initialDrafts]);
const [selectedDraftId, setSelectedDraftId] = useState<string | null>(
initialDrafts[0]?.id ?? null,
);
const [compareDraftId, setCompareDraftId] = useState<string | null>(null);
const [message, setMessage] = useState("");
const [isBusy, setIsBusy] = useState(false);
const [editor, setEditor] = useState<DraftEditorState>(() =>
createEditorState(initialDrafts[0] ?? null),
);
const selectedDraft = useMemo(
() => selectDraftById(drafts, selectedDraftId),
[drafts, selectedDraftId],
);
const compareDraft = useMemo(
() => selectDraftById(drafts, compareDraftId),
[drafts, compareDraftId],
);
const previewBlocks = useMemo(
() => parseDraftPreview(editor.bodyMarkdown, selectedDraft?.faq_items ?? []),
[editor.bodyMarkdown, selectedDraft],
);
const comparison = useMemo(() => {
if (!selectedDraft || !compareDraft || selectedDraft.id === compareDraft.id) {
return null;
}
return compareDraftVersions(selectedDraft, compareDraft);
}, [selectedDraft, compareDraft]);
function onSelectDraft(draftId: string) {
setSelectedDraftId(draftId);
setCompareDraftId((current) => (current === draftId ? null : current));
const nextDraft = selectDraftById(drafts, draftId);
setEditor(createEditorState(nextDraft));
}
async function onAssembleDraft() {
setIsBusy(true);
setMessage("");
try {
const response = await assembleDraft(articleId);
setDrafts((current) => [response.draft, ...current]);
setSelectedDraftId(response.draft.id);
setEditor(createEditorState(response.draft));
setMessage(`Draft v${response.draft.version} assembled.`);
} catch (error) {
setMessage(error instanceof ApiError ? error.message : "Failed to assemble draft.");
} finally {
setIsBusy(false);
}
}
async function onSaveDraft() {
if (!selectedDraft) {
setMessage("Assemble draft before editing.");
return;
}
setIsBusy(true);
setMessage("");
try {
const response = await updateDraft(articleId, selectedDraft.id, {
title: editor.title,
meta_title: editor.metaTitle,
meta_description: editor.metaDescription,
body_markdown: editor.bodyMarkdown,
});
setDrafts((current) => [response.draft, ...current]);
setSelectedDraftId(response.draft.id);
setEditor(createEditorState(response.draft));
setMessage(`Saved draft v${response.draft.version}.`);
} catch (error) {
setMessage(error instanceof ApiError ? error.message : "Failed to save draft.");
} finally {
setIsBusy(false);
}
}
return (
<section className="draftEditor">
<div className="sectionHeader">
<div>
<h2>Draft editor</h2>
<p>{drafts.length ? `${drafts.length} version(s)` : "No drafts yet"}</p>
</div>
<button type="button" onClick={onAssembleDraft} disabled={isBusy}>
{isBusy ? "Working..." : "Assemble draft"}
</button>
</div>
<div className="detailGrid">
<div>
<dt>Selected draft</dt>
<dd>
<select
value={selectedDraftId ?? ""}
onChange={(event) => onSelectDraft(event.target.value)}
disabled={!drafts.length || isBusy}
>
{drafts.length === 0 ? <option value="">No drafts</option> : null}
{drafts.map((draft) => (
<option key={draft.id} value={draft.id}>
v{draft.version} • {draft.title}
</option>
))}
</select>
</dd>
</div>
<div>
<dt>Compare with</dt>
<dd>
<select
value={compareDraftId ?? ""}
onChange={(event) => setCompareDraftId(event.target.value || null)}
disabled={!drafts.length || isBusy}
>
<option value="">No comparison</option>
{drafts
.filter((draft) => draft.id !== selectedDraftId)
.map((draft) => (
<option key={draft.id} value={draft.id}>
v{draft.version}
</option>
))}
</select>
</dd>
</div>
</div>
{comparison ? (
<p className="formSuccess">
Compare v{comparison.leftVersion} vs v{comparison.rightVersion}: changed lines{" "}
{comparison.changedLineCount}, title {comparison.titleChanged ? "changed" : "same"},
meta title {comparison.metaTitleChanged ? "changed" : "same"}, description{" "}
{comparison.metaDescriptionChanged ? "changed" : "same"}.
</p>
) : null}
{message ? <p className={message.startsWith("Failed") ? "formError" : "formSuccess"}>{message}</p> : null}
<form
className="articleForm"
onSubmit={(event) => {
event.preventDefault();
void onSaveDraft();
}}
>
<label htmlFor="draft-title">Title</label>
<input
id="draft-title"
value={editor.title}
onChange={(event) =>
setEditor((current) => ({ ...current, title: event.target.value }))
}
/>
<label htmlFor="draft-meta-title">Meta title</label>
<input
id="draft-meta-title"
value={editor.metaTitle}
onChange={(event) =>
setEditor((current) => ({ ...current, metaTitle: event.target.value }))
}
/>
<label htmlFor="draft-meta-description">Meta description</label>
<input
id="draft-meta-description"
value={editor.metaDescription}
onChange={(event) =>
setEditor((current) => ({ ...current, metaDescription: event.target.value }))
}
/>
<label htmlFor="draft-body">Body markdown</label>
<textarea
id="draft-body"
className="codeArea"
value={editor.bodyMarkdown}
onChange={(event) =>
setEditor((current) => ({ ...current, bodyMarkdown: event.target.value }))
}
/>
<div className="formActions">
<button type="submit" disabled={isBusy || !selectedDraft}>
Save as next version
</button>
</div>
</form>
<h3>Preview</h3>
<div className="previewPane">
{previewBlocks.map((block, index) => {
if (block.type === "heading") {
if (block.level <= 2) {
return <h4 key={`heading-${index}`}>{block.text}</h4>;
}
return <h5 key={`heading-${index}`}>{block.text}</h5>;
}
if (block.type === "paragraph") {
return (
<p key={`paragraph-${index}`}>
{block.text}
{block.links.map((link) => (
<span key={`${link.href}-${link.label}`}> • <a href={link.href}>{link.label}</a></span>
))}
</p>
);
}
if (block.type === "table") {
return (
<table className="table" key={`table-${index}`}>
<thead>
<tr>
{block.headers.map((header, headerIndex) => (
<th key={`${header}-${headerIndex}`}>{header}</th>
))}
</tr>
</thead>
<tbody>
{block.rows.map((row, rowIndex) => (
<tr key={`row-${rowIndex}`}>
{row.map((cell, cellIndex) => (
<td key={`cell-${rowIndex}-${cellIndex}`}>{cell}</td>
))}
</tr>
))}
</tbody>
</table>
);
}
if (block.type === "image") {
return (
<figure key={`image-${index}`} className="previewImage">
<img src={block.src} alt={block.alt} />
<figcaption>{block.alt || block.src}</figcaption>
</figure>
);
}
return (
<section key={`faq-${index}`}>
<h4>FAQ</h4>
<ul className="timeline">
{block.items.map((item) => (
<li key={item.question}>
<strong>{item.question}</strong>
<div>{item.answer}</div>
</li>
))}
</ul>
</section>
);
})}
{previewBlocks.length === 0 ? <p>No preview content.</p> : null}
</div>
</section>
);
}
function createEditorState(draft: DraftSummary | null): DraftEditorState {
if (!draft) {
return {
title: "",
metaTitle: "",
metaDescription: "",
bodyMarkdown: "",
};
}
return {
title: draft.title,
metaTitle: draft.meta_title ?? "",
metaDescription: draft.meta_description ?? "",
bodyMarkdown: draft.body_markdown ?? "",
};
}