playreel/frontend/app/(ado2)/studio/page.tsx
2026-09-08 15:00:42 +09:00

312 lines
14 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";
import DisabledNotice from "@/components/disabled-notice";
import { FEATURES } from "@/lib/features";
import { useEffect, useRef, useState } from "react";
import PosterDropzone from "@/components/poster-dropzone";
import {
InternalOnlyWarning, LicenseBadge, UserReferenceNotice,
} from "@/components/license-badge";
import { apiFetch, useJob, type F2Category, type F2Template } from "@/lib/api";
interface F2Format { id: string; label: string }
interface UploadHint { enabled: boolean; min_long_edge: number }
const USER_CATEGORY = "user";
function StudioPageInner() {
const [templates, setTemplates] = useState<F2Template[]>([]);
const [categories, setCategories] = useState<F2Category[]>([]);
const [formats, setFormats] = useState<F2Format[]>([]);
const [file, setFile] = useState<File | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const [format, setFormat] = useState("poster");
const [jobId, setJobId] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [tplOpen, setTplOpen] = useState(true);
const [hint, setHint] = useState<UploadHint | null>(null);
const [refBusy, setRefBusy] = useState(false);
const refInput = useRef<HTMLInputElement>(null);
const { job } = useJob("f2", jobId);
const selectedTpl = templates.find((t) => t.id === selected) ?? null;
const loadTemplates = () =>
apiFetch<F2Template[]>("/api/f2/templates").then(setTemplates);
useEffect(() => {
loadTemplates().catch((e) => setError(e.message));
apiFetch<F2Category[]>("/api/f2/categories").then(setCategories).catch(() => {});
apiFetch<F2Format[]>("/api/f2/formats").then(setFormats).catch(() => {});
apiFetch<UploadHint>("/api/f2/upload-hint").then(setHint).catch(() => {});
}, []);
/** 레퍼런스 업로드 — 저장 + vision 분석까지 서버가 동기로 끝낸다(10초 안팎) */
const uploadReference = async (f: File) => {
setRefBusy(true);
setError(null);
try {
const fd = new FormData();
fd.append("reference", f);
fd.append("name", f.name.replace(/\.[^.]+$/, "").slice(0, 40));
const t = await apiFetch<F2Template>("/api/f2/templates", { method: "POST", body: fd });
await loadTemplates();
setSelected(t.id); // 방금 올린 것을 바로 고른 상태로
} catch (e) {
setError((e as Error).message);
} finally {
setRefBusy(false);
if (refInput.current) refInput.current.value = "";
}
};
const removeReference = async (id: string) => {
if (!confirm("이 레퍼런스를 삭제할까요? 되돌릴 수 없습니다.")) return;
try {
await apiFetch(`/api/f2/templates/${id}`, { method: "DELETE" });
if (selected === id) setSelected(null);
await loadTemplates();
} catch (e) {
setError((e as Error).message);
}
};
const submit = async () => {
if (!file || !selected) return;
setBusy(true);
setError(null);
try {
const fd = new FormData();
fd.append("poster", file);
fd.append("template_id", selected);
fd.append("format", format);
const { id } = await apiFetch<{ id: string }>("/api/f2/jobs", { method: "POST", body: fd });
setJobId(id);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
};
const renderCard = (t: F2Template) => (
<div key={t.id} style={{ position: "relative" }}>
<button onClick={() => setSelected(t.id)}
className={selected === t.id ? "btn-select selected" : "btn-select"}
style={{ width: "100%", height: "100%", padding: "0.5rem", display: "flex", flexDirection: "column", gap: "0.5rem", alignItems: "stretch" }}>
{/* 명화는 가로 그림도 있다. cover로 자르면 파도가 잘려 무엇인지 알 수 없다 */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={t.thumb_url} alt={t.name_ko}
style={{
width: "100%", aspectRatio: "2/3", objectFit: "contain", display: "block",
borderRadius: "var(--radius-md)", background: "var(--color-bg-darker)",
}} />
<span style={{ fontSize: "var(--text-sm)", fontWeight: 700, whiteSpace: "normal", lineHeight: 1.3 }}>
{t.name_ko}
</span>
{/* 제목이 1줄인 카드와 2줄인 카드가 섞인다. 그리드가 높이를 맞춰주므로
배지를 아래로 밀어붙이면 한 행의 배지가 같은 선에 선다 */}
<span style={{ display: "flex", marginTop: "auto" }}>
<LicenseBadge license={t.license} />
</span>
</button>
{t.removable && (
<button onClick={() => removeReference(t.id)} title="레퍼런스 삭제"
style={{
position: "absolute", top: 10, right: 10, width: 26, height: 26,
borderRadius: "var(--radius-full)", border: "1px solid var(--color-border-white-10)",
background: "rgba(0,0,0,0.62)", color: "var(--color-text-gray-300)",
cursor: "pointer", fontSize: 15, lineHeight: 1, fontFamily: "var(--font)",
}}>×</button>
)}
</div>
);
const running = job && (job.status === "queued" || job.status === "running");
return (
<div>
<h1 className="page-title">포스터 스타일링</h1>
<p className="page-subtitle">
내 포스터를 명화·고전화·영화 포스터의 화법으로 재해석합니다. 행사명·날짜·장소 텍스트는 그대로 유지됩니다.
</p>
<div className="card" style={{ maxWidth: 880, margin: "1.5rem auto 0", padding: "var(--spacing-page-md)" }}>
{/* 스타일 템플릿 — 접이식, 종류가 늘어나도 접어둘 수 있다 */}
<button
onClick={() => setTplOpen(!tplOpen)}
style={{
width: "100%", display: "flex", alignItems: "center", gap: "0.75rem",
background: "none", border: "none", cursor: "pointer", padding: 0,
fontFamily: "var(--font)", color: "var(--color-text-white)", textAlign: "left",
}}
>
<span className="eyebrow">스타일 템플릿</span>
{!tplOpen && selectedTpl && (
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.5rem" }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={selectedTpl.thumb_url} alt="" style={{ width: 22, height: 30, objectFit: "cover", borderRadius: 4 }} />
<span style={{ fontSize: "var(--text-base)", fontWeight: 700, color: "var(--color-mint)" }}>{selectedTpl.name_ko}</span>
<LicenseBadge license={selectedTpl.license} />
</span>
)}
{!tplOpen && !selectedTpl && (
<span style={{ fontSize: "var(--text-base)", color: "var(--color-text-gray-500)" }}>템플릿을 선택하세요</span>
)}
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
strokeLinecap="round" strokeLinejoin="round"
style={{ marginLeft: "auto", color: "var(--color-text-gray-400)", transition: "transform 0.2s", transform: tplOpen ? "rotate(180deg)" : "none" }}>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
{tplOpen && categories.filter((c) => c.id !== USER_CATEGORY).map((c) => {
const items = templates.filter((t) => t.category === c.id);
if (items.length === 0) return null;
return (
<div key={c.id} style={{ marginTop: "1.25rem" }}>
<p style={{
margin: "0 0 0.6rem", fontSize: "var(--text-sm)", fontWeight: 700,
color: "var(--color-text-gray-400)",
}}>
{c.label}
<span style={{ marginLeft: "0.5rem", fontWeight: 500, color: "var(--color-text-gray-500)" }}>
{items.length}종
</span>
</p>
<div style={{ display: "grid", gridTemplateColumns: "repeat(5, 1fr)", gap: "0.75rem" }}>
{items.map(renderCard)}
</div>
</div>
);
})}
{/* 내 레퍼런스 — 목록이 비어도 업로드 타일은 보여야 하므로 /categories가 아니라
upload-hint의 enabled로 렌더한다 */}
{tplOpen && hint?.enabled && (
<div style={{ marginTop: "1.25rem" }}>
<p style={{
margin: "0 0 0.6rem", fontSize: "var(--text-sm)", fontWeight: 700,
color: "var(--color-text-gray-400)",
}}>
내 레퍼런스
<span style={{ marginLeft: "0.5rem", fontWeight: 500, color: "var(--color-text-gray-500)" }}>
직접 올린 이미지의 화법으로 변환합니다
</span>
</p>
<div style={{ display: "grid", gridTemplateColumns: "repeat(5, 1fr)", gap: "0.75rem" }}>
{templates.filter((t) => t.category === USER_CATEGORY).map(renderCard)}
<button onClick={() => refInput.current?.click()} disabled={refBusy}
className="btn-select"
style={{
padding: "0.5rem", display: "flex", flexDirection: "column",
alignItems: "center", justifyContent: "center", gap: "0.6rem",
minHeight: 200, borderStyle: "dashed",
}}>
{refBusy ? (
<>
<div className="gen-spinner" style={{ width: 26, height: 26, borderWidth: 3 }} />
<span style={{ fontSize: "var(--text-sm)", fontWeight: 600 }}>화풍 분석 중…</span>
</>
) : (
<>
<span style={{ fontSize: 26, lineHeight: 1, fontWeight: 300 }}>+</span>
<span style={{ fontSize: "var(--text-sm)", fontWeight: 700 }}>레퍼런스 추가</span>
<span style={{ fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)", whiteSpace: "normal", lineHeight: 1.4 }}>
긴 변 {hint.min_long_edge}px 이상 권장
</span>
</>
)}
</button>
<input ref={refInput} type="file" accept="image/jpeg,image/png,image/webp,image/gif"
style={{ display: "none" }}
onChange={(e) => { const f = e.target.files?.[0]; if (f) uploadReference(f); }} />
</div>
<div style={{ marginTop: "0.75rem" }}><UserReferenceNotice /></div>
</div>
)}
{/* 경고는 실제로 고른 순간에만. 항상 떠 있으면 아무도 안 읽는다 */}
{selectedTpl && selectedTpl.license === "internal-only" && (
<div style={{ marginTop: "1.25rem" }}>
<InternalOnlyWarning note={selectedTpl.license_note} />
</div>
)}
{selectedTpl?.attribution && (
<p style={{
margin: "0.75rem 0 0", fontSize: "var(--text-xs)",
color: "var(--color-text-gray-500)", lineHeight: 1.6,
}}>
레퍼런스 출처 · {selectedTpl.attribution}
</p>
)}
{/* 입력(=원본)과 결과, 같은 크기의 4:5 박스 */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--spacing-page-md)", marginTop: "2rem" }}>
<div>
<p className="eyebrow" style={{ margin: "0 0 0.75rem" }}>내 포스터</p>
<PosterDropzone file={file} onFile={setFile} />
</div>
<div>
<p className="eyebrow" style={{ margin: "0 0 0.75rem" }}>결과</p>
{job?.status === "done" && job.artifacts.image ? (
<div className="card-inner" style={{
aspectRatio: "4 / 5", maxWidth: 400, margin: "0 auto", overflow: "hidden",
display: "flex", alignItems: "center", justifyContent: "center", padding: 0,
}}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={job.artifacts.image} alt="변환 결과" style={{ width: "100%", height: "100%", objectFit: "contain" }} />
</div>
) : (
<div className="card-inner" style={{
aspectRatio: "4 / 5", maxWidth: 400, margin: "0 auto",
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: "1rem",
}}>
{running && <div className="gen-spinner" style={{ width: 48, height: 48, borderWidth: 4 }} />}
<span style={{ fontSize: "var(--text-base)", color: running ? "var(--color-text-gray-300)" : "var(--color-text-gray-500)" }}>
{running ? "변환 중 (30초 안팎)" : "변환 결과가 여기에 표시됩니다"}
</span>
</div>
)}
{job?.status === "done" && job.artifacts.image && (
<div style={{ display: "flex", justifyContent: "center", marginTop: "0.75rem" }}>
<a href={job.artifacts.image} download className="btn-tonal-mint">PNG 다운로드</a>
</div>
)}
</div>
</div>
<p className="eyebrow" style={{ margin: "1.5rem 0 0.75rem" }}>출력 포맷</p>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: "0.75rem" }}>
{formats.map((f) => (
<button key={f.id} onClick={() => setFormat(f.id)}
className={format === f.id ? "btn-select selected" : "btn-select"}>
{f.label}
</button>
))}
</div>
<div style={{ display: "flex", justifyContent: "center", marginTop: "2rem" }}>
<button className="btn-cta" disabled={!file || !selected || busy || !!running} onClick={submit}>
{running ? "변환 중…" : busy ? "업로드 중…" : "스타일 변환"}
</button>
</div>
{error && <p style={{ color: "#ff7a7a", fontSize: "var(--text-sm)", marginTop: "0.75rem", textAlign: "center" }}>{error}</p>}
{job?.status === "failed" && job.error && (
<pre className="card-inner" style={{
fontSize: "var(--text-sm)", whiteSpace: "pre-wrap", color: "var(--color-text-gray-400)",
marginTop: "1rem", maxHeight: 200, overflow: "auto",
}}>{job.error.detail}</pre>
)}
</div>
</div>
);
}
export default function StudioPage() {
if (!FEATURES.styling) return <DisabledNotice title="포스터 스타일링" backHref="/" />;
return <StudioPageInner />;
}