CODE · 35.9 KB
src/main.jsx
Workspace snapshot · 09/04 13:35
import React, { useEffect, useReducer, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import { makeAngles, makeCopyCandidates, sanitizeText, shortenCopy, strengthenCopy } from "./engine.js";
import { generationReducer, initialGenerationState } from "./generation-state.js";
import { addEvidence, buildEvidenceExport, evidenceSummary } from "./evidence.js";
import { listProjects, saveProject } from "./project-store.js";
import { buildExpansionVariants, getUseCase, useCases } from "./creative-presets.js";
import "./styles.css";
import ManagePanel from "./manage-panel.jsx";
import Library from "./library.jsx";
const steps = ["素材・用途", "届ける相手", "訴求", "コピー", "生成・展開", "保存・DL"];
const colorOptions = ["赤系", "青系", "緑系", "黄系", "オレンジ系", "紫系", "黒系", "ピンク系", "カラフル"];
const tasteOptions = ["かわいい", "スタイリッシュ", "ポップ", "エレガント", "クール", "ナチュラル", "インパクト", "コーポレート", "エディトリアル", "イラスト", "AIにおまかせ"];
const directions = ["人物写真", "強コピー", "シンプル"];
const directionNotes = { 人物写真: "感情と利用シーンで伝える", 強コピー: "言葉の力で視線を止める", シンプル: "余白と品で信頼をつくる" };
const showcaseSamples = [
{ src: "/showcase-food-v2.webp", label: "FOOD / WARM", title: "今夜は、10分でいい。" },
{ src: "/showcase-music-v2.webp", label: "MUSIC / ELECTRIC", title: "まだ知らない音に会う。" },
{ src: "/showcase-travel-v2.webp", label: "TRAVEL / EDITORIAL", title: "週末、知らない町へ。" },
];
const initialInput = { serviceName: "", summary: "", url: "", target: "", useCaseId: "sns-square", creativeInstruction: "", colorScheme: "カラフル", taste: "AIにおまかせ", fileName: "", documentText: "", documentPalette: [], fileStatus: "" };
function OptionChips({ label, options, value, onChange }) {
return <fieldset className="option-field wide"><legend>{label}</legend><div className="option-chips">{options.map((option) => <button type="button" key={option} className={value === option ? "option-chip selected" : "option-chip"} aria-pressed={value === option} onClick={() => onChange(option)}>{option}</button>)}</div></fieldset>;
}
function UseCasePicker({ value, onChange, multiple = false, compact = false }) {
const selected = multiple ? value : [value];
const toggle = (id) => {
if (!multiple) return onChange(id);
if (selected.includes(id)) return onChange(selected.filter((item) => item !== id));
if (selected.length >= 3) return;
onChange([...selected, id]);
};
return <div className={compact ? "use-cases compact" : "use-cases"}>{useCases.map((item, index) => <button type="button" key={item.id} className={selected.includes(item.id) ? "use-case selected" : "use-case"} onClick={() => toggle(item.id)} aria-pressed={selected.includes(item.id)}><span>{String(index + 1).padStart(2, "0")}</span><strong>{item.shortLabel}</strong><small>{item.size}</small>{!compact && <p>{item.purpose}</p>}</button>)}</div>;
}
function Showcase() {
return <div className="showcase" aria-label="AI Banner Labで生成した異なる業種のバナー例"><div className="showcase-label">THREE BRIEFS / THREE WORLDS / MADE IN AI BANNER LAB</div><div className="showcase-grid">{showcaseSamples.map((sample, index) => <figure key={sample.label} className={`showcase-card card-${index + 1}`}><img src={sample.src} alt={`${sample.title}の生成バナー例`}/><figcaption><span>{sample.label}</span><b>{sample.title}</b></figcaption></figure>)}</div><a className="showcase-library-link" href="/library"><span>PUBLIC CREATIVE ARCHIVE</span><b>生成された作品をもっと見る</b><i>↗</i></a></div>;
}
function Hero({ apiStatus }) {
return <header className="masthead-art">
<picture aria-hidden="true"><source media="(max-width: 600px)" srcSet="/hero-generated-mobile-v1.webp"/><img src="/hero-generated-desktop-v1.webp" alt="" width="1776" height="888" fetchPriority="high"/></picture>
<div className="sr-only"><span>ABL—03</span><span>ONE IDEA → EVERY FORMAT</span><h1>AI Banner Lab</h1><p>資料やサービス情報から、届ける相手・言葉・ビジュアルを設計。1つの勝ち筋を、あらゆる接点へ。</p><span>{apiStatus === "connected" ? "IMAGE ENGINE READY" : apiStatus === "checking" ? "IMAGE ENGINE CONNECTING" : "IMAGE ENGINE OFFLINE"}</span></div>
<a className="hero-library-hit" href="/library" aria-label="作品ライブラリを見る">作品ライブラリ</a>
{apiStatus === "disconnected" && <span className="hero-engine-warning">IMAGE ENGINE OFFLINE</span>}
</header>;
}
function BannerCard({ project, direction, generation, selected, onSelect, onRegenerate, onDownloaded, onPublish, publishStatus }) {
const [downloadStatus, setDownloadStatus] = useState("idle");
const [revisionInstruction, setRevisionInstruction] = useState("");
const useCase = getUseCase(project.input.useCaseId);
const download = async () => {
if (!generation.jobId) return;
const filename = `${project.input.serviceName || "creative"}-${useCase.shortLabel}-${direction}.png`;
setDownloadStatus("loading");
try {
const response = await fetch(`/api/download-banner?jobId=${encodeURIComponent(generation.jobId)}&filename=${encodeURIComponent(filename)}`);
if (!response.ok) throw new Error("download failed");
const objectUrl = URL.createObjectURL(await response.blob());
const link = document.createElement("a"); link.href = objectUrl; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(objectUrl);
onDownloaded(direction); setDownloadStatus("idle");
} catch { setDownloadStatus("error"); }
};
return <article className={selected ? "banner selected" : "banner"}>
<div className="banner-meta"><span>{direction}</span><small>{useCase.shortLabel} / {useCase.size}</small></div>
<div className="image-stage" style={{ aspectRatio: `${useCase.width} / ${useCase.height}` }}>
{generation.status === "loading" && <div className="loading"><span/>GPTが構図と文字を制作中…</div>}
{generation.status === "error" && <div className="generation-error"><b>生成できませんでした</b><span>{generation.error}</span></div>}
{generation.status === "idle" && <div className="generation-idle">まだ生成されていません</div>}
{generation.status === "success" && <img src={generation.imageUrl} alt={`${direction}の生成クリエイティブ`}/>}
</div>
<h3>{direction}</h3><p className="direction-note">{directionNotes[direction]}</p>
<div className="revision-box"><label>この1枚をどう変える?<textarea rows="3" value={revisionInstruction} onChange={(event) => setRevisionInstruction(sanitizeText(event.target.value, 500))} placeholder="例:人物を右へ。コピーをもっと大胆に。背景は夜の街に。"/></label><button className="ghost regenerate" disabled={generation.status === "loading"} onClick={() => onRegenerate(revisionInstruction)}>{revisionInstruction ? "指示を反映して再生成" : "同じ条件でもう1案"}</button></div>
<div className="card-actions"><button disabled={generation.status !== "success"} onClick={onSelect}>{selected ? "この方向を選択中" : "この方向から展開する"}</button><div className="secondary-actions"><button className="text-button" disabled={generation.status !== "success" || !generation.jobId || downloadStatus === "loading"} onClick={download}>{downloadStatus === "loading" ? "PNG準備中…" : downloadStatus === "error" ? "DLを再試行" : "PNGを保存 ↘"}</button><button className="publish-button" disabled={generation.status !== "success" || !generation.jobId || publishStatus === "loading" || publishStatus === "published"} onClick={onPublish}>{publishStatus === "loading" ? "公開中…" : publishStatus === "published" ? "ライブラリ公開済み" : publishStatus === "error" ? "公開を再試行" : "ライブラリへ公開 ↗"}</button></div></div>
</article>;
}
function ExpansionCard({ item, generation, onDownload, onPublish, publishStatus }) {
return <article className="expansion-card"><div className="expansion-head"><div><span>FORMAT</span><h3>{item.label}</h3></div><b>{item.size}</b></div><div className="expansion-image" style={{ aspectRatio: `${item.width} / ${item.height}` }}>{generation?.status === "success" ? <img src={generation.imageUrl} alt={`${item.label}への展開画像`}/> : generation?.status === "error" ? <div className="generation-error">{generation.error}</div> : <div className="loading"><span/>用途に合わせて再構成中…</div>}</div>{generation?.status === "success" && <div className="expansion-publish"><button className="text-button" onClick={() => onDownload(item, generation)}>PNGを保存 ↘</button><button className="publish-button" disabled={publishStatus === "loading" || publishStatus === "published"} onClick={onPublish}>{publishStatus === "loading" ? "公開中…" : publishStatus === "published" ? "公開済み" : "ライブラリへ公開 ↗"}</button></div>}</article>;
}
function App() {
const emptyProject = () => ({ id: crypto.randomUUID(), mode: "guided", input: { ...initialInput }, brandDirection: null, targets: [], targetIndex: 0, angles: [], angleIndex: 0, copyCandidates: [], copyRound: 0, copy: null, banners: [], selectedBanner: null, expansions: [], history: [], updatedAt: Date.now() });
const [project, setProject] = useState(emptyProject);
const [step, setStep] = useState(0);
const [projects, setProjects] = useState([]);
const [generation, dispatchGeneration] = useReducer(generationReducer, directions, initialGenerationState);
const [expansionGeneration, setExpansionGeneration] = useState({});
const [expansionUseCases, setExpansionUseCases] = useState(["blog", "youtube", "web-hero"]);
const [batchUseCases, setBatchUseCases] = useState(["download-cover", "blog", "sns-square"]);
const [expansionInstructions, setExpansionInstructions] = useState({});
const [apiStatus, setApiStatus] = useState("checking");
const [strategyStatus, setStrategyStatus] = useState("idle");
const [strategyError, setStrategyError] = useState("");
const [fileLoading, setFileLoading] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef(null);
const [saveError, setSaveError] = useState("");
const [evidenceCopyStatus, setEvidenceCopyStatus] = useState("idle");
const [publishStates, setPublishStates] = useState({});
useEffect(() => { fetch("/api/generate-banners").then((response) => response.json()).then((data) => setApiStatus(data.connected ? "connected" : "disconnected")).catch(() => setApiStatus("disconnected")); listProjects().then(setProjects).catch(() => {}); }, []);
const patch = (value) => setProject((current) => ({ ...current, ...value, updatedAt: Date.now() }));
const record = (action, metadata = {}) => setProject((current) => ({ ...current, history: addEvidence(current.history, action, metadata), updatedAt: Date.now() }));
const primaryUseCase = getUseCase(project.input.useCaseId);
const selectedTarget = project.targets[project.targetIndex];
const selectedAngle = project.angles[project.angleIndex];
const validStart = project.input.summary.trim() || project.input.documentText;
const baseBody = (context = {}) => {
const useCase = context.useCase || primaryUseCase;
const brand = context.brandDirection || project.brandDirection;
const brandContext = brand ? [`トーン: ${brand.visualTone}`, `配色: ${brand.colorDirection}`, `モチーフ: ${brand.motifs}`, `文字設計: ${brand.typography}`, brand.mustKeep?.length ? `必須: ${brand.mustKeep.join("、")}` : "", brand.avoid?.length ? `避ける: ${brand.avoid.join("、")}` : ""].filter(Boolean).join(" / ") : "";
return { serviceName: project.input.serviceName, target: context.target?.name || selectedTarget?.name, angle: `${context.angle?.type || selectedAngle?.type}: ${context.angle?.detail || selectedAngle?.detail}`, colorScheme: project.input.colorScheme, taste: project.input.taste, copy: context.copy || project.copy, useCase: useCase.label, purpose: useCase.purpose, width: useCase.width, height: useCase.height, brandContext, documentPalette: (project.input.documentPalette || []).join(", "), documentExcerpt: (project.input.documentText || "").slice(0, 1200) };
};
const pollJobs = async (queuedJobs, onResult, timeoutMs = 360000) => {
const startedAt = Date.now();
const pending = new Map(queuedJobs.filter((item) => item.jobId).map((item) => [item.jobId, item]));
while (pending.size && Date.now() - startedAt < timeoutMs) {
await new Promise((resolve) => setTimeout(resolve, 1500));
const response = await fetch(`/api/generate-banners?jobIds=${encodeURIComponent([...pending.keys()].join(","))}`);
const data = await response.json();
if (!response.ok) throw new Error(data.error || "生成状況を取得できませんでした");
data.jobs.forEach((job) => {
const queued = pending.get(job.id); if (!queued) return;
if (job.status === "COMPLETED") {
const artifact = job.output?.artifacts?.[0];
onResult(queued, artifact?.signedUrl ? { status: "success", imageUrl: artifact.signedUrl, jobId: job.id } : { status: "error", jobId: job.id, error: "生成画像を取得できませんでした" });
if (artifact?.signedUrl) record("生成完了", { direction: queued.direction, useCase: queued.useCaseId, jobId: job.id });
pending.delete(job.id);
} else if (["FAILED", "CANCELLED"].includes(job.status)) { onResult(queued, { status: "error", jobId: job.id, error: job.errorSummary || "画像生成に失敗しました" }); pending.delete(job.id); }
});
}
pending.forEach((queued, jobId) => onResult(queued, { status: "error", jobId, error: "生成がタイムアウトしました" }));
};
const requestBanners = async (requestedDirections, instruction = "", context = {}) => {
dispatchGeneration({ type: "start", directions: requestedDirections }); setStep(4);
try {
const response = await fetch("/api/generate-banners", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...baseBody(context), useCaseId: (context.useCase || primaryUseCase).id, directions: requestedDirections, instruction: sanitizeText(instruction || project.input.creativeInstruction, 500) }) });
const data = await response.json(); if (!response.ok) throw new Error(data.error || "画像生成を開始できませんでした");
const queued = data.jobs.filter((item) => item.jobId).map((item) => ({ ...item, key: item.direction }));
dispatchGeneration({ type: "results", results: [...data.jobs.filter((item) => !item.jobId).map((item) => ({ direction: item.direction, status: "error", error: item.error })), ...queued.map((item) => ({ direction: item.direction, status: "loading", jobId: item.jobId }))] });
setApiStatus("connected"); record(requestedDirections.length === 3 ? "3方向の生成開始" : "再生成開始", { directions: requestedDirections });
await pollJobs(queued, (item, result) => dispatchGeneration({ type: "results", results: [{ direction: item.direction, ...result }] }));
} catch (error) { if (error.message.includes("未接続")) setApiStatus("disconnected"); dispatchGeneration({ type: "failure", directions: requestedDirections, error: error.message }); }
};
const requestExpansions = async (ids, instructions = {}, context = {}) => {
const variants = buildExpansionVariants(ids);
setStep(4);
setExpansionGeneration((current) => ({ ...current, ...Object.fromEntries(variants.map((item) => [item.id, { status: "loading" }])) }));
const queued = [];
await Promise.all(variants.map(async (useCase, index) => {
const direction = context.direction || project.selectedBanner || directions[index % directions.length];
try {
const useCaseInstruction = typeof instructions === "string" ? instructions : instructions[useCase.id] || "";
const response = await fetch("/api/generate-banners", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...baseBody({ ...context, useCase }), useCaseId: useCase.id, directions: [direction], instruction: sanitizeText(useCaseInstruction, 500) }) });
const data = await response.json(); if (!response.ok) throw new Error(data.error || "用途展開を開始できませんでした");
const job = data.jobs[0]; if (!job?.jobId) throw new Error(job?.error || "用途展開を開始できませんでした");
queued.push({ ...job, useCaseId: useCase.id });
} catch (error) { setExpansionGeneration((current) => ({ ...current, [useCase.id]: { status: "error", error: error.message } })); }
}));
record("用途展開開始", { useCases: variants.map((item) => item.id), sourceDirection: context.direction || project.selectedBanner });
await pollJobs(queued, (item, result) => setExpansionGeneration((current) => ({ ...current, [item.useCaseId]: result })));
};
const nextTargets = async (documentBatch = false) => {
setStrategyStatus("loading"); setStrategyError("");
try {
const response = await fetch("/api/generate-strategy", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...project.input, useCaseLabel: primaryUseCase.label, useCasePurpose: primaryUseCase.purpose }) });
const queued = await response.json(); if (!response.ok) throw new Error(queued.error || "GPT戦略生成を開始できませんでした");
const startedAt = Date.now();
while (Date.now() - startedAt < 240000) {
await new Promise((resolve) => setTimeout(resolve, 1500));
const statusResponse = await fetch(`/api/generate-strategy?jobId=${encodeURIComponent(queued.jobId)}`); const status = await statusResponse.json();
if (!statusResponse.ok) throw new Error(status.error || "GPT戦略案を取得できませんでした");
if (status.status === "COMPLETED") {
const target = status.strategy.targets[0]; const angle = target.angles[0]; const copy = angle.copies[0];
patch({ mode: documentBatch ? "document-batch" : "guided", brandDirection: status.strategy.brandDirection, targets: status.strategy.targets, targetIndex: 0, angles: target.angles, angleIndex: 0, copyCandidates: angle.copies, copy, selectedBanner: null });
setStrategyStatus("idle");
if (documentBatch) await requestExpansions(batchUseCases, "資料のブランドトーン、基調色、語彙を守り、各媒体で最も伝わる情報量と構図にしてください。", { target, angle, copy, brandDirection: status.strategy.brandDirection }); else setStep(1);
return;
}
if (["FAILED", "CANCELLED"].includes(status.status)) throw new Error(status.errorSummary || "GPT戦略生成に失敗しました");
}
throw new Error("GPT戦略生成がタイムアウトしました");
} catch (error) { setStrategyStatus("error"); setStrategyError(error.message); }
};
const chooseTarget = (index) => { const target = project.targets[index]; patch({ targetIndex: index, angles: target.angles || makeAngles(target), angleIndex: 0 }); setStep(2); };
const chooseAngle = (index) => { const angle = project.angles[index]; const candidates = angle.copies || makeCopyCandidates(project.input, selectedTarget, angle); patch({ angleIndex: index, copyCandidates: candidates, copyRound: 0, copy: candidates[0] }); setStep(3); };
const readFile = async (file) => {
if (!file) return patch({ input: { ...project.input, fileName: "", documentText: "", documentPalette: [], fileStatus: "" } });
setFileLoading(true);
try { const { readServiceDocument } = await import("./document-reader.js"); const result = await readServiceDocument(file); patch({ input: { ...project.input, fileName: file.name, documentText: result.text, documentPalette: result.palette, fileStatus: result.status } }); }
catch (error) { patch({ input: { ...project.input, fileName: file.name, documentText: "", documentPalette: [], fileStatus: error.message } }); }
finally { setFileLoading(false); }
};
const downloadExpansion = async (item, result) => {
const filename = `${project.input.serviceName || "creative"}-${item.shortLabel}.png`;
const response = await fetch(`/api/download-banner?jobId=${encodeURIComponent(result.jobId)}&filename=${encodeURIComponent(filename)}`); if (!response.ok) return;
const objectUrl = URL.createObjectURL(await response.blob()); const link = document.createElement("a"); link.href = objectUrl; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(objectUrl); record("PNGダウンロード", { useCase: item.id });
};
const reset = () => { setProject(emptyProject()); dispatchGeneration({ type: "reset", directions }); setExpansionGeneration({}); setStep(0); };
const save = async () => {
setSaveError("");
const stored = { ...project, history: addEvidence(project.history, "制作保存"), banners: directions.map((direction) => ({ direction, ...generation[direction] })).filter((item) => item.status === "success"), expansions: Object.entries(expansionGeneration).filter(([, value]) => value.status === "success").map(([useCaseId, value]) => ({ useCaseId, ...value })) };
try { await saveProject(stored); setProject(stored); setProjects([stored, ...projects.filter((item) => item.id !== project.id)].slice(0, 20)); setStep(5); } catch { setSaveError("制作を保存できませんでした。ブラウザの保存領域を確認してください。"); }
};
const copyEvidence = async () => { setEvidenceCopyStatus("copying"); try { await navigator.clipboard.writeText(JSON.stringify(buildEvidenceExport(project), null, 2)); record("匿名利用結果コピー"); setEvidenceCopyStatus("done"); } catch { setEvidenceCopyStatus("error"); } };
const publishToLibrary = async (jobId, metadata) => {
setPublishStates((current) => ({ ...current, [jobId]: "loading" }));
try { const response = await fetch("/api/library", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jobId, ...metadata }) }); const data = await response.json(); if (!response.ok) throw new Error(data.error); setPublishStates((current) => ({ ...current, [jobId]: "published" })); record("ライブラリ公開", { jobId, format: metadata.format }); }
catch { setPublishStates((current) => ({ ...current, [jobId]: "error" })); }
};
const generatedExpansions = buildExpansionVariants(Object.keys(expansionGeneration));
return <main>
<Hero apiStatus={apiStatus}/>
{step === 0 && <Showcase/>}
<nav>{steps.map((label, index) => <button key={label} className={index === step ? "active" : index < step ? "done" : ""} disabled={index > step} onClick={() => index <= step && setStep(index)}><b>0{index + 1}</b>{label}</button>)}</nav>
{step === 0 && <section className="start-section"><div className="section-kicker">START WITH A DESTINATION</div><h2>まず、どこで使う?</h2><p className="muted">用途を先に決めることで、単なるサイズ変更ではなく、その媒体で伝わる情報量・構図・安全領域から設計します。</p><UseCasePicker value={project.input.useCaseId} onChange={(useCaseId) => patch({ input: { ...project.input, useCaseId } })}/>
<div className="input-layout"><div className="input-main"><div className="grid form"><label>サービス名<input value={project.input.serviceName} onChange={(event) => patch({ input: { ...project.input, serviceName: sanitizeText(event.target.value, 80) } })} placeholder="例:MinuteFlow"/></label><label>最初の出力<output className="format-output">{primaryUseCase.shortLabel}<b>{primaryUseCase.size}</b></output></label><label className="wide">サービス概要<textarea rows="5" value={project.input.summary} onChange={(event) => patch({ input: { ...project.input, summary: sanitizeText(event.target.value) } })} placeholder="誰の、どんな課題を、どう変えるサービスか。箇条書きでもOK。"/></label><label>サービスURL(任意)<input type="url" value={project.input.url} onChange={(event) => patch({ input: { ...project.input, url: sanitizeText(event.target.value, 300) } })}/></label><label>想定ターゲット(任意)<input value={project.input.target} onChange={(event) => patch({ input: { ...project.input, target: sanitizeText(event.target.value, 100) } })}/></label><label className="wide">{primaryUseCase.shortLabel}の生成指示(任意)<textarea rows="3" value={project.input.creativeInstruction} onChange={(event) => patch({ input: { ...project.input, creativeInstruction: sanitizeText(event.target.value, 500) } })} placeholder={`例:${primaryUseCase.shortLabel}では商品画面を主役に。コピーは左上、右側に人物。`}/><small>この用途だけに加えたい構図・雰囲気・素材・情報量を入力できます。</small></label><OptionChips label="色味" options={colorOptions} value={project.input.colorScheme} onChange={(colorScheme) => patch({ input: { ...project.input, colorScheme } })}/><OptionChips label="テイスト" options={tasteOptions} value={project.input.taste} onChange={(taste) => patch({ input: { ...project.input, taste } })}/></div>{strategyError && <p className="connection-error">{strategyError}</p>}<button className="primary-cta" disabled={!validStart || strategyStatus === "loading"} onClick={() => nextTargets(false)}>{strategyStatus === "loading" ? "GPTが戦略を組み立てています…" : "ターゲットと表現を設計する →"}</button></div>
<aside className="document-drop"><span className="badge">DOCUMENT TO CREATIVE</span><h3>資料の世界観ごと、<br/>バナーに。</h3><p>文章だけでなくPDFの基調色も読み取り、資料のトンマナを保ったまま最大3用途へ展開します。</p><div className={isDragging ? "drop-zone dragging" : "drop-zone"} role="button" tabIndex="0" onClick={() => fileInputRef.current?.click()} onKeyDown={(event) => (event.key === "Enter" || event.key === " ") && fileInputRef.current?.click()} onDragEnter={(event) => { event.preventDefault(); setIsDragging(true); }} onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = "copy"; setIsDragging(true); }} onDragLeave={(event) => { event.preventDefault(); if (!event.currentTarget.contains(event.relatedTarget)) setIsDragging(false); }} onDrop={(event) => { event.preventDefault(); setIsDragging(false); readFile(event.dataTransfer.files?.[0]); }}><input ref={fileInputRef} type="file" accept=".pdf,.txt,.md,.docx" onChange={(event) => readFile(event.target.files?.[0])}/><span className="drop-icon">↘</span><b>{fileLoading ? "資料を解析中…" : project.input.fileName || "ここに資料をドロップ"}</b><small>{project.input.fileStatus || "またはクリックして選択 / PDF・DOCX・TXT・MD / 15MBまで"}</small></div>{(project.input.documentPalette || []).length > 0 && <div className="palette-readout"><span>資料から検出した色</span><div>{project.input.documentPalette.map((color) => <i key={color} style={{ background: color }} title={color}/>)}</div></div>}<span className="aside-label">一括生成する用途(最大3つ)</span><UseCasePicker compact multiple value={batchUseCases} onChange={setBatchUseCases}/>{project.input.documentText && <output className="file-preview"><b>抽出した内容</b>{project.input.documentText.slice(0, 180)}…</output>}<button disabled={!project.input.documentText || batchUseCases.length === 0 || strategyStatus === "loading" || apiStatus !== "connected"} onClick={() => nextTargets(true)}>{strategyStatus === "loading" ? "資料のトンマナを解析中…" : `${batchUseCases.length}用途を資料から生成 →`}</button></aside></div>
</section>}
{step === 1 && <section><div className="section-kicker">AUDIENCE</div><h2>誰の、どの瞬間を動かす?</h2><div className="cards">{project.targets.map((target, index) => <button className="choice" key={target.name} onClick={() => chooseTarget(index)}><span className="choice-number">0{index + 1}</span><h3>{target.name}</h3><dl><dt>今の痛み</dt><dd>{target.pain}</dd><dt>見せたい未来</dt><dd>{target.future}</dd><dt>このサービスの役割</dt><dd>{target.value}</dd></dl></button>)}</div></section>}
{step === 2 && <section><div className="section-kicker">ANGLE</div><h2>{selectedTarget?.name}の<br/>心を動かす切り口</h2><div className="cards">{project.angles.map((angle, index) => <button className="choice" key={`${angle.type}-${index}`} onClick={() => chooseAngle(index)}><span className="tag">{angle.type}</span><h3>{angle.title}</h3><p>{angle.detail}</p></button>)}</div></section>}
{step === 3 && project.copy && <section><div className="section-kicker">WORDS BEFORE VISUALS</div><h2>絵を作る前に、<br/>言葉の芯を決める。</h2><p className="muted">{primaryUseCase.label}で小さく表示されても伝わる情報量に整えます。</p><div className="copy-candidates">{project.copyCandidates.map((candidate, index) => <button key={candidate.id} className={project.copy.id === candidate.id ? "copy-choice selected" : "copy-choice"} onClick={() => patch({ copy: candidate })}><small>ROUTE 0{index + 1}</small><strong>{candidate.main}</strong><span>{candidate.sub}</span></button>)}</div><div className="form copy-editor">{Object.entries({ main: "メインコピー", sub: "サブコピー", offer: "オファー", cta: "CTA" }).map(([key, label]) => <label key={key}>{label}<input value={project.copy[key]} onChange={(event) => patch({ copy: { ...project.copy, [key]: sanitizeText(event.target.value, 120) } })}/></label>)}</div>{apiStatus === "disconnected" && <p className="connection-error">画像生成エンジンがオフラインです。</p>}<div className="row"><button className="ghost" onClick={() => { const round = project.copyRound + 1; const candidates = makeCopyCandidates(project.input, selectedTarget, selectedAngle, round); patch({ copyRound: round, copyCandidates: candidates, copy: candidates[0] }); }}>切り口を変えて3案</button><button className="ghost" onClick={() => patch({ copy: shortenCopy(project.copy) })}>もっと短く</button><button className="ghost" onClick={() => patch({ copy: strengthenCopy(project.copy) })}>もっと強く</button><button disabled={apiStatus !== "connected"} onClick={() => requestBanners(directions)}>3つの表現方向を生成 →</button></div></section>}
{step === 4 && <section className="generation-section"><div className="section-kicker">{project.mode === "document-batch" ? "DOCUMENT → CREATIVE" : "GENERATE / SELECT / EXPAND"}</div><h2>{project.mode === "document-batch" ? <>資料から生まれた、<br/>媒体別クリエイティブ。</> : <>3方向を比べて、<br/>育てる1案を選ぶ。</>}</h2>{project.mode === "document-batch" ? <div className="brand-brief"><span>DOCUMENT ART DIRECTION</span><b>{project.brandDirection?.visualTone}</b><p>{project.brandDirection?.colorDirection}</p><small>{project.brandDirection?.typography}</small></div> : <><p className="muted">「選ぶ」は公開や広告配信ではありません。用途展開の元にする表現方向を決めるだけです。各カードの文章欄から、追加指示を入れて何度でも再生成できます。</p><div className="banners">{directions.map((direction) => <BannerCard key={direction} project={project} direction={direction} generation={generation[direction]} selected={project.selectedBanner === direction} onSelect={() => { patch({ selectedBanner: direction }); record("展開元を選択", { direction }); }} onRegenerate={(instruction) => requestBanners([direction], instruction)} onDownloaded={(value) => record("PNGダウンロード", { direction: value })} publishStatus={publishStates[generation[direction]?.jobId]} onPublish={() => publishToLibrary(generation[direction].jobId, { title: `${project.input.serviceName || "名称未設定"} / ${direction}`, format: primaryUseCase.label, direction, width: primaryUseCase.width, height: primaryUseCase.height })}/>)}</div></>}
{project.selectedBanner && <div className="expansion-studio"><div className="expansion-intro"><span className="badge">FORMAT STUDIO</span><h2>選んだ方向を、<br/>次の接点へ。</h2><p><b>{project.selectedBanner}</b>のコンセプトと確定コピーを使い、各媒体に合わせて構図を作り直します。単純な引き伸ばしではありません。</p></div><div className="expansion-controls"><span className="aside-label">展開する用途(最大3つ)</span><UseCasePicker compact multiple value={expansionUseCases} onChange={setExpansionUseCases}/><div className="format-prompt-list">{buildExpansionVariants(expansionUseCases).map((item) => <label key={item.id}><span>{item.shortLabel}の生成指示</span><textarea rows="3" value={expansionInstructions[item.id] || ""} onChange={(event) => setExpansionInstructions((current) => ({ ...current, [item.id]: sanitizeText(event.target.value, 500) }))} placeholder={`${item.label}だけに加える構図・雰囲気・情報量`}/></label>)}</div><button disabled={!expansionUseCases.length || Object.values(expansionGeneration).some((item) => item.status === "loading")} onClick={() => requestExpansions(expansionUseCases, expansionInstructions)}>選んだ{expansionUseCases.length}用途へ展開 →</button></div></div>}
{generatedExpansions.length > 0 && <div className="expansion-grid">{generatedExpansions.map((item) => <ExpansionCard key={item.id} item={item} generation={expansionGeneration[item.id]} onDownload={downloadExpansion} publishStatus={publishStates[expansionGeneration[item.id]?.jobId]} onPublish={() => publishToLibrary(expansionGeneration[item.id].jobId, { title: `${project.input.serviceName || "資料生成"} / ${item.shortLabel}`, format: item.label, direction: project.selectedBanner || "資料最適化", width: item.width, height: item.height })}/>)}</div>}
{saveError && <p className="connection-error">{saveError}</p>}<button className="save-creative" disabled={!directions.some((direction) => generation[direction]?.status === "success") && !Object.values(expansionGeneration).some((item) => item.status === "success")} onClick={save}>この制作を保存する</button>
</section>}
{step === 5 && <section><div className="section-kicker">SAVED TO YOUR LAB</div><h2>制作を保存しました。</h2><p>入力、ターゲット、訴求、コピー、生成画像、用途展開をこのブラウザに保存しました。</p><div className="summary"><b>{project.input.serviceName || "名称未設定"}</b><span>{primaryUseCase.shortLabel}</span><span>履歴 {project.history.length}件</span><span>{project.selectedBanner ? `展開元:${project.selectedBanner}` : "展開元未選択"}</span></div><p className="muted">利用証拠: {JSON.stringify(evidenceSummary(project.history))}</p><button className="ghost" disabled={evidenceCopyStatus === "copying"} onClick={copyEvidence}>{evidenceCopyStatus === "done" ? "匿名結果をコピー済み" : evidenceCopyStatus === "error" ? "コピーを再試行" : "匿名の利用結果をコピー"}</button><h3>保存済みの制作</h3>{projects.map((item) => <button className="saved" key={item.id} onClick={() => { setProject(item); dispatchGeneration({ type: "reset", directions }); dispatchGeneration({ type: "results", results: item.banners || [] }); setExpansionGeneration(Object.fromEntries((item.expansions || []).map((value) => [value.useCaseId, value]))); setStep(item.banners?.length || item.expansions?.length ? 4 : 0); }}>{item.input.serviceName || "名称未設定"}<small>{new Date(item.updatedAt).toLocaleString("ja-JP")}</small></button>)}</section>}
<footer><span>AI BANNER LAB / BETA 03</span><span>STRATEGY → CREATIVE → FORMAT</span><span>CODEX SUBSCRIPTION · NATIVE IMAGE GENERATION</span></footer>
</main>;
}
createRoot(document.getElementById("root")).render(window.location.pathname.startsWith("/manage-panel") ? <ManagePanel/> : window.location.pathname.startsWith("/library") ? <Library/> : <App/>);