"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([]); const [categories, setCategories] = useState([]); const [formats, setFormats] = useState([]); const [file, setFile] = useState(null); const [selected, setSelected] = useState(null); const [format, setFormat] = useState("poster"); const [jobId, setJobId] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [tplOpen, setTplOpen] = useState(true); const [hint, setHint] = useState(null); const [refBusy, setRefBusy] = useState(false); const refInput = useRef(null); const { job } = useJob("f2", jobId); const selectedTpl = templates.find((t) => t.id === selected) ?? null; const loadTemplates = () => apiFetch("/api/f2/templates").then(setTemplates); useEffect(() => { loadTemplates().catch((e) => setError(e.message)); apiFetch("/api/f2/categories").then(setCategories).catch(() => {}); apiFetch("/api/f2/formats").then(setFormats).catch(() => {}); apiFetch("/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("/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) => (
{t.removable && ( )}
); const running = job && (job.status === "queued" || job.status === "running"); return (

포스터 스타일링

내 포스터를 명화·고전화·영화 포스터의 화법으로 재해석합니다. 행사명·날짜·장소 텍스트는 그대로 유지됩니다.

{/* 스타일 템플릿 — 접이식, 종류가 늘어나도 접어둘 수 있다 */} {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 (

{c.label} {items.length}종

{items.map(renderCard)}
); })} {/* 내 레퍼런스 — 목록이 비어도 업로드 타일은 보여야 하므로 /categories가 아니라 upload-hint의 enabled로 렌더한다 */} {tplOpen && hint?.enabled && (

내 레퍼런스 직접 올린 이미지의 화법으로 변환합니다

{templates.filter((t) => t.category === USER_CATEGORY).map(renderCard)} { const f = e.target.files?.[0]; if (f) uploadReference(f); }} />
)} {/* 경고는 실제로 고른 순간에만. 항상 떠 있으면 아무도 안 읽는다 */} {selectedTpl && selectedTpl.license === "internal-only" && (
)} {selectedTpl?.attribution && (

레퍼런스 출처 · {selectedTpl.attribution}

)} {/* 입력(=원본)과 결과, 같은 크기의 4:5 박스 */}

내 포스터

결과

{job?.status === "done" && job.artifacts.image ? (
{/* eslint-disable-next-line @next/next/no-img-element */} 변환 결과
) : (
{running &&
} {running ? "변환 중 (30초 안팎)" : "변환 결과가 여기에 표시됩니다"}
)} {job?.status === "done" && job.artifacts.image && ( )}

출력 포맷

{formats.map((f) => ( ))}
{error &&

{error}

} {job?.status === "failed" && job.error && (
{job.error.detail}
)}
); } export default function StudioPage() { if (!FEATURES.styling) return ; return ; }