CODE · 10.3 KB
src/main.jsx
Workspace snapshot · 09/04 13:45
import React, { useMemo, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import { initialSlides, progressFor, reviseSlide } from "./deck-model.js";
import "./styles.css";
import "./runtime.css";
import "./art-direction.css";
const purposes = ["営業提案", "サービス紹介", "社内企画", "採用ピッチ", "ホワイトペーパー"];
function SlideCanvas({ slide }) {
return <article className={`slide-canvas slide-${slide.type}`}>
<div className="slide-grid" />
<div className="slide-meta"><span>{slide.eyebrow}</span><span>0{slide.id}</span></div>
<h2>{slide.title.split("\n").map((line, index) => <React.Fragment key={line}>{index > 0 && <br />}{line}</React.Fragment>)}</h2>
<p className="slide-body">{slide.body}</p>
{slide.type === "process" && <div className="process-line"><i>INPUT</i><b>→</b><i>STORY</i><b>→</b><i>EDIT</i><b>→</b><i>QA</i></div>}
{slide.type === "contrast" && <div className="contrast"><span>AI初稿<br /><small>見た目で終了</small></span><strong>VS</strong><span>AI Slide Lab<br /><small>使える正本へ</small></span></div>}
{slide.type === "proof" && <div className="proof-number"><b>03</b><span>real decks<br />to validate</span></div>}
{slide.type === "cover" && <div className="cover-orbit"><i /><i /><i /></div>}
{slide.type === "cta" && <div className="cta-stamp">START<br />WITH<br />ONE NOTE</div>}
<footer><span>{slide.claim}</span><small>AI SLIDE LAB</small></footer>
</article>;
}
function PreflightResults({ report, fileName }) {
const risky = report.slides.filter((slide) => slide.risks.length);
return <section className="preflight-results">
<header><div><span>CHECKED FILE</span><b>{fileName}</b></div><div className={`score score-${report.score >= 85 ? "good" : report.score >= 60 ? "warn" : "bad"}`}><strong>{report.score}</strong><small>/ 100</small></div></header>
<div className="verdict"><span>送信前判定</span><h2>{report.verdict}</h2><p>{report.slideCount}ページをブラウザ内で検査しました。ファイル本体は外部へ送信していません。</p></div>
<div className="finding-grid">{report.findings.map((finding)=><article key={finding.label} className={finding.level}><i>{finding.level === "pass" ? "PASS" : "CHECK"}</i><h3>{finding.label}</h3><p>{finding.detail}</p></article>)}</div>
<div className="slide-risks"><div><span>SLIDE REVIEW</span><b>{risky.length ? `${risky.length}ページを確認` : "追加確認なし"}</b></div>{report.slides.map((slide)=><p key={slide.page} className={slide.risks.length ? "has-risk" : "is-clear"}><strong>{String(slide.page).padStart(2,"0")}</strong><span>{slide.risks.length ? slide.risks.join(" / ") : `${slide.textElements}テキスト要素・${slide.editableShapes}図形を検出`}</span><i>{slide.risks.length ? "要確認" : "OK"}</i></p>)}</div>
<footer><strong>この診断は機械検査です。</strong><span>最終送信前にはPowerPointで全ページを開き、表示を確認してください。</span></footer>
</section>;
}
function PreflightEmpty() {
return <section className="preflight-empty">
<span>DROP A PPTX TO START</span><h2>送る前の、不安を見える化。</h2><p>PowerPointを選ぶと、この画面に診断スコアとスライド別の要確認箇所が表示されます。</p>
<div><article><b>01</b><h3>フォント</h3><p>相手の環境で置換される可能性のある標準外フォント候補。</p></article><article><b>02</b><h3>編集可能性</h3><p>テキスト要素と図形を数え、全面画像化されたページ候補を発見。</p></article><article><b>03</b><h3>レイアウト</h3><p>文字量と自動フィット設定から、表示環境差で崩れやすいページを抽出。</p></article></div>
<footer><strong>PRIVATE BY DESIGN</strong><span>解析はブラウザ内で完結します。PPTXはサーバーへアップロードされません。</span></footer>
</section>;
}
function App() {
const [step, setStep] = useState(3);
const [slides, setSlides] = useState(initialSlides);
const [selected, setSelected] = useState(0);
const [purpose, setPurpose] = useState("営業提案");
const [audience, setAudience] = useState("週3件以上の提案を行う小規模BtoBチーム");
const [fileName, setFileName] = useState("");
const [sourceText, setSourceText] = useState("商談準備の負担、情報の優先順位、編集可能な資料、全ページQAを扱うサービス資料。");
const [sourceStatus, setSourceStatus] = useState("");
const [preflight, setPreflight] = useState(null);
const [instruction, setInstruction] = useState("");
const [busy, setBusy] = useState(false);
const [generationStatus, setGenerationStatus] = useState("");
const fileRef = useRef(null);
const current = slides[selected];
const facts = useMemo(() => slides.map((slide) => slide.source), [slides]);
const loadSource = async (file) => {
if (!file) return;
setFileName(file.name); setSourceStatus("資料を読み取っています…");
try {
if (file.name.toLowerCase().endsWith(".pptx")) { const { analyzePptx } = await import("./pptx-preflight.js"); setPreflight(await analyzePptx(file)); }
else setPreflight(null);
const { readDocument } = await import("./document-reader.js"); const result = await readDocument(file); setSourceText(result.text); setSourceStatus(result.status);
}
catch (error) { setSourceStatus(error.message || "資料を読み取れませんでした"); }
};
const loadDemo = async () => {
setSourceStatus("検証用サンプルを読み込んでいます…");
try { const response=await fetch("/sample/deck-preflight-risk-demo.pptx"); if(!response.ok) throw new Error("サンプルを取得できませんでした"); const blob=await response.blob(); await loadSource(new File([blob],"deck-preflight-risk-demo.pptx",{type:"application/vnd.openxmlformats-officedocument.presentationml.presentation"})); }
catch(error){ setSourceStatus(error.message||"サンプルを読み込めませんでした"); }
};
const runRevision = () => {
if (!instruction.trim()) return;
setBusy(true);
window.setTimeout(() => {
setSlides((items) => items.map((slide, index) => index === selected ? reviseSlide(slide, instruction) : slide));
setInstruction("");
setBusy(false);
}, 520);
};
const generateStory = async () => {
setGenerationStatus("資料を読み、ストーリーを設計中…");
try {
const start = await fetch("/api/generate-story", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ purpose, audience, sourceText }) });
const started = await start.json();
if (!start.ok) throw new Error(started.error);
for (let attempt = 0; attempt < 45; attempt += 1) {
await new Promise((resolve) => window.setTimeout(resolve, 2000));
const response = await fetch(`/api/generate-story?jobId=${encodeURIComponent(started.jobId)}`);
const result = await response.json();
if (result.status === "COMPLETED") { setSlides(result.slides); setSelected(0); setStep(3); setGenerationStatus("AI構成を反映しました"); return; }
if (result.status === "FAILED") throw new Error(result.errorSummary || "生成に失敗しました");
}
throw new Error("生成がタイムアウトしました");
} catch (error) { setGenerationStatus(error.message || "生成基盤へ接続できませんでした"); }
};
return <main>
<header className="topbar">
<a className="brand" href="#"><span>ASL—01</span><b>AI Slide Lab</b></a>
<div className="status"><i /> BROWSER-ONLY ANALYSIS</div>
<button className="ghost">プロジェクト</button>
</header>
<section className="hero">
<div className="hero-copy">
<span className="kicker">CHECK BEFORE YOU SEND.</span>
<h1>そのPowerPoint、<br /><em>相手の環境でも崩れない?</em></h1>
<p>PPTXを送る前に、フォント・全面画像化・編集可能性・長文リスクを30秒で確認。<br />ファイルはブラウザ内で解析し、外部へ送信しません。</p>
</div>
<div className="hero-deck" aria-label="生成される資料のプレビュー">
<article className="hero-card card-a"><small>01 / FONT</small><b>置換リスクを<br />送る前に。</b><i>→</i></article>
<article className="hero-card card-b"><small>02 / EDIT</small><b>画像化された<br />ページを発見。</b><span>LOCAL</span></article>
<article className="hero-card card-c"><small>03 / SCORE</small><b>要確認箇所を<br />1画面に。</b><i>↗</i></article>
</div>
</section>
<nav className="steps" aria-label="制作ステップ">
{["PPTX", "CHECK", "RISKS", "SEND"].map((label, index) => <button key={label} className={step === index + 1 ? "active" : step > index + 1 ? "done" : ""} onClick={() => setStep(index + 1)}><span>0{index + 1}</span>{label}</button>)}
<div className="progress" style={{ "--progress": `${progressFor(step)}%` }} />
</nav>
<section className="studio">
<aside className="source-panel">
<div className="panel-title"><span>PPTX PREFLIGHT</span><b>送信前診断</b></div>
<button className="dropzone" onClick={() => fileRef.current?.click()} onDragOver={(event) => event.preventDefault()} onDrop={(event) => { event.preventDefault(); loadSource(event.dataTransfer.files[0]); }}>
<input ref={fileRef} type="file" accept=".pptx" onChange={(event) => loadSource(event.target.files[0])} />
<span className="plus">+</span><strong>{fileName || "PPTXを選択"}</strong><small>POWERPOINT / 15MBまで</small>
</button>
{!preflight && <button className="demo-button" onClick={loadDemo}>まず検証用サンプルで試す ↗</button>}
{sourceStatus && <p className="generation-status">{sourceStatus}</p>}
<div className="privacy-points"><p><i>01</i><span>ファイルは外部送信しない</span></p><p><i>02</i><span>結果を保存しない</span></p><p><i>03</i><span>機械検査の限界を明示</span></p></div>
</aside>
<section className="editor">{preflight ? <PreflightResults report={preflight} fileName={fileName} /> : <PreflightEmpty />}</section>
</section>
</main>;
}
createRoot(document.getElementById("root")).render(<App />);