312 lines
14 KiB
TypeScript
312 lines
14 KiB
TypeScript
"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 />;
|
||
}
|