// /Users/mdamba/dev/candrm/hotels_video/scripts/web/app.jsx
// ホテル動画ジェネレータ — 本番 UI (React + Babel inline)
// docs/ver1 の 2 ペイン構成を採用しつつ、すべて実 API に接続している。
// POST /api/scripts … LLM で台本を初期生成
// POST /api/narration … 1 文ぶんのナレーション合成 (WAV)
// POST /api/narration/rewrite … AI による書き直し
// POST /api/jobs … 動画レンダリングジョブ作成
// GET /api/jobs/{id} … ジョブ進捗ポーリング
// GET /api/jobs/{id}/result … 完成動画 (mp4)
//
// 共通デザイントークンは ./tokens.css と ./components.css に従う。
const { useState, useEffect, useRef, useMemo, useCallback } = React;
// ============================================================
// ログイン画面 (docs/ver1_login から移植)
// セッションは sessionStorage で保持する。
// ============================================================
// ---------- ログイン: アイコン ----------
const LoginIcon = {
Mail: () => (
),
Arrow: () => (
),
Back: () => (
),
Check: () => (
),
Alert: () => (
),
};
// ---------- ログイン: ロゴ ----------
function LoginBrandLogo() {
return (
);
}
// ---------- ログイン: メール入力ステップ ----------
function LoginStepEmail({ initialEmail, onSend, isLoading }) {
const [email, setEmail] = useState(initialEmail || "");
const [agree, setAgree] = useState(true);
const [touched, setTouched] = useState(false);
const valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
const showError = touched && !valid && email.length > 0;
const submit = (e) => {
e.preventDefault();
setTouched(true);
if (!valid || !agree) return;
onSend(email);
};
return (
);
}
// ---------- ログイン: 認証コードステップ ----------
function LoginStepCode({ email, expectedCode, onVerify, onBack, onResend, isLoading }) {
const [digits, setDigits] = useState(["", "", "", "", "", ""]);
const [error, setError] = useState(null);
const [resendTimer, setResendTimer] = useState(45);
const inputsRef = useRef([]);
useEffect(() => {
if (resendTimer <= 0) return;
const id = setTimeout(() => setResendTimer((s) => s - 1), 1000);
return () => clearTimeout(id);
}, [resendTimer]);
useEffect(() => { inputsRef.current[0]?.focus(); }, []);
const setDigit = (i, v) => {
setError(null);
setDigits((prev) => { const next = [...prev]; next[i] = v; return next; });
};
const handleChange = (i, e) => {
const v = e.target.value.replace(/\D/g, "").slice(-1);
setDigit(i, v);
if (v && i < 5) inputsRef.current[i + 1]?.focus();
if (i === 5 && v) {
const all = [...digits.slice(0, 5), v].join("");
if (all.length === 6) setTimeout(() => verifyCode(all), 120);
}
};
const handleKeyDown = (i, e) => {
if (e.key === "Backspace") {
if (digits[i]) { setDigit(i, ""); }
else if (i > 0) { inputsRef.current[i - 1]?.focus(); setDigit(i - 1, ""); }
} else if (e.key === "ArrowLeft" && i > 0) {
inputsRef.current[i - 1]?.focus();
} else if (e.key === "ArrowRight" && i < 5) {
inputsRef.current[i + 1]?.focus();
} else if (e.key === "Enter") {
const code = digits.join("");
if (code.length === 6) verifyCode(code);
}
};
const handlePaste = (e) => {
const text = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, 6);
if (!text) return;
e.preventDefault();
const next = [...digits];
for (let i = 0; i < 6; i++) next[i] = text[i] || "";
setDigits(next);
setError(null);
inputsRef.current[Math.min(text.length, 5)]?.focus();
if (text.length === 6) setTimeout(() => verifyCode(text), 120);
};
// 検証は完全にサーバ側で行う。expectedCode は廃止 (古いモック互換のため引数だけ残す)。
const verifyCode = async (code) => {
setError(null);
let ok = false;
try {
const r = onVerify(code);
ok = r && typeof r.then === "function" ? await r : Boolean(r);
} catch (_) {
ok = false;
}
if (!ok) {
setError("認証コードが一致しません。もう一度ご確認ください。");
setTimeout(() => {
inputsRef.current[0]?.focus();
inputsRef.current[0]?.select?.();
}, 350);
}
};
const code = digits.join("");
const canSubmit = code.length === 6 && !isLoading;
return (
);
}
// ---------- ログイン: 成功ステップ ----------
function LoginStepSuccess({ email, onDone }) {
useEffect(() => {
const id = setTimeout(onDone, 1400);
return () => clearTimeout(id);
}, [onDone]);
return (
ログイン完了
{email} でログインしました。
ダッシュボードへ移動しています
);
}
// ---------- ログイン: 開発用トースト (擬似メール) ----------
function LoginDevToast({ email, code, onDismiss }) {
if (!code) return null;
return (
DEV
{email} 宛の認証コード:
{code}
);
}
// ---------- ログイン: 画面全体 ----------
function LoginScreen({ onLogin }) {
const [step, setStep] = useState("email");
const [email, setEmail] = useState("");
const [code, setCode] = useState(null); // dev トースト表示用 (LOGIN_DEV_RETURN_CODE=true 時のみ)
const [loading, setLoading] = useState(false);
const [animKey, setAnimKey] = useState(0);
const [error, setError] = useState("");
// メールアドレスを受け取り → サーバに OTP 発行を依頼
const sendCode = async (em) => {
setEmail(em);
setLoading(true);
setError("");
try {
const fd = new FormData();
fd.append("email", em);
const res = await fetch("/api/login/start", { method: "POST", body: fd });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || `HTTP ${res.status}`);
// 開発モード時は code が返るので DEV トーストに表示
setCode(data.code || null);
setStep("code");
setAnimKey((k) => k + 1);
} catch (e) {
setError(e.message || "送信に失敗しました");
} finally {
setLoading(false);
}
};
// OTP を検証 → 成功なら Cookie がセットされる
// 戻り値の boolean を StepCode 側のシェイク制御に使う
const verifyCode = async (entered) => {
setLoading(true);
setError("");
try {
const fd = new FormData();
fd.append("email", email);
fd.append("code", String(entered || ""));
const res = await fetch("/api/login/verify", {
method: "POST",
body: fd,
credentials: "same-origin",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || "コードが一致しません");
setStep("success");
setAnimKey((k) => k + 1);
return true;
} catch (e) {
setError(e.message || "検証に失敗しました");
return false;
} finally {
setLoading(false);
}
};
const resendCode = async () => {
if (!email) return;
await sendCode(email);
};
const backToEmail = () => {
setStep("email"); setCode(null);
setError(""); setAnimKey((k) => k + 1);
};
const handleDone = useCallback(() => {
onLogin(email);
}, [email, onLogin]);
return (
{step === "email" && (
)}
{step === "code" && (
)}
{step === "success" && }
setCode(null)}
/>
);
}
// ============================================================
// 以下、メインアプリ
// ============================================================
// ---------- 定数 ----------
const ANGLES = [
["foodie", "食重視"],
["onsen", "温泉重視"],
["couple", "カップル向け"],
["family", "家族連れ向け"],
["solo", "一人旅向け"],
];
const TELOP_STYLES = [
["classic", "クラシック"],
["yellow", "イエロー"],
["pink", "ピンク"],
["neon", "ネオン"],
];
const TELOP_POS = [
["top", "上"],
["center", "中央"],
["bottom", "下"],
];
const SPEAKERS = [
["3", "ずんだもん"],
["2", "四国めたん"],
["8", "春日部つむぎ"],
["14", "冥鳴ひまり"],
];
// 「修正の方向性」プリセット
const REWRITE_PRESETS = [
"もっと短く",
"もっと長く",
"数字を入れて",
"数字を消す",
"若者向けに",
"丁寧に",
"カジュアルに",
"感情的に",
"落ち着いた口調で",
"一文にまとめて",
];
// ナレーション文字数からの粗い秒数推定 (UI 表示用)
function estDuration(text) {
const chars = (text || "").replace(/\s/g, "").length;
return Math.max(1.5, +(chars / 6.2).toFixed(1));
}
function cx(...xs) { return xs.filter(Boolean).join(" "); }
// ---------- 素材プレビュー ----------
// File から img / video を作る。サムネ用に静止表示する。
function MaterialMedia({ material, autoplay = false, controls = false }) {
if (!material) {
return 素材なし
;
}
if (material.kind === "VIDEO") {
return (