"use client"; import { useEffect, useRef, useState } from "react"; export interface StageState { status: "idle" | "running" | "done" | "failed"; started: number | null; ended: number | null; } export interface Job { id: string; kind: "f1" | "f2"; name: string; status: "queued" | "running" | "awaiting_review" | "failed" | "done"; stage: string | null; stages: Record; narration: string[] | null; motion_elements?: string[] | null; metadata: PosterMeta | null; error: { stage: string; detail: string } | null; artifacts: Record; created_at: number; queue_size?: number; template_id?: string | null; } export interface PosterMeta { event_name: string; date_text: string; place: string; category: string; keywords: string[]; region_guess?: string; } export interface ArchiveEntry { slug: string; name: string; /** 갈래. 서버가 아직 안 주면(구 엔트리) archiveKind()가 source 유무로 추정한다 — 백엔드 요청 항목(PLAYREEL_JOURNEY §5) */ kind?: "f1" | "playreel"; /** 갈래 2(상품페이지)일 때만 존재 */ source?: { url: string; goods_id: string; slug: string } | null; metadata: PosterMeta | null; narration: string[] | null; poster_url: string; video_url: string | null; f2_variants: { template_id: string; image_url: string; name_ko?: string; license?: "public-domain" | "internal-only" | "user-uploaded"; attribution?: string; }[]; created_at: number; } /** 아카이브 카드 배지 — 홈 최근 작업과 같은 이름("이미지" / "상품페이지") */ export function archiveKind(e: ArchiveEntry): "이미지" | "상품페이지" { if (e.kind) return e.kind === "playreel" ? "상품페이지" : "이미지"; return e.source?.url ? "상품페이지" : "이미지"; } export interface F2Template { id: string; name_ko: string; thumb_url: string; category: string; /** 배포 등급 — public-domain만 외부 공개 가능 */ license: "public-domain" | "internal-only" | "user-uploaded"; attribution: string; license_note: string; /** 사용자가 올린 레퍼런스인가 (삭제 가능) */ removable: boolean; /** 레퍼런스가 작아 결과가 나빠질 수 있음 */ small_ref: boolean; } export interface F2Category { id: string; label: string; } /** 서버가 본문 없이 실패했을 때의 안내. Next 리라이트가 백엔드에 못 붙으면 500/502가 빈 본문으로 온다. */ function friendlyStatus(status: number, path: string): string { if (status === 404) return `요청한 경로가 서버에 없습니다 (${path})`; if (status >= 500) return "API 서버(:30101)에 연결할 수 없습니다. 서버가 켜져 있는지 확인해 주세요."; return `요청 실패 (HTTP ${status})`; } export async function apiFetch(path: string, init?: RequestInit): Promise { const res = await fetch(path, init); if (!res.ok) { let detail: string | null = null; try { const body = await res.json(); detail = body.detail ?? JSON.stringify(body); } catch { /* 본문 없는 에러 — 아래 상태별 안내로 */ } // FastAPI 기본 404 본문("Not Found")은 안내가 안 되므로 상태별 문구로 대체 throw new Error(detail && detail !== "Not Found" ? detail : friendlyStatus(res.status, path)); } return res.json(); } const F1_ACTIVE = new Set(["queued", "running"]); /** 잡 폴링 훅 — done/failed/awaiting_review에서 폴링 중단 (approve 후 재개는 refresh()) */ export function useJob(kind: "f1" | "f2", id: string | null, intervalMs = 2500) { const [job, setJob] = useState(null); const [error, setError] = useState(null); const timer = useRef | null>(null); const refresh = async () => { if (!id) return; try { const j = await apiFetch(`/api/${kind}/jobs/${id}`); setJob(j); setError(null); if (!F1_ACTIVE.has(j.status) && timer.current) { clearInterval(timer.current); timer.current = null; } } catch (e) { setError((e as Error).message); } }; useEffect(() => { if (!id) return; // eslint-disable-next-line react-hooks/set-state-in-effect -- 폴링 시작: setState는 fetch 완료 후 비동기로만 일어난다 refresh(); timer.current = setInterval(refresh, intervalMs); return () => { if (timer.current) clearInterval(timer.current); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [id]); return { job, error, refresh: () => { refresh(); if (!timer.current) timer.current = setInterval(refresh, intervalMs); } }; }