300 lines
9.5 KiB
TypeScript
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 ?? "",
|
|
};
|
|
}
|