CODE · 73.6 KB
src/main.jsx
Workspace snapshot · 08/31 23:51
import React, { useEffect, useMemo, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import {
answerQuestion,
createComment,
exportHtml,
getActiveQuestions,
getNextQuestion,
initialMarkdown,
initialProject,
initialSections,
markdownToSections,
mergeTargetedSections,
rewindAnswers,
sectionsToMarkdown,
stages,
toneDirections,
} from "./lp-model.js";
import {
normalizeWireframeType,
resolveWireframeLayout,
} from "./wireframe-layouts.js";
import "./product-v3.css";
import "./wireframe-variants.css";
import { wireframeVariantPreviewSections } from "./wireframe-preview.js";
import { buildExportBundle } from "./export-bundle.js";
import { normalizeContentModel } from "./section-content.js";
import { buildVisualPlan, mergeVisualRecords } from "./visual-strategy.js";
const STORAGE_KEY = "landing-page-studio:v5";
const ACCESS_KEY = "landing-page-studio:access";
const SESSION_KEY = "landing-page-studio:session";
const sessionId = (() => {
const current = window.sessionStorage.getItem(SESSION_KEY);
if (current) return current;
const next = `lp-session:${crypto.randomUUID()}`;
window.sessionStorage.setItem(SESSION_KEY, next);
return next;
})();
const generationHeaders = () => ({
"Content-Type": "application/json",
"x-ai-lp-access-token": window.sessionStorage.getItem(ACCESS_KEY) || "",
"x-ai-lp-session": sessionId,
});
function readDraft() {
try {
return JSON.parse(window.localStorage.getItem(STORAGE_KEY) || "null");
} catch {
return null;
}
}
function StudioMark({ compact = false }) {
return (
<span
className={`studio-mark ${compact ? "compact" : ""}`}
aria-label="Landing Page Studio"
>
<svg viewBox="0 0 48 48" role="img">
<path d="M8 8h14v8H16v16h6v8H8V8Z" />
<path d="M26 8h7c8 0 12 4 12 11 0 8-5 12-13 12h-1v9h-5V8Zm5 8v8h2c4 0 6-1 6-4 0-3-2-4-6-4h-2Z" />
</svg>
{!compact && (
<b>
<span>LANDING PAGE</span>STUDIO
</b>
)}
</span>
);
}
function AccessGate({ onUnlock }) {
const [value, setValue] = useState("");
return (
<main className="access-gate">
<StudioMark />
<small>PRIVATE BETA</small>
<h1>制作環境へ入る</h1>
<p>生成コストと制作データを守るため、現在はアクセスキー制です。</p>
<form onSubmit={(event) => {
event.preventDefault();
if (!value.trim()) return;
window.sessionStorage.setItem(ACCESS_KEY, value.trim());
onUnlock(value.trim());
}}>
<label>アクセスキー<input type="password" value={value} onChange={(event) => setValue(event.target.value)} autoComplete="current-password" /></label>
<button type="submit" disabled={!value.trim()}>制作を始める</button>
</form>
</main>
);
}
async function runGeneration(kind, payload, onStatus) {
const targetedRevision =
kind === "wireframe" &&
typeof payload?.instruction === "string" &&
/"sectionId"\s*:/u.test(payload.instruction);
const messages =
kind === "brief"
? [
"資料の文章を読み取り",
"商材・顧客・訴求を抽出",
"ヒアリングの下書きを作成",
]
: kind === "tone"
? [
"構成から感情の軸を抽出",
"ビジュアル言語を3方向へ展開",
"書体・色・動きを統合",
]
: [
"資料と回答を理解",
"競合・代替とCV心理を整理",
"説得順とコピーを設計",
"品質ゲートで監査",
];
for (let generationAttempt = 0; generationAttempt < 3; generationAttempt += 1) {
onStatus?.(generationAttempt ? `出力を整え直しています(${generationAttempt}/2)` : "準備しています");
const start = await fetch("/api/generate-page", {
method: "POST",
headers: generationHeaders(),
body: JSON.stringify({ ...payload, kind, requestId: crypto.randomUUID() }),
});
const started = await start.json();
if (!start.ok) throw new Error(started.error || "生成を開始できませんでした");
let retryOutput = false;
// Strategy jobs are allowed to run for 600 seconds on the VPS. Do not
// enqueue another job while the first one is still legitimately running.
for (let pollAttempt = 0; pollAttempt < 420; pollAttempt += 1) {
onStatus?.(messages[Math.min(Math.floor(pollAttempt / 5), messages.length - 1)]);
await new Promise((resolve) => window.setTimeout(resolve, 1600));
const partial = targetedRevision || generationAttempt === 2 ? "&partial=1" : "";
const response = await fetch(
`/api/generate-page?jobId=${encodeURIComponent(started.jobId)}&kind=${kind}${partial}`,
{ headers: generationHeaders() },
);
const result = await response.json();
if (response.status === 422) {
retryOutput = true;
break;
}
if (!response.ok) throw new Error(result.error || "生成結果を取得できませんでした");
if (result.status === "COMPLETED") return result;
if (result.status === "FAILED") {
retryOutput = true;
break;
}
}
if (retryOutput) continue;
throw new Error("生成が10分を超えました。実行状態を確認してください");
}
throw new Error("生成結果を整形できませんでした。入力内容は保持されています");
}
async function runWireframeAudit(sections, answers, qualityGate = {}) {
const response = await fetch("/api/audit-wireframe", {
method: "POST",
headers: generationHeaders(),
body: JSON.stringify({ sections, answers, qualityGate }),
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || "品質監査を実行できませんでした");
return result;
}
async function runImageGeneration(
{ project, sections, direction, purpose = "tone", sectionIds = null },
onStatus,
) {
const requested = Array.isArray(sectionIds) ? new Set(sectionIds) : null;
const plan = buildVisualPlan({ project, sections, direction, purpose })
.filter((visual) => !requested || requested.has(visual.sectionId));
if (!plan.length && requested) return [];
if (!plan.length) throw new Error("画像を使うセクションを特定できませんでした");
const queued = { jobs: [] };
for (let index = 0; index < plan.length; index += 7) {
const batch = plan.slice(index, index + 7);
onStatus?.(`画像生成を準備 ${Math.min(index + batch.length, plan.length)} / ${plan.length}`);
const response = await fetch("/api/design-images", {
method: "POST",
headers: generationHeaders(),
body: JSON.stringify({
visuals: batch.map((visual) => ({
...visual,
colorScheme: "カラフル",
useCase: visual.type === "hero" ? "Webサイト メインビジュアル" : "Webサイト セクション画像",
useCaseId: visual.type === "hero" ? "web-hero" : "web-banner",
directions: [visual.direction],
})),
}),
});
const result = await response.json();
if (!response.ok)
throw new Error(result.error || "画像生成を開始できませんでした");
queued.jobs.push(...(result.jobs || []));
}
const ids = queued.jobs
.filter((item) => item.jobId)
.map((item) => item.jobId);
if (!ids.length) throw new Error("画像生成ジョブを作成できませんでした");
const visualById = new Map(plan.map((visual) => [visual.visualId, visual]));
const visualByJob = new Map(queued.jobs.map((job) => [job.jobId, {
sectionId: job.sectionId,
visualId: job.visualId || `${job.sectionId}:${job.layer || "content"}`,
layer: job.layer || "content",
}]));
// サブスク画像Workerは同一セッションを直列処理するため、7枚で10分前後を許容する。
for (let attempt = 0; attempt < 420; attempt += 1) {
onStatus?.(attempt === 0 ? "画像生成エンジンへ接続" : `${plan.length}枚を生成しています`);
await new Promise((resolve) => window.setTimeout(resolve, 2200));
const polled = await fetch(
`/api/design-images?jobIds=${encodeURIComponent(ids.join(","))}`,
{ headers: generationHeaders() },
);
const data = await polled.json();
const jobs = data.jobs || [];
const completedCount = jobs.filter((job) => job.status === "COMPLETED").length;
onStatus?.(`画像 ${completedCount} / ${ids.length}枚 完了`);
if (jobs.some((job) => job.status === "FAILED"))
throw new Error(
jobs.find((job) => job.status === "FAILED")?.error_summary ||
"画像生成に失敗しました",
);
if (
jobs.length === ids.length &&
jobs.every((job) => job.status === "COMPLETED")
) {
const images = jobs.map((job) => {
const meta = visualByJob.get(job.id || job.jobId) || {
sectionId: job.sectionId,
visualId: job.visualId || `${job.sectionId}:${job.layer || "content"}`,
layer: job.layer || "content",
};
const visual = visualById.get(meta.visualId);
return {
sectionId: meta.sectionId,
visualId: meta.visualId,
layer: meta.layer,
textIntegrated: visual?.textIntegrated === true,
src:
job.output?.artifacts?.[0]?.signedUrl ||
job.artifact?.signedUrl ||
job.output?.artifact?.signedUrl,
};
}).filter((image) => image.src && image.sectionId);
if (images.length !== ids.length)
throw new Error("生成画像の取得に失敗しました");
return images;
}
}
throw new Error("画像生成がタイムアウトしました");
}
function Header({ stage, setStage, saveStatus, versions, onRestore }) {
const active = stages.findIndex((item) => item.id === stage);
return (
<header className="studio-header">
<button className="brand" onClick={() => setStage("hearing")}>
<StudioMark />
</button>
<nav>
{stages.map((item, index) => (
<button
key={item.id}
className={
index === active ? "active" : index < active ? "done" : ""
}
onClick={() => index <= active && setStage(item.id)}
>
<span>{index < active ? "✓" : item.no}</span>
<b>{item.label}</b>
</button>
))}
</nav>
<details className="version-menu">
<summary className={`save ${saveStatus}`}>
<i />
{saveStatus === "saving"
? "SAVING"
: saveStatus === "error"
? "SAVE ERROR"
: "SAVED"}
</summary>
<div>
<small>VERSION HISTORY</small>
{versions.length ? (
[...versions].reverse().map((version) => (
<button key={version.id} onClick={() => onRestore(version)}>
<b>{version.label}</b>
<span>
{new Date(version.createdAt).toLocaleTimeString("ja-JP", {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</button>
))
) : (
<p>生成後の版がここに残ります。</p>
)}
</div>
</details>
</header>
);
}
function SourceDrop({ project, setProject, onLoaded }) {
const input = useRef(null);
const [reading, setReading] = useState(false);
const [drag, setDrag] = useState(false);
const [readError, setReadError] = useState("");
const load = async (file) => {
if (!file) return;
setReading(true);
setReadError("");
try {
const { readDocument } = await import("./document-reader.js");
const result = await readDocument(file);
const next = {
...project,
sourceName: file.name,
sourceText: result.text,
sourceReadStatus: result.status,
sourceTruncated: result.truncated,
};
setProject(next);
await onLoaded?.(next);
} catch (error) {
setReadError(error instanceof Error ? error.message : "資料を読み込めませんでした");
} finally {
setReading(false);
setDrag(false);
}
};
return (
<label
className={`source-pill ${drag ? "drag" : ""}`}
tabIndex="0"
role="button"
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") input.current?.click();
}}
onDragOver={(event) => {
event.preventDefault();
setDrag(true);
}}
onDragLeave={() => setDrag(false)}
onDrop={(event) => {
event.preventDefault();
load(event.dataTransfer.files[0]);
}}
>
<input
ref={input}
type="file"
accept=".pdf,.pptx,.docx,.txt,.md,.csv"
onChange={(event) => load(event.target.files[0])}
/>
<span>{reading ? "···" : "+"}</span>
<div>
<b>{reading ? "資料を分析中" : project.sourceName}</b>
<small>
{readError
? readError
: project.sourceReadStatus
? project.sourceReadStatus
: project.sourceFilled?.length
? `${project.sourceFilled.length}項目を資料から下書き済み`
: "資料からヒアリングを自動作成"}
</small>
</div>
</label>
);
}
function ChoiceCard({ option, index, selected, onClick }) {
return (
<button
className={`taste-card option-card ${selected ? "selected" : ""}`}
onClick={onClick}
>
<span className="option-symbol">
<i>{String(index + 1).padStart(2, "0")}</i>
</span>
<span className="taste-copy">
<b>{option.title}</b>
<small>{option.note}</small>
</span>
<span className="pick-check">✓</span>
</button>
);
}
function Generating({ title, status }) {
return (
<div className="generating">
<div className="generation-brand">
<StudioMark compact />
<i />
<i />
</div>
<small>LANDING PAGE STUDIO</small>
<h2>{title}</h2>
<p>
{status}
<span>...</span>
</p>
<div className="generation-line">
<i />
</div>
</div>
);
}
function Hearing({
project,
setProject,
onComplete,
onSourceLoaded,
generating,
generationKind,
generationStatus,
}) {
const answers = project.answers;
const question = getNextQuestion(answers);
const active = getActiveQuestions(answers);
const [picked, setPicked] = useState(null);
const [multiPicked, setMultiPicked] = useState([]);
const [custom, setCustom] = useState("");
const [fieldValues, setFieldValues] = useState({});
const [confirmationValues, setConfirmationValues] = useState(() =>
Object.fromEntries((project.sourceNeedsConfirmation || []).map((item) => [item, ""])),
);
useEffect(() => {
setPicked(null);
const existing = question ? answers[question.id] : null;
const selected = Array.isArray(existing) ? existing : [];
setMultiPicked(selected.filter((item) => !String(item).startsWith("自由記述: ")));
setCustom(
question?.multiple
? String(selected.find((item) => String(item).startsWith("自由記述: ")) || "").replace(/^自由記述:\s*/u, "")
: question?.type === "text" && typeof existing === "string"
? existing
: "",
);
setFieldValues(
question?.type === "fields"
? Object.fromEntries(question.fields.map((field) => [field.id, answers[field.id] || ""]))
: {},
);
}, [question?.id]);
const commit = (value) => {
setProject((current) => ({
...current,
answers: question.type === "fields" && value
? { ...answerQuestion(current.answers, question, true), ...value }
: question.multiple && Array.isArray(value)
? answerQuestion(current.answers, { ...question, multiple: false }, value)
: answerQuestion(current.answers, question, value),
}));
setPicked(null);
setCustom("");
};
const choose = (value) => {
if (question.multiple) {
setMultiPicked((items) =>
items.includes(value)
? items.filter((item) => item !== value)
: [...items, value],
);
return;
}
if (picked) return;
setPicked(value);
commit(value);
};
const continueMultiple = () => {
const values = [
...multiPicked,
...(custom.trim() ? [`自由記述: ${custom.trim()}`] : []),
];
if (values.length) {
commit(values);
setMultiPicked([]);
}
};
const currentSelection = multiPicked;
const currentIndex = question
? active.findIndex((item) => item.id === question.id)
: active.length;
const canBack = active
.slice(0, currentIndex)
.some((item) => answers[item.id] != null);
if (generating)
return (
<Generating
title={
generationKind === "brief"
? "資料から下書きを作成しています"
: "構成を作成しています"
}
status={generationStatus}
/>
);
return (
<main className="onboarding">
<section className="taste-stage">
<div className="taste-head">
<div>
<small>LP BRIEF</small>
<b>
{project.sourceSummary ||
"資料と最小限の回答から構成をつくります。"}
</b>
</div>
<SourceDrop
project={project}
setProject={setProject}
onLoaded={onSourceLoaded}
/>
</div>
{project.sourceNeedsConfirmation?.length > 0 && (
<aside className="source-confirmation">
<div><small>資料からの要確認</small><b>不明点を修正してから進めます</b></div>
<div className="confirmation-fields">
{project.sourceNeedsConfirmation.map((item) => (
<label key={item}>
<span>{item}</span>
<input
value={confirmationValues[item] || ""}
onChange={(event) => setConfirmationValues((current) => ({ ...current, [item]: event.target.value }))}
placeholder="正しい内容、または『未定』"
/>
</label>
))}
</div>
<button
disabled={project.sourceNeedsConfirmation.some((item) => !String(confirmationValues[item] || "").trim())}
onClick={() => setProject((current) => ({
...current,
sourceConfirmationNotes: confirmationValues,
sourceNeedsConfirmation: [],
}))}
>修正内容を反映</button>
</aside>
)}
{question ? (
<div className="question-scene" key={question.id}>
<div className="question-tools">
<button
onClick={() =>
setProject((current) => ({
...current,
answers: rewindAnswers(current.answers),
}))
}
disabled={!canBack}
>
← 戻る
</button>
<span>
{currentIndex + 1} / {active.length}
</span>
</div>
<div className="question-count">
<span
style={{ width: `${((currentIndex + 1) / active.length) * 100}%` }}
/>
</div>
<small>{question.eyebrow}</small>
<h1>{question.title}</h1>
<p>
{question.type === "text"
? question.optional
? "資料にない場合だけ補足してください。スキップもできます。"
: "一文で十分です。資料から分かる項目は自動で省略されます。"
: question.multiple
? "複数選べます。回答に応じて候補を変えています。"
: "最も近いものを選んでください。"}
</p>
{question.type === "fields" ? (
<div className="field-answer-grid">
{question.fields.map((field) => (
<label key={field.id}>
<span>{field.label}</span>
<textarea value={fieldValues[field.id] || ""} onChange={(event) => setFieldValues((current) => ({ ...current, [field.id]: event.target.value }))} placeholder={field.placeholder} />
</label>
))}
<div className="field-actions">
{question.optional && <button className="secondary" onClick={() => commit(null)}>今回はスキップ</button>}
<button onClick={() => commit(Object.fromEntries(Object.entries(fieldValues).filter(([, value]) => value.trim())))} disabled={!question.optional && !Object.values(fieldValues).some((value) => value.trim())}>次へ →</button>
</div>
</div>
) : question.type === "text" ? (
<div className="free-answer primary-answer">
<textarea
autoFocus
value={custom}
onChange={(event) => setCustom(event.target.value)}
placeholder={question.placeholder}
/>
<button
onClick={() =>
commit(custom.trim() || (question.optional ? null : ""))
}
disabled={!custom.trim() && !question.optional}
>
{question.optional && !custom.trim() ? "スキップ" : "次へ"}
<i>→</i>
</button>
</div>
) : (
<>
<div
className={`taste-grid options-${question.options.length}`}
>
{question.options.map((option, index) => (
<ChoiceCard
key={option.id}
option={option}
index={index}
selected={
picked === option.id ||
currentSelection.includes(option.id) ||
answers[question.id] === option.id
}
onClick={() => choose(option.id)}
/>
))}
</div>
<details className="custom-answer">
<summary>選択肢にない場合だけ入力</summary>
<div className="free-answer">
<textarea
value={custom}
onChange={(event) => setCustom(event.target.value)}
placeholder="その他の内容"
/>
<button
onClick={() =>
question.multiple
? continueMultiple()
: commit(`自由記述: ${custom.trim()}`)
}
disabled={!custom.trim() && !question.multiple}
>
{question.multiple ? "選択内容で次へ" : "この内容で次へ"}
<i>→</i>
</button>
</div>
</details>
{question.multiple && (
<button
className="multiple-next"
disabled={!multiPicked.length}
onClick={continueMultiple}
>
選択した内容で次へ →
</button>
)}
</>
)}
</div>
) : (
<div className="ready-scene">
<small>BRIEF COMPLETE</small>
<h1>
入力内容をもとに、
<br />
<em>LP構成を作成します。</em>
</h1>
<p>
資料の事実と未確認情報を分け、キャッチコピー3案と必要なセクションを生成します。
</p>
<button onClick={onComplete}>
<span>構成を生成</span>
<i>→</i>
</button>
</div>
)}
</section>
</main>
);
}
function StructuredContentEditor({ section, onChange }) {
const model = normalizeContentModel(section.contentModel);
const type = normalizeWireframeType(section);
const setModel = (next) => onChange({ ...model, ...next });
const updateArray = (key, index, field, value) =>
setModel({
[key]: model[key].map((entry, entryIndex) =>
entryIndex === index ? { ...entry, [field]: value } : entry,
),
});
const itemKey = ["mechanism", "process"].includes(type) ? "steps" : "items";
if (type === "faq")
return <div className="structured-fields"><b>質問と回答</b>{model.faqs.map((entry, index) => <div className="structured-row" key={`${section.id}-faq-${index}`}><input value={entry.question} aria-label={`質問 ${index + 1}`} onChange={(event) => updateArray("faqs", index, "question", event.target.value)} /><textarea value={entry.answer} aria-label={`回答 ${index + 1}`} onChange={(event) => updateArray("faqs", index, "answer", event.target.value)} /></div>)}</div>;
if (type === "proof" && model.metrics.length)
return <div className="structured-fields"><b>実績・根拠</b>{model.metrics.map((entry, index) => <div className="structured-row metrics" key={`${section.id}-metric-${index}`}><input value={entry.value} aria-label={`実績値 ${index + 1}`} onChange={(event) => updateArray("metrics", index, "value", event.target.value)} /><input value={entry.label} aria-label={`実績ラベル ${index + 1}`} onChange={(event) => updateArray("metrics", index, "label", event.target.value)} /><input value={entry.source} aria-label={`実績出典 ${index + 1}`} onChange={(event) => updateArray("metrics", index, "source", event.target.value)} /></div>)}</div>;
if (type === "pricing")
return <div className="structured-fields"><b>プラン</b>{model.plans.map((entry, index) => <div className="structured-row metrics" key={`${section.id}-plan-${index}`}><input value={entry.name} aria-label={`プラン名 ${index + 1}`} onChange={(event) => updateArray("plans", index, "name", event.target.value)} /><input value={entry.price} aria-label={`価格 ${index + 1}`} onChange={(event) => updateArray("plans", index, "price", event.target.value)} /><textarea value={entry.description} aria-label={`プラン説明 ${index + 1}`} onChange={(event) => updateArray("plans", index, "description", event.target.value)} /></div>)}</div>;
if (type === "comparison" && model.comparison)
return <div className="structured-fields"><b>比較表</b>{model.comparison.rows.map((row, index) => <div className="structured-row metrics" key={`${section.id}-comparison-${index}`}><input value={row.label} aria-label={`比較軸 ${index + 1}`} onChange={(event) => { const rows = model.comparison.rows.map((entry, entryIndex) => entryIndex === index ? { ...entry, label: event.target.value } : entry); setModel({ comparison: { ...model.comparison, rows } }); }} />{row.cells.map((cell, cellIndex) => <textarea key={cellIndex} value={cell} aria-label={`比較内容 ${index + 1}-${cellIndex + 1}`} onChange={(event) => { const rows = model.comparison.rows.map((entry, entryIndex) => entryIndex === index ? { ...entry, cells: entry.cells.map((value, valueIndex) => valueIndex === cellIndex ? event.target.value : value) } : entry); setModel({ comparison: { ...model.comparison, rows } }); }} />)}</div>)}</div>;
if (model[itemKey].length)
return <div className="structured-fields"><b>{itemKey === "steps" ? "手順" : "表示項目"}</b>{model[itemKey].map((entry, index) => <div className="structured-row" key={`${section.id}-${itemKey}-${index}`}><input value={entry.title} aria-label={`項目見出し ${index + 1}`} onChange={(event) => updateArray(itemKey, index, "title", event.target.value)} /><textarea value={entry.description} aria-label={`項目説明 ${index + 1}`} onChange={(event) => updateArray(itemKey, index, "description", event.target.value)} /></div>)}</div>;
return null;
}
function BlockStructureEditor({ sections, onChange }) {
const update = (id, key, value) =>
onChange(
sections.map((section) =>
section.id === id ? { ...section, [key]: value } : section,
),
);
return (
<div className="block-editor">
{sections.map((section, index) => (
<article key={section.id}>
<header>
<span>{String(index + 1).padStart(2, "0")}</span>
<input
aria-label={`${index + 1} セクション名`}
value={section.label}
onChange={(event) =>
update(section.id, "label", event.target.value)
}
/>
</header>
<input
className="block-title"
aria-label={`${index + 1} 見出し`}
value={section.title}
onChange={(event) =>
update(section.id, "title", event.target.value)
}
/>
<textarea
aria-label={`${index + 1} 本文`}
value={section.body}
onChange={(event) => update(section.id, "body", event.target.value)}
/>
<div className="block-fields">
<label>
役割
<input
value={section.role || ""}
onChange={(event) =>
update(section.id, "role", event.target.value)
}
/>
</label>
<label>
根拠
<input
value={section.proof || ""}
onChange={(event) =>
update(section.id, "proof", event.target.value)
}
/>
</label>
<label>
CTA
<input
value={section.cta || ""}
onChange={(event) =>
update(section.id, "cta", event.target.value)
}
/>
</label>
</div>
<StructuredContentEditor
section={section}
onChange={(contentModel) => update(section.id, "contentModel", contentModel)}
/>
</article>
))}
</div>
);
}
function Structure({
markdown,
setMarkdown,
sections,
onChangeSections,
headlines,
qualityGate,
selectedHeadline,
onSelectHeadline,
onRegenerate,
onNext,
busyKind,
status,
}) {
const [instruction, setInstruction] = useState("");
const [editorMode, setEditorMode] = useState("blocks");
if (busyKind)
return (
<Generating
title={
busyKind === "wireframe"
? "構成をWFへ変換しています"
: "構成を組み直しています"
}
status={status}
/>
);
return (
<main className="workspace structure-work">
<div className="work-title compact-title">
<div>
<small>02 / 構成</small>
<h1>構成とキャッチコピー</h1>
<p>読者の検討順に並べ、文章を直接編集できます。</p>
</div>
<div className="step-actions">
<button
onClick={() => onRegenerate(instruction)}
disabled={!instruction.trim()}
>
指示を反映
</button>
<button className="primary" onClick={onNext}>
WFを生成 <i>→</i>
</button>
</div>
</div>
<section className="evidence-brief">
<div>
<small>SOURCE MAP</small>
<b>AIが判断に使った前提</b>
</div>
{[
["確認済み", qualityGate.known],
["仮説", qualityGate.hypotheses],
["未確認", qualityGate.unconfirmed],
].map(([label, items]) => (
<section key={label}>
<strong>
{label}
<i>{items.length}</i>
</strong>
{items.length ? (
items.slice(0, 4).map((item) => <p key={item}>{item}</p>)
) : (
<p>該当なし</p>
)}
</section>
))}
</section>
<section className="headline-lab">
<div>
<small>CATCH COPY</small>
<b>ファーストビューを選択</b>
</div>
{headlines.map((item, index) => (
<button
key={item.id}
className={selectedHeadline === item.id ? "selected" : ""}
onClick={() => onSelectHeadline(item)}
>
<i>
案 {index + 1} / {item.axis || "訴求"}
</i>
<strong>{item.copy}</strong>
<span>{item.reason}</span>
</button>
))}
</section>
<div className="structure-grid">
<section className="markdown-editor notion-editor">
<div className="editor-top">
<div className="editor-tabs">
<button
className={editorMode === "blocks" ? "active" : ""}
onClick={() => setEditorMode("blocks")}
>
読みやすく編集
</button>
<button
className={editorMode === "markdown" ? "active" : ""}
onClick={() => setEditorMode("markdown")}
>
Markdown
</button>
</div>
<b>{sections.length} sections</b>
</div>
{editorMode === "blocks" ? (
<BlockStructureEditor
sections={sections}
onChange={onChangeSections}
/>
) : (
<textarea
value={markdown}
onChange={(event) => setMarkdown(event.target.value)}
/>
)}
<div className="prompt-dock">
<textarea
value={instruction}
onChange={(event) => setInstruction(event.target.value)}
placeholder="例:導入リスクと費用対効果を前半へ移動"
/>
<button
onClick={() => onRegenerate(instruction)}
disabled={!instruction.trim()}
>
↗
</button>
</div>
</section>
<aside className="story-map">
<small>SECTION MAP</small>
{sections.map((section, index) => (
<article key={section.id}>
<i>{String(index + 1).padStart(2, "0")}</i>
<div>
<b>{section.label}</b>
<p>{section.userQuestion || section.title}</p>
</div>
</article>
))}
</aside>
</div>
</main>
);
}
const wireframeItems = (section, minimum = 3) => {
const model = normalizeContentModel(section.contentModel);
const typed = [...model.items, ...model.steps]
.map((item) => item.title)
.filter(Boolean);
if (typed.length) return typed.slice(0, Math.max(minimum, 6));
return String(section.content || "")
.split(/\r?\n/u)
.map((item) => item.replace(/^\s*[-・*]\s*/u, "").trim())
.filter((item) => item.length > 2)
.slice(0, Math.max(minimum, 6));
};
function WireframeVisual({ label, variant = "screen" }) {
return (
<div className={`wf-visual wf-visual-${variant}`} data-comment-element="visual">
<span>{label || "必要素材を確認"}</span>
<i />
<i />
<i />
</div>
);
}
function WireframeSectionBody({ section, index }) {
const type = normalizeWireframeType(section);
const variant = resolveWireframeLayout(section, index);
const model = normalizeContentModel(section.contentModel);
const items = wireframeItems(section);
const lead = (
<div className="wf-copy">
<h2 data-comment-element="headline">{section.title}</h2>
<p data-comment-element="body">{section.body}</p>
{section.proof && <em data-comment-element="proof">{section.proof}</em>}
{section.cta && !["cta", "form"].includes(type) && (
<button data-comment-element="cta">{section.cta}</button>
)}
</div>
);
if (type === "hero")
return (
<div className={`wf-layout wf-layout-hero wf-variant-${variant}`}>
{lead}
<WireframeVisual label={section.asset} variant="hero" />
<div className="wf-hero-proof">
{items.slice(0, 3).map((item, itemIndex) => (
<span key={item}><b>{String(itemIndex + 1).padStart(2, "0")}</b>{item}</span>
))}
</div>
</div>
);
if (type === "trust")
return (
<div className={`wf-layout wf-layout-trust wf-variant-${variant}`}>
{lead}
<div className="wf-logo-wall" data-comment-element="visual">
{model.metrics.map((metric) => <span key={`${metric.value}-${metric.label}`}><b>{metric.value}</b>{metric.label}</span>)}
{model.items.map((item) => <span key={item.title}><b>{item.title}</b>{item.description}</span>)}
</div>
</div>
);
if (type === "problem" || type === "risk")
return (
<div className={`wf-layout wf-layout-${type} wf-variant-${variant}`}>
{lead}
<div className="wf-pain-grid" data-comment-element="visual">
{items.slice(0, 3).map((item, itemIndex) => (
<article key={item}><i>0{itemIndex + 1}</i><b>{item}</b><span>{type === "risk" ? "放置時の影響" : "現場で起きること"}</span></article>
))}
</div>
</div>
);
if (type === "mechanism" || type === "process")
return (
<div className={`wf-layout wf-layout-${type} wf-variant-${variant}`}>
{lead}
<ol className="wf-flow" data-comment-element="visual">
{items.slice(0, 4).map((item, itemIndex) => (
<li key={item}><span>{itemIndex + 1}</span><b>{item}</b><small>{type === "process" ? "導入ステップ" : "変化の仕組み"}</small></li>
))}
</ol>
</div>
);
if (["differentiation", "usecase"].includes(type))
return (
<div className={`wf-layout wf-layout-${type} wf-variant-${variant}`}>
<header>{lead}</header>
<div className="wf-feature-grid" data-comment-element="visual">
{items.slice(0, type === "usecase" ? 4 : 3).map((item, itemIndex) => (
<article key={item}><span>{type === "usecase" ? `SCENE ${itemIndex + 1}` : `POINT ${itemIndex + 1}`}</span><b>{item}</b><i /></article>
))}
</div>
</div>
);
if (type === "comparison")
return (
<div className={`wf-layout wf-layout-comparison wf-variant-${variant}`}>
{lead}
<div className="wf-compare" data-comment-element="visual">
<div><b>比較軸</b>{(model.comparison?.columns || []).map((column) => <b key={column}>{column}</b>)}</div>
{(model.comparison?.rows || []).map((row) => <div key={row.label}><span>{row.label}</span>{row.cells.map((cell, cellIndex) => <small key={`${row.label}-${cellIndex}`}>{cell}</small>)}</div>)}
</div>
</div>
);
if (type === "proof")
return (
<div className={`wf-layout wf-layout-proof wf-variant-${variant}`}>
{lead}
<div className="wf-metrics" data-comment-element="visual">
{model.metrics.map((metric) => <article key={`${metric.value}-${metric.label}`}><strong>{metric.value}</strong><span>{metric.label}</span>{metric.source && <small>{metric.source}</small>}</article>)}
</div>
<WireframeVisual label={section.asset} variant="case" />
</div>
);
if (type === "pricing")
return (
<div className={`wf-layout wf-layout-pricing wf-variant-${variant}`}>
{lead}
<div className="wf-price-grid" data-comment-element="visual">
{model.plans.map((plan, itemIndex) => <article key={plan.name} className={itemIndex === 1 ? "featured" : ""}><small>{plan.name}</small><strong>{plan.price}{plan.period}</strong><span>{plan.description}</span><button>{plan.cta || section.cta}</button></article>)}
</div>
</div>
);
if (type === "faq")
return (
<div className={`wf-layout wf-layout-faq wf-variant-${variant}`}>
{lead}
<div className="wf-faq-list" data-comment-element="visual">
{model.faqs.slice(0, 6).map((item, itemIndex) => <article key={item.question}><span>Q{itemIndex + 1}</span><b>{item.question}</b><small>{item.answer}</small><i>+</i></article>)}
</div>
</div>
);
if (type === "fit")
return (
<div className={`wf-layout wf-layout-fit wf-variant-${variant}`}>
{lead}
<div className="wf-fit-grid" data-comment-element="visual">
<article><small>RECOMMENDED</small>{items.slice(0, 2).map((item) => <b key={item}>✓ {item}</b>)}</article>
<article><small>NOT A FIT</small>{items.slice(2, 4).map((item) => <b key={item}>— {item}</b>)}</article>
</div>
</div>
);
if (type === "form" || type === "cta")
return (
<div className={`wf-layout wf-layout-${type} wf-variant-${variant}`}>
{lead}
{type === "form" ? (
<div className="wf-form" data-comment-element="visual"><label>会社名<input readOnly /></label><label>メールアドレス<input readOnly /></label><label>相談内容<textarea readOnly /></label><button>{section.cta || "送信する"}</button><small>入力時間・送信後・個人情報の扱いを明記</small></div>
) : <div className="wf-cta-actions" data-comment-element="visual"><button>{section.cta || "次へ進む"}</button><span>{section.proof || "行動前の不安を解消"}</span></div>}
</div>
);
if (type === "message")
return <div className={`wf-layout wf-layout-message wf-variant-${variant}`}><blockquote data-comment-element="headline">{section.title}</blockquote><div>{section.body}<strong>{section.proof}</strong></div></div>;
return (
<div className={`wf-layout wf-layout-split wf-variant-${variant} ${index % 2 ? "reverse" : ""}`}>
{lead}<WireframeVisual label={section.asset} />
</div>
);
}
function WireframeCanvas({ sections, comments, setComments, viewport }) {
const [draft, setDraft] = useState(null);
const [message, setMessage] = useState("");
const pin = (event, section) => {
const rect = event.currentTarget.getBoundingClientRect();
const target = event.target.closest("[data-comment-element]");
setDraft({
sectionId: section.id,
x: ((event.clientX - rect.left) / rect.width) * 100,
y: ((event.clientY - rect.top) / rect.height) * 100,
element: target?.dataset.commentElement || "section",
});
};
const save = () => {
if (!draft || !message.trim()) return;
setComments((items) => [
...items,
createComment(
draft.sectionId,
draft.x,
draft.y,
message.trim(),
draft.element,
),
]);
setDraft(null);
setMessage("");
};
return (
<div className={`wf-page ${viewport}`}>
{sections.map((section, index) => (
<section
key={section.id}
className={`wf-section wf-${section.id}`}
onClick={(event) => pin(event, section)}
>
<div className="wf-label">
<span>{String(index + 1).padStart(2, "0")}</span>
{section.label}
<small>{section.role}</small>
</div>
<div className="wf-question" data-comment-element="reader-question">
<i>READER QUESTION</i>
{section.userQuestion || "このセクションで何を判断するか"}
</div>
<WireframeSectionBody section={section} index={index} />
<dl className="wf-spec">
<div>
<dt>CONTENT</dt>
<dd>{section.content || "要設計"}</dd>
</div>
<div>
<dt>LAYOUT</dt>
<dd>{section.layout || "内容に応じて設計"}</dd>
</div>
<div>
<dt>BUILD</dt>
<dd>{section.implementation || "PC/SP差分と計測を設計"}</dd>
</div>
</dl>
{comments
.filter((comment) => comment.sectionId === section.id)
.map((comment) => (
<b
className={`wf-pin ${comment.status}`}
key={comment.id}
style={{ left: `${comment.x}%`, top: `${comment.y}%` }}
>
{comments.findIndex((item) => item.id === comment.id) + 1}
</b>
))}
{draft?.sectionId === section.id && (
<div
className="pin-form"
style={{ left: `${Math.min(draft.x, 68)}%`, top: `${draft.y}%` }}
onClick={(event) => event.stopPropagation()}
>
<small>{draft.element}</small>
<textarea
autoFocus
value={message}
onChange={(event) => setMessage(event.target.value)}
placeholder="この箇所をどう変える?"
/>
<button onClick={save}>コメント追加</button>
</div>
)}
</section>
))}
</div>
);
}
function Wireframe({
sections,
audit,
comments,
setComments,
onRegenerate,
onNext,
busyKind,
status,
}) {
const [viewport, setViewport] = useState("desktop");
const gate = audit || { checks: [], humanChecks: [], mustViolations: [], passed: 0, total: 0 };
if (busyKind)
return (
<Generating
title={
busyKind === "tone"
? "トンマナ3案を設計しています"
: "WFを再設計しています"
}
status={status}
/>
);
return (
<main className="review-space">
<div className="canvas-bar">
<div>
<small>03 / WF</small>
<b>ワイヤーフレーム確認</b>
</div>
<div className="viewport-switch">
<button
className={viewport === "desktop" ? "active" : ""}
onClick={() => setViewport("desktop")}
>
PC
</button>
<button
className={viewport === "mobile" ? "active" : ""}
onClick={() => setViewport("mobile")}
>
SP
</button>
</div>
<button
className="primary"
disabled={gate.mustViolations.length > 0}
onClick={onNext}
>
トンマナ3案を生成 <i>→</i>
</button>
</div>
<div className="review-body">
<div className="canvas-bed">
<WireframeCanvas
sections={sections}
comments={comments}
setComments={setComments}
viewport={viewport}
/>
</div>
<aside className="feedback">
<section
className={`qa-panel ${gate.mustViolations.length ? "has-errors" : "passed"}`}
>
<div>
<small>DELIVERY QUALITY</small>
<b>
{gate.passed} / {gate.total}
</b>
</div>
{gate.checks.map((check) => (
<p key={check.id} className={check.pass ? "pass" : "fail"}>
<i>{check.pass ? "✓" : "!"}</i>
{check.label}
</p>
))}
{gate.humanChecks?.length > 0 && (
<details>
<summary>公開前の人間確認 {gate.humanChecks.length}項目</summary>
{gate.humanChecks.map((check) => (
<p key={check.id}><i>?</i>{check.label}</p>
))}
</details>
)}
</section>
<small>POSITIONAL COMMENTS</small>
<p>
見出し、本文、根拠、画像、CTAを直接クリックしてください。セクション・要素・座標を修正指示へ渡します。
</p>
{comments.map((comment, index) => (
<article key={comment.id} className={comment.status}>
<i>{index + 1}</i>
<div>
<b>
{
sections.find((section) => section.id === comment.sectionId)
?.label
}{" "}
/ {comment.element}
</b>
<p>{comment.message}</p>
<small>
{comment.status === "applied"
? "反映済み"
: `x ${Math.round(comment.x)} / y ${Math.round(comment.y)}`}
</small>
</div>
</article>
))}
<button
disabled={!comments.some((item) => item.status === "open")}
onClick={() =>
onRegenerate(comments.filter((item) => item.status === "open"))
}
>
コメントを反映して再生成
</button>
</aside>
</div>
</main>
);
}
function DirectionPreview({ direction, sections, selected, onSelect, index }) {
const hero = sections[0];
return (
<article
className={`direction-card direction-${index} ${selected ? "selected" : ""}`}
style={{
"--tone-ink": direction.palette[0],
"--tone-accent": direction.palette[1],
"--tone-paper": direction.palette[2],
}}
onClick={onSelect}
>
<div className="direction-browser">
{direction.previewImage ? (
<img
className="direction-generated"
src={direction.previewImage}
alt={`${direction.name}の生成メインビジュアル`}
/>
) : (
<>
<div className="direction-nav">
<b>
{index === 0
? "DOCUMENTARY"
: index === 1
? "TYPE IMPACT"
: "PRODUCT PROOF"}
</b>
<span>0{index + 1}</span>
</div>
<div className="direction-hero">
<small>{direction.name}</small>
<h2>{direction.thesis}</h2>
<p>{hero.body}</p>
<button>{hero.cta} ↗</button>
<i className="direction-image">
<b>{direction.code}</b>
<span />
</i>
</div>
</>
)}
</div>
<div className="direction-meta">
<span>0{index + 1}</span>
<div>
<small>{direction.concept || direction.name}</small>
<h3>{direction.name}</h3>
<p>{direction.visual}</p>
<dl>
<div>
<dt>LAYOUT</dt>
<dd>{direction.layout}</dd>
</div>
<div>
<dt>TYPE</dt>
<dd>{direction.type}</dd>
</div>
<div>
<dt>MOTION</dt>
<dd>{direction.motion || "スクロールに合わせた段階表示"}</dd>
</div>
</dl>
</div>
<button>{selected ? "選択中 ✓" : "この方向を選ぶ"}</button>
</div>
</article>
);
}
function Tone({
directions,
sections,
selected,
setSelected,
onRegenerate,
onNext,
busyKind,
status,
}) {
const [instruction, setInstruction] = useState("");
if (busyKind)
return (
<Generating
title={
busyKind === "design"
? "選んだ世界観をLPへ展開しています"
: "3つの世界観を画像でつくっています"
}
status={status}
/>
);
return (
<main className="workspace tone-work">
<div className="work-title">
<div>
<small>04 / デザイン</small>
<h1>デザイン方向を選択</h1>
</div>
<button className="primary" disabled={!selected} onClick={onNext}>
選択して実装 <i>→</i>
</button>
</div>
<div className="directions">
{directions.map((direction, index) => (
<DirectionPreview
key={direction.id}
direction={direction}
sections={sections}
index={index}
selected={selected === direction.id}
onSelect={() => setSelected(direction.id)}
/>
))}
</div>
<div className="tone-prompt">
<input
value={instruction}
onChange={(event) => setInstruction(event.target.value)}
placeholder="例:Aの余白感、Bの色、Cの人物表現でもう3案"
/>
<button
onClick={() => onRegenerate(instruction)}
disabled={!instruction.trim()}
>
3案を作り直す
</button>
</div>
</main>
);
}
function Implementation({
project,
sections,
tone,
images,
audit,
comments,
setComments,
onRegenerate,
busyKind,
status,
}) {
const [viewport, setViewport] = useState("desktop");
const [mode, setMode] = useState("preview");
const [draft, setDraft] = useState(null);
const [message, setMessage] = useState("");
const [downloadError, setDownloadError] = useState("");
const frameRef = useRef(null);
const html = useMemo(
() => exportHtml(project, sections, tone, images),
[project, sections, tone, images],
);
const download = async () => {
setDownloadError("");
const violations = audit?.mustViolations || [];
if (violations.length) {
setDownloadError(`品質ゲート未完了: ${violations.map((item) => item.label).join(" / ")}`);
return;
}
try {
const blob = await buildExportBundle({ project, sections, tone, images });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = "landing-page-package.zip";
anchor.click();
URL.revokeObjectURL(url);
} catch (error) {
setDownloadError(error.message);
}
};
const connectFrame = () => {
const doc = frameRef.current?.contentDocument;
if (!doc) return;
doc.querySelectorAll("section").forEach((section) => {
section.style.cursor = "crosshair";
section.addEventListener("click", (event) => {
event.preventDefault();
const rect = section.getBoundingClientRect();
const target = event.target?.closest?.(
"h1,h2,h3,p,img,a,button,form,input,textarea,section",
);
const className =
typeof target?.className === "string"
? target.className.trim().split(/\s+/u)[0]
: "";
setDraft({
sectionId: section.id,
x: ((event.clientX - rect.left) / rect.width) * 100,
y: ((event.clientY - rect.top) / rect.height) * 100,
element: `${target?.tagName?.toLowerCase() || "section"}${className ? `.${className}` : ""}`,
});
});
});
};
useEffect(() => {
const doc = frameRef.current?.contentDocument;
if (!doc) return;
doc.querySelectorAll(".studio-review-pin").forEach((pin) => pin.remove());
comments.forEach((comment, index) => {
const section = doc.getElementById(comment.sectionId);
if (!section) return;
if (getComputedStyle(section).position === "static") section.style.position = "relative";
const pin = doc.createElement("b");
pin.className = `studio-review-pin ${comment.status}`;
pin.textContent = String(index + 1);
Object.assign(pin.style, {
position: "absolute",
left: `${comment.x}%`,
top: `${comment.y}%`,
zIndex: "99",
display: "grid",
placeItems: "center",
width: "28px",
height: "28px",
borderRadius: "50%",
color: "#fff",
background: comment.status === "applied" ? "#526056" : "#ff4f36",
border: "2px solid #fff",
boxShadow: "0 4px 14px rgba(0,0,0,.22)",
font: "700 12px/1 Arial,sans-serif",
transform: "translate(-50%,-50%)",
pointerEvents: "none",
});
section.appendChild(pin);
});
}, [comments, html]);
const save = () => {
if (!draft || !message.trim()) return;
setComments((items) => [
...items,
createComment(
draft.sectionId,
draft.x,
draft.y,
message.trim(),
draft.element,
),
]);
setDraft(null);
setMessage("");
};
if (busyKind)
return <Generating title="デザインを再生成しています" status={status} />;
return (
<main className="implementation">
<div className="canvas-bar">
<div>
<small>05 / 実装</small>
<b>LPプレビューと書き出し</b>
</div>
<div className="viewport-switch">
<button
className={mode === "preview" ? "active" : ""}
onClick={() => setMode("preview")}
>
PREVIEW
</button>
<button
className={mode === "code" ? "active" : ""}
onClick={() => setMode("code")}
>
CODE
</button>
<button
className={viewport === "desktop" ? "active" : ""}
onClick={() => setViewport("desktop")}
>
PC
</button>
<button
className={viewport === "mobile" ? "active" : ""}
onClick={() => setViewport("mobile")}
>
SP
</button>
</div>
<button className="primary" onClick={download} disabled={Boolean(audit?.mustViolations?.length)}>
納品ZIPをダウンロード ↓
</button>
{downloadError && <p className="download-error">{downloadError}</p>}
</div>
<div className="implementation-review">
<div className={`implementation-bed ${viewport}`}>
{mode === "preview" ? (
<iframe
ref={frameRef}
onLoad={connectFrame}
title="生成LPプレビュー"
srcDoc={html}
/>
) : (
<pre>
<code>{html}</code>
</pre>
)}
</div>
<aside className="feedback implementation-feedback">
<small>DESIGN COMMENTS</small>
<p>プレビュー内の修正したい場所をクリックしてください。</p>
{draft && (
<div className="implementation-comment">
<b>
{draft.sectionId} / {draft.element}
</b>
<textarea
autoFocus
value={message}
onChange={(event) => setMessage(event.target.value)}
placeholder="この箇所をどう変える?"
/>
<button onClick={save}>コメント追加</button>
</div>
)}
{comments.map((comment, index) => (
<article key={comment.id} className={comment.status}>
<i>{index + 1}</i>
<div>
<b>
{comment.sectionId} / {comment.element}
</b>
<p>{comment.message}</p>
<small>
{comment.status === "applied" ? "反映済み" : "未反映"}
</small>
</div>
</article>
))}
<button
disabled={!comments.some((item) => item.status === "open")}
onClick={() =>
onRegenerate(comments.filter((item) => item.status === "open"))
}
>
コメントを反映して再生成
</button>
</aside>
</div>
</main>
);
}
function App() {
const restored = useMemo(() => readDraft(), []);
const localPreview =
import.meta.env.DEV &&
new URLSearchParams(window.location.search).get("preview") === "wireframe";
const layoutPreview =
import.meta.env.DEV &&
new URLSearchParams(window.location.search).get("preview") === "wireframe-library";
const [accessToken, setAccessToken] = useState(() => window.sessionStorage.getItem(ACCESS_KEY) || "");
const emptyGate = {
applied: [],
skipped: [],
known: [],
hypotheses: [],
unconfirmed: [],
};
const [stage, setStage] = useState(
localPreview || layoutPreview
? "wireframe"
: stages.some((item) => item.id === restored?.stage)
? restored.stage
: "hearing",
);
const [project, setProject] = useState(restored?.project || initialProject);
const [markdown, setMarkdown] = useState(
restored?.markdown || initialMarkdown,
);
const [sections, setSections] = useState(
layoutPreview
? wireframeVariantPreviewSections
: restored?.sections || initialSections,
);
const [headlines, setHeadlines] = useState(
restored?.headlines || [
{ id: "initial-1", copy: initialSections[0].title, reason: "初期案" },
],
);
const [headlineId, setHeadlineId] = useState(
restored?.headlineId || "initial-1",
);
const [qualityGate, setQualityGate] = useState(
restored?.qualityGate || emptyGate,
);
const [wireframeAudit, setWireframeAudit] = useState(
restored?.wireframeAudit || null,
);
const [directions, setDirections] = useState(
restored?.directions || toneDirections,
);
const [toneId, setToneId] = useState(restored?.toneId || null);
const [designImages, setDesignImages] = useState(
restored?.designImages || [],
);
const [comments, setComments] = useState(restored?.comments || []);
const [versions, setVersions] = useState(restored?.versions || []);
const [saveStatus, setSaveStatus] = useState(restored ? "saved" : "draft");
const [busyKind, setBusyKind] = useState(null);
const [status, setStatus] = useState("");
const [errorMessage, setErrorMessage] = useState("");
const tone = useMemo(
() => directions.find((item) => item.id === toneId) || directions[0],
[directions, toneId],
);
useEffect(() => {
window.scrollTo({ top: 0, behavior: "smooth" });
}, [stage]);
useEffect(() => {
setSaveStatus("saving");
const timer = window.setTimeout(() => {
try {
window.localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
stage,
project: {
...project,
sourceText: String(project.sourceText || "").slice(0, 400000),
},
markdown,
sections,
headlines,
headlineId,
qualityGate,
wireframeAudit,
directions,
toneId,
designImages,
comments,
versions,
}),
);
setSaveStatus("saved");
} catch {
setSaveStatus("error");
}
}, 350);
return () => window.clearTimeout(timer);
}, [
stage,
project,
markdown,
sections,
headlines,
headlineId,
qualityGate,
wireframeAudit,
directions,
toneId,
designImages,
comments,
versions,
]);
const pushVersion = (label, snapshot = {}) =>
setVersions((items) => [
...items.slice(-7),
{
id: `version-${Date.now()}`,
label,
createdAt: new Date().toISOString(),
sections: snapshot.sections || sections,
markdown: snapshot.markdown || markdown,
headlines: snapshot.headlines || headlines,
headlineId: snapshot.headlineId || headlineId,
qualityGate: snapshot.qualityGate || qualityGate,
wireframeAudit: snapshot.wireframeAudit || wireframeAudit,
directions: snapshot.directions || directions,
toneId: snapshot.toneId ?? toneId,
designImages: snapshot.designImages || designImages,
},
]);
const restoreVersion = (version) => {
setSections(version.sections || sections);
setMarkdown(version.markdown || markdown);
setHeadlines(version.headlines || headlines);
setHeadlineId(version.headlineId || headlineId);
setQualityGate(version.qualityGate || emptyGate);
setWireframeAudit(version.wireframeAudit || null);
setDirections(version.directions || directions);
setToneId(version.toneId ?? toneId);
setDesignImages(version.designImages || []);
setStage("structure");
};
const selectHeadline = (item) => {
setHeadlineId(item.id);
const next = sections.map((section, index) =>
index === 0 ? { ...section, title: item.copy } : section,
);
setSections(next);
setMarkdown(sectionsToMarkdown(next));
};
const analyzeSource = async (nextProject) => {
setErrorMessage("");
setBusyKind("brief");
try {
const result = await runGeneration("brief", nextProject, setStatus);
setProject((current) => ({
...current,
name: result.brief.projectName || current.name,
sourceSummary: result.brief.summary,
sourceFilled: result.brief.filled,
sourceNeedsConfirmation: result.brief.needsConfirmation,
answers: { ...result.brief.answers, ...current.answers },
}));
} catch (error) {
setErrorMessage(error.message);
} finally {
setBusyKind(null);
}
};
const generateStructure = async (instruction = "") => {
setErrorMessage("");
setBusyKind("structure");
try {
const result = await runGeneration(
"structure",
{ ...project, markdown: instruction ? markdown : "", instruction },
setStatus,
);
const nextMarkdown = sectionsToMarkdown(result.sections);
setSections(result.sections);
setMarkdown(nextMarkdown);
setHeadlines(result.headlines);
setHeadlineId(result.headlines[0]?.id);
setQualityGate(result.qualityGate || emptyGate);
const audit = await runWireframeAudit(result.sections, project.answers, result.qualityGate || emptyGate);
setWireframeAudit(audit);
pushVersion(instruction ? "構成を再生成" : "初回構成", {
sections: result.sections,
markdown: nextMarkdown,
headlines: result.headlines,
headlineId: result.headlines[0]?.id,
qualityGate: result.qualityGate || emptyGate,
wireframeAudit: audit,
});
setStage("structure");
} catch (error) {
setErrorMessage(error.message);
} finally {
setBusyKind(null);
}
};
const generateWireframe = async (feedback = "") => {
const feedbackItems = Array.isArray(feedback) ? feedback : [];
const instruction = feedbackItems.length
? JSON.stringify(
feedbackItems.map(({ id, sectionId, x, y, element, message }) => ({
id,
sectionId,
x: Math.round(x),
y: Math.round(y),
element,
message,
})),
)
: feedback;
setErrorMessage("");
setBusyKind("wireframe");
try {
const result = await runGeneration(
"wireframe",
{ ...project, markdown, instruction },
setStatus,
);
const nextSections = mergeTargetedSections(
sections,
result.sections,
feedbackItems,
);
const nextMarkdown = sectionsToMarkdown(nextSections);
setSections(nextSections);
setMarkdown(nextMarkdown);
setQualityGate(result.qualityGate || qualityGate);
const audit = await runWireframeAudit(nextSections, project.answers, result.qualityGate || qualityGate);
setWireframeAudit(audit);
pushVersion(
feedbackItems.length
? `WF部分修正 ${new Set(feedbackItems.map((item) => item.sectionId)).size}箇所`
: "WF生成",
{
sections: nextSections,
markdown: nextMarkdown,
qualityGate: result.qualityGate || qualityGate,
wireframeAudit: audit,
},
);
if (feedbackItems.length)
setComments((items) =>
items.map((item) =>
feedbackItems.some((feedbackItem) => feedbackItem.id === item.id)
? { ...item, status: "applied" }
: item,
),
);
setStage("wireframe");
} catch (error) {
setErrorMessage(error.message);
} finally {
setBusyKind(null);
}
};
const generateTone = async (instruction = "") => {
setErrorMessage("");
setBusyKind("tone");
try {
const result = await runGeneration(
"tone",
{ ...project, markdown, instruction },
setStatus,
);
setStatus("3方向の実画像を生成しています");
const imageSets = await Promise.all(
result.directions.map((direction) =>
runImageGeneration(
{ project, sections, direction, purpose: "tone" },
setStatus,
),
),
);
const withImages = result.directions.map((direction, index) => ({
...direction,
previewImage: imageSets[index]?.[0]?.src,
}));
setDirections(withImages);
setToneId(null);
pushVersion("トンマナ3案", { directions: withImages, toneId: null });
setStage("tone");
} catch (error) {
setErrorMessage(error.message);
} finally {
setBusyKind(null);
}
};
const generateDesign = async () => {
setErrorMessage("");
setBusyKind("design");
try {
const images = await runImageGeneration(
{ project, sections, direction: tone, purpose: "implementation" },
setStatus,
);
setDesignImages(images);
pushVersion("実装生成", { designImages: images, toneId: tone.id });
setStage("implementation");
} catch (error) {
setErrorMessage(error.message);
} finally {
setBusyKind(null);
}
};
const regenerateImplementation = async (feedbackItems) => {
const instruction = JSON.stringify(
feedbackItems.map(({ id, sectionId, x, y, element, message }) => ({
id,
sectionId,
x: Math.round(x),
y: Math.round(y),
element,
message,
})),
);
setErrorMessage("");
setBusyKind("implementation");
try {
const result = await runGeneration(
"wireframe",
{ ...project, markdown, instruction },
setStatus,
);
const nextSections = mergeTargetedSections(
sections,
result.sections,
feedbackItems,
);
const regeneratedImages = await runImageGeneration(
{
project,
sections: nextSections,
direction: {
...tone,
concept: `${tone.concept}。修正指示: ${feedbackItems.map((item) => item.message).join(" / ")}`,
},
purpose: "implementation",
sectionIds: [...new Set(feedbackItems.map((item) => item.sectionId))],
},
setStatus,
);
const images = mergeVisualRecords(designImages, regeneratedImages);
const nextMarkdown = sectionsToMarkdown(nextSections);
setSections(nextSections);
setMarkdown(nextMarkdown);
setDesignImages(images);
setQualityGate(result.qualityGate || qualityGate);
pushVersion(
`実装を部分修正 ${new Set(feedbackItems.map((item) => item.sectionId)).size}箇所`,
{
sections: nextSections,
markdown: nextMarkdown,
designImages: images,
qualityGate: result.qualityGate || qualityGate,
},
);
setComments((items) =>
items.map((item) =>
feedbackItems.some((feedbackItem) => feedbackItem.id === item.id)
? { ...item, status: "applied" }
: item,
),
);
} catch (error) {
setErrorMessage(error.message);
} finally {
setBusyKind(null);
}
};
const syncMarkdown = (value) => {
setMarkdown(value);
setSections((current) => markdownToSections(value, current));
};
const syncSections = (value) => {
setSections(value);
setMarkdown(sectionsToMarkdown(value));
};
let content;
if (stage === "hearing")
content = (
<Hearing
project={project}
setProject={setProject}
onComplete={() => generateStructure()}
onSourceLoaded={analyzeSource}
generating={Boolean(busyKind)}
generationKind={busyKind}
generationStatus={status}
/>
);
else if (stage === "structure")
content = (
<Structure
markdown={markdown}
setMarkdown={syncMarkdown}
sections={sections}
onChangeSections={syncSections}
headlines={headlines}
qualityGate={qualityGate}
selectedHeadline={headlineId}
onSelectHeadline={selectHeadline}
onRegenerate={generateStructure}
onNext={() => generateWireframe()}
busyKind={busyKind}
status={status}
/>
);
else if (stage === "wireframe")
content = (
<Wireframe
sections={sections}
audit={wireframeAudit}
comments={comments}
setComments={setComments}
onRegenerate={generateWireframe}
onNext={() => generateTone()}
busyKind={busyKind}
status={status}
/>
);
else if (stage === "tone")
content = (
<Tone
directions={directions}
sections={sections}
selected={toneId}
setSelected={setToneId}
onRegenerate={generateTone}
onNext={generateDesign}
busyKind={busyKind}
status={status}
/>
);
else
content = (
<Implementation
project={project}
sections={sections}
tone={tone}
images={designImages}
audit={wireframeAudit}
comments={comments}
setComments={setComments}
onRegenerate={regenerateImplementation}
busyKind={busyKind}
status={status}
/>
);
if (!accessToken && !localPreview && !layoutPreview)
return <AccessGate onUnlock={setAccessToken} />;
return (
<div className="studio">
<Header
stage={stage}
setStage={setStage}
saveStatus={saveStatus}
versions={versions}
onRestore={restoreVersion}
/>
{content}
{errorMessage && (
<aside className="runtime-error">
<i>!</i>
<div>
<b>生成を完了できませんでした</b>
<p>{errorMessage}。入力内容と現在の版は保持されています。</p>
</div>
<button
aria-label="エラーを閉じる"
onClick={() => setErrorMessage("")}
>
×
</button>
</aside>
)}
</div>
);
}
createRoot(document.getElementById("root")).render(<App />);