playreel/frontend/components/gate-card.tsx
2026-09-10 17:25:35 +09:00

301 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
/**
* 컨펌 게이트 카드 — PLAYREEL_JOURNEY.md §2 의 공통 구조.
* [eyebrow] n/5 · 이름 [title] [why] [body] [actions] [cost]
* 게이트별 본문은 아래 5개 컴포넌트. 편집값은 `edits` 로 부모에게 올려 approve 시 함께 보낸다.
*/
import { useState } from "react";
import {
GATE_META, type AnalysisReview, type ClipReview, type DetailSection, type FetchReview,
type FinalReview, type GateKey, type GateReview, type NarrationReview,
} from "@/lib/playreel";
// ── 공통 셸 ──────────────────────────────────────────────────────────
export function GateShell({ gate, children, onApprove, onBack, busy, error, approveLabel }: {
gate: GateKey; children: React.ReactNode;
onApprove: () => void; onBack?: () => void; busy: boolean; error: string | null; approveLabel?: string;
}) {
const g = GATE_META[gate];
return (
<div>
<p className="eyebrow" style={{ margin: 0, color: "var(--color-mint)", fontSize: "var(--text-sm)" }}>
{g.step} / 5 · {g.name}
</p>
<h2 style={{ margin: "0.4rem 0 0.5rem", fontSize: "var(--text-2xl)", fontWeight: 700, letterSpacing: "-0.02em" }}>{g.title}</h2>
<p style={{ margin: "0 0 1.5rem", fontSize: "var(--text-base)", color: "var(--color-text-gray-400)", lineHeight: 1.6 }}>{g.why}</p>
{children}
<div className="gate-actions">
<button className="btn-cta btn-lg" disabled={busy} onClick={onApprove}>
{busy ? "처리 중…" : (approveLabel ?? "승인하고 계속")}
</button>
{g.canBack && onBack && (
<button className="btn-outline btn-lg" disabled={busy} onClick={onBack}> </button>
)}
</div>
<p style={{ margin: "0.75rem 0 0", fontSize: "var(--text-sm)", color: "var(--color-text-gray-500)" }}>
· {g.next} · {g.eta}
</p>
{error && <p style={{ color: "#ff7a7a", fontSize: "var(--text-sm)", marginTop: "0.75rem" }}>{error}</p>}
</div>
);
}
// ── 작은 공용 조각 (아이콘은 SVG만 — 이모지 금지) ─────────────────────
const Ico = ({ d, size = 12 }: { d: string; size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" aria-hidden style={{ flexShrink: 0 }}>
<path d={d} />
</svg>
);
const CHECK = "M20 6 9 17l-5-5";
const CROSS = "M18 6 6 18M6 6l12 12";
const PLUS = "M12 5v14M5 12h14";
const Lock = ({ size = 12 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden style={{ flexShrink: 0 }}>
<rect x="4" y="11" width="16" height="10" rx="2" /><path d="M8 11V7a4 4 0 0 1 8 0v4" />
</svg>
);
function Chip({ on, onClick, children, locked }: { on: boolean; onClick?: () => void; children: React.ReactNode; locked?: boolean }) {
return (
<button type="button" onClick={locked ? undefined : onClick}
style={{
display: "inline-flex", alignItems: "center", gap: 6,
padding: "0.35rem 0.8rem", borderRadius: 999, cursor: locked ? "default" : "pointer", fontSize: "var(--text-sm)",
border: on ? "1px solid var(--color-mint)" : "1px solid var(--color-border-white-10)",
background: on ? "var(--color-mint-20)" : "transparent",
color: on ? "var(--color-mint)" : "var(--color-text-gray-400)", opacity: locked ? 0.85 : 1,
}}>
{locked ? <Lock /> : on ? <Ico d={CHECK} /> : <Ico d={PLUS} />}{children}
</button>
);
}
function Warn({ children }: { children: React.ReactNode }) {
return (
<p style={{ margin: "0.75rem 0 0", padding: "0.6rem 0.9rem", borderRadius: "var(--radius-md)", background: "rgba(255,210,122,0.1)", border: "1px solid rgba(255,210,122,0.35)", color: "#ffd27a", fontSize: "var(--text-sm)", lineHeight: 1.5 }}>
{children}
</p>
);
}
const two = { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: "var(--spacing-page-md)", alignItems: "start" } as const;
// 상세페이지 조각은 라벨이 겹친다 — "유의사항"만 일곱 개인 페이지가 흔하다.
// 칩으로 그리면 같은 글자가 줄줄이 서서 무엇을 고르는지 알 수 없어 썸네일을 그린다.
function SectionPicker({ sections, onToggle }: {
sections: DetailSection[]; onToggle: (id: string) => void;
}) {
const chosen = sections.filter((s) => s.selected).length;
return (
<>
<p className="field-label" style={{ margin: "1.5rem 0 0.35rem" }}> </p>
<p style={{ margin: "0 0 0.7rem", fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)", lineHeight: 1.5 }}>
. .
</p>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(84px, 1fr))", gap: 8, maxHeight: 340, overflowY: "auto", paddingRight: 4 }}>
{sections.map((s, i) => (
<button key={s.id} type="button" title={`${s.label} · ${s.height.toLocaleString()}px`}
onClick={s.required ? undefined : () => onToggle(s.id)}
style={{
position: "relative", padding: 0, borderRadius: "var(--radius-md)", overflow: "hidden",
cursor: s.required ? "default" : "pointer", textAlign: "left", background: "transparent",
border: s.selected ? "2px solid var(--color-mint)" : "2px solid var(--color-border-white-10)",
opacity: s.selected ? 1 : 0.5,
}}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={s.thumb_url} alt={s.label}
style={{ width: "100%", aspectRatio: "3 / 4", objectFit: "cover", objectPosition: "top", display: "block", background: "#000" }} />
<span style={{ position: "absolute", top: 4, left: 4, minWidth: 16, padding: "1px 4px", borderRadius: 4, background: "rgba(0,0,0,0.65)", color: "#fff", fontSize: 10, textAlign: "center" }}>{i + 1}</span>
<span style={{ display: "flex", alignItems: "center", gap: 4, padding: "3px 5px", fontSize: 10, lineHeight: 1.3, background: "rgba(0,0,0,0.65)", color: s.selected ? "var(--color-mint)" : "var(--color-text-gray-400)" }}>
{s.required ? <Lock size={10} /> : s.selected ? <Ico d={CHECK} size={10} /> : <Ico d={PLUS} size={10} />}
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{s.label}</span>
</span>
</button>
))}
</div>
<p style={{ margin: "0.6rem 0 0", fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)", display: "flex", alignItems: "center", gap: 6 }}>
<Lock size={11} /> {sections.length} {chosen} · .
</p>
</>
);
}
// ── ① 수집 확인 ───────────────────────────────────────────────────────
export function FetchGate({ data, onChange }: { data: FetchReview; onChange: (edits: { sections: string[]; meta: FetchReview["meta"] }) => void }) {
const [sections, setSections] = useState(data.sections);
const [meta, setMeta] = useState(data.meta);
const emit = (s = sections, m = meta) => onChange({ sections: s.filter((x) => x.selected).map((x) => x.id), meta: m });
return (
<div style={two}>
<div>
<p className="field-label" style={{ margin: "0 0 0.5rem" }}> </p>
{([["title", "공연명"], ["date_text", "일시"], ["place", "장소"]] as const).map(([k, label]) => (
<div key={k} style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.5rem" }}>
<span style={{ width: 52, flexShrink: 0, fontSize: "var(--text-sm)", color: "var(--color-text-gray-400)" }}>{label}</span>
<input className="input" style={{ height: 44 }} value={meta[k]}
onChange={(e) => { const m = { ...meta, [k]: e.target.value }; setMeta(m); emit(sections, m); }} />
</div>
))}
<p style={{ margin: "0.5rem 0 0", fontSize: "var(--text-sm)", color: "var(--color-text-gray-400)" }}>
· {meta.cast.join(", ")}
</p>
<SectionPicker sections={sections}
onToggle={(id) => {
const n = sections.map((x) => x.id === id ? { ...x, selected: !x.selected } : x);
setSections(n); emit(n);
}} />
{data.poster_width <= 800 && (
<Warn> {data.poster_width}px로 . 2 . .</Warn>
)}
</div>
<div>
<p className="field-label" style={{ margin: "0 0 0.5rem" }}></p>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={data.poster_url} alt="포스터" className="card-inner" style={{ width: "100%", maxWidth: 300, padding: 0, display: "block" }} />
</div>
</div>
);
}
// ── ② 연출 확인 ───────────────────────────────────────────────────────
export function AnalysisGate({ data, onChange }: { data: AnalysisReview; onChange: (edits: { movable: string[]; fixed: string[]; model: string }) => void }) {
const [movable, setMovable] = useState(data.movable);
const [fixed, setFixed] = useState(data.fixed);
const emit = (m = movable, f = fixed) => onChange({ movable: m.filter((x) => x.on).map((x) => x.key), fixed: f.filter((x) => x.on).map((x) => x.key), model: data.model });
return (
<div style={two}>
<div>
<p className="field-label" style={{ margin: "0 0 0.5rem" }}> </p>
<p style={{ margin: "0 0 0.6rem", fontSize: "var(--text-sm)", color: "var(--color-text-gray-400)" }}> . ·· .</p>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
{movable.map((m) => (
<Chip key={m.key} on={m.on} onClick={() => { const n = movable.map((x) => x.key === m.key ? { ...x, on: !x.on } : x); setMovable(n); emit(n); }}>{m.label}</Chip>
))}
</div>
<p className="field-label" style={{ margin: "1.5rem 0 0.5rem" }}> </p>
<p style={{ margin: "0 0 0.6rem", fontSize: "var(--text-sm)", color: "var(--color-text-gray-400)" }}> . .</p>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
{fixed.map((f) => (
<Chip key={f.key} on={f.on} locked={f.key === "title"} onClick={() => { const n = fixed.map((x) => x.key === f.key ? { ...x, on: !x.on } : x); setFixed(n); emit(movable, n); }}>{f.label}</Chip>
))}
</div>
<p className="field-label" style={{ margin: "1.5rem 0 0.5rem" }}> </p>
<p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--color-text-gray-300)" }}>Kling 3.0 pro · 8</p>
{data.ip_risk && <Warn> IP( ) . Kling으로만 . .</Warn>}
{!data.has_qr && <p style={{ margin: "0.6rem 0 0", fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)" }}> QR이 QR은 .</p>}
</div>
<div>
<p className="field-label" style={{ margin: "0 0 0.5rem" }}> (5% )</p>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={data.grid_url} alt="격자 오버레이" className="card-inner" style={{ width: "100%", maxWidth: 300, padding: 0, display: "block" }} />
</div>
</div>
);
}
// ── ③ 나레이션·보이스 확인 ────────────────────────────────────────────
export function NarrationGate({ data, onChange }: { data: NarrationReview; onChange: (edits: { lines: string[]; voice_id: string }) => void }) {
const [lines, setLines] = useState(data.lines);
const lenOk = data.est_seconds >= 28 && data.est_seconds <= 31;
return (
<div style={two}>
<div>
<p className="field-label" style={{ margin: "0 0 0.5rem" }}> 8</p>
{lines.map((l, i) => (
<div key={i} className="narr-row">
<span className="narr-slot" style={{ color: l.slot === "캐스팅 스케줄" ? "var(--color-mint)" : "var(--color-text-gray-400)" }}>{l.slot}</span>
<input className="input" style={{ height: 42, fontSize: "var(--text-sm)" }} value={l.text}
onChange={(e) => { const n = lines.map((x, j) => j === i ? { ...x, text: e.target.value } : x); setLines(n); onChange({ lines: n.map((x) => x.text), voice_id: data.voice.id }); }} />
</div>
))}
</div>
<div>
<p className="field-label" style={{ margin: "0 0 0.5rem" }}></p>
<div className="card-inner">
<p style={{ margin: 0, fontWeight: 700 }}>{data.voice.name}</p>
{data.voice.sample_url
? <audio controls src={data.voice.sample_url} style={{ width: "100%", marginTop: "0.6rem" }} />
: <p style={{ margin: "0.5rem 0 0", fontSize: "var(--text-sm)", color: "var(--color-text-gray-500)" }}> </p>}
</div>
<p className="field-label" style={{ margin: "1.25rem 0 0.5rem" }}> </p>
<p style={{ margin: 0, fontSize: "var(--text-2xl)", fontWeight: 700, color: lenOk ? "var(--color-mint)" : "#ffd27a" }}>
{data.est_seconds.toFixed(1)}
</p>
<p style={{ margin: "0.25rem 0 0", fontSize: "var(--text-sm)", color: "var(--color-text-gray-500)" }}> 28~31{!lenOk && " — 문장을 줄이거나 늘려주세요"}</p>
</div>
</div>
);
}
// ── ④ 클립 검수 ───────────────────────────────────────────────────────
export function ClipGate({ data }: { data: ClipReview }) {
return (
<div style={two}>
<div className="card-inner" style={{ display: "flex", alignItems: "center", justifyContent: "center", minHeight: 320 }}>
{data.clip_url
? <video src={data.clip_url} controls style={{ width: "100%", maxHeight: 520, borderRadius: "var(--radius-md)" }} />
: <p style={{ color: "var(--color-text-gray-500)", fontSize: "var(--text-sm)" }}> 8</p>}
</div>
<div>
<p className="field-label" style={{ margin: "0 0 0.5rem" }}> </p>
<div className="card-inner" style={{ borderColor: data.gate_passed ? "var(--color-mint-30)" : "rgba(255,122,122,0.4)" }}>
<p style={{ margin: 0, fontSize: "var(--text-xl)", fontWeight: 700, color: data.gate_passed ? "var(--color-mint)" : "#ff8c8c" }}>
{data.gate_passed ? "통과" : "실패"} · {data.title_mae.toFixed(2)}
</p>
<p style={{ margin: "0.35rem 0 0", fontSize: "var(--text-sm)", color: "var(--color-text-gray-400)" }}> 4.0 . .</p>
{data.gate_reason && <p style={{ margin: "0.5rem 0 0", fontSize: "var(--text-sm)", color: "#ffb37a" }}>{data.gate_reason}</p>}
</div>
<p className="field-label" style={{ margin: "1.25rem 0 0.5rem" }}>5 </p>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={data.frames_url} alt="프레임 시트" className="card-inner" style={{ width: "100%", padding: 0, display: "block" }} />
<Warn> . .</Warn>
</div>
</div>
);
}
// ── ⑤ 최종 검수 ───────────────────────────────────────────────────────
export function FinalGate({ data }: { data: FinalReview }) {
return (
<div style={two}>
<div className="card-inner" style={{ display: "flex", alignItems: "center", justifyContent: "center", minHeight: 420 }}>
{data.video_url
? <video src={data.video_url} controls style={{ width: "100%", maxHeight: 560, borderRadius: "var(--radius-md)" }} />
: <p style={{ color: "var(--color-text-gray-500)", fontSize: "var(--text-sm)" }}> {data.duration.toFixed(1)}</p>}
</div>
<div>
<p className="field-label" style={{ margin: "0 0 0.5rem" }}> </p>
{data.checks.map((c) => (
<p key={c.key} style={{ margin: "0.3rem 0", fontSize: "var(--text-base)", color: c.ok ? "var(--color-text-gray-300)" : "#ff8c8c", display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ color: c.ok ? "var(--color-mint)" : "#ff8c8c", display: "inline-flex" }}><Ico d={c.ok ? CHECK : CROSS} size={14} /></span>{c.label}
</p>
))}
<p className="field-label" style={{ margin: "1.25rem 0 0.5rem" }}>proof </p>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={data.proof_url} alt="proof" className="card-inner" style={{ width: "100%", padding: 0, display: "block" }} />
<p style={{ margin: "0.75rem 0 0", fontSize: "var(--text-sm)", color: "var(--color-text-gray-500)" }}> v{data.version} .</p>
</div>
</div>
);
}
/** review 페이로드 → 게이트 본문. edits 콜백은 승인 시 부모가 모아 보낸다. */
export function GateBody({ review, onEdits }: { review: GateReview; onEdits: (e: unknown) => void }) {
switch (review.gate) {
case "fetch_confirm": return <FetchGate data={review.data} onChange={onEdits} />;
case "analysis_confirm": return <AnalysisGate data={review.data} onChange={onEdits} />;
case "narration_confirm": return <NarrationGate data={review.data} onChange={onEdits} />;
case "clip_confirm": return <ClipGate data={review.data} />;
case "final_confirm": return <FinalGate data={review.data} />;
}
}