playreel/frontend/app/(ado2)/page.tsx
2026-09-08 13:48:20 +09:00

108 lines
5.3 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { apiFetch, type Job } from "@/lib/api";
import { GATE_META, type PlayreelJob } from "@/lib/playreel";
/* 무빙포스터 진입 — 두 갈래 (MOVING_POSTER_ENTRY_PLAN.md §1·§5) */
const ENTRIES = [
{
href: "/poster", icon: "IMG", title: "이미지로 시작하기",
desc: "포스터 한 장이 첫 프레임 그대로 살아나는 8~15초 무빙포스터",
flow: ["포스터 원본이 첫 프레임", "불꽃·물결·조명 등 요소만 움직임", "제목·일시 나레이션 + 음악"],
facts: [["필요한 것", "이미지 1장"], ["확인", "1회"], ["시간", "약 3분"], ["비용", "14크레딧"]],
cta: "포스터 올리기", hot: false, badge: null,
},
{
href: "/playreel", icon: "URL", title: "공연상품페이지로 시작하기",
desc: "상품페이지 주소 하나로 캐스팅·일정·줄거리까지 담긴 30초 예고편",
flow: ["포스터 무빙 훅 8초", "상세페이지 스크롤 (줄거리·캐스트·캐스팅 스케줄·할인)", "예매 안내 밴드 + QR"],
facts: [["필요한 것", "상품페이지 URL"], ["확인", "5회"], ["시간", "약 15분"], ["비용", "16크레딧"]],
cta: "주소 넣기", hot: true, badge: "상세페이지까지",
},
] as const;
type Recent = { id: string; name: string; href: string; kind: "이미지" | "상품페이지"; label: string; tone: string };
const F1_STATUS: Record<string, string> = {
queued: "대기 중", running: "만드는 중", awaiting_review: "검수 대기", failed: "실패", done: "완료",
};
function tone(status: string) {
return status === "failed" ? "#ff7a7a" : status === "done" ? "var(--color-mint)" : status === "awaiting_review" ? "#ffd27a" : "var(--color-text-gray-400)";
}
export default function HomePage() {
const [recent, setRecent] = useState<Recent[]>([]);
useEffect(() => {
// 두 갈래의 최근 작업을 합쳐 최신순 6개. 한쪽 API가 없어도(playreel 미구현) 다른 쪽은 보인다.
Promise.all([
apiFetch<Job[]>("/api/f1/jobs").catch(() => [] as Job[]),
apiFetch<PlayreelJob[]>("/api/playreel/jobs").catch(() => [] as PlayreelJob[]),
]).then(([f1, pr]) => {
const a: (Recent & { t: number })[] = [
...f1.map((j) => ({ id: j.id, name: j.name, href: `/poster/${j.id}`, kind: "이미지" as const, label: F1_STATUS[j.status] ?? j.status, tone: tone(j.status), t: j.created_at })),
...pr.map((j) => ({ id: j.id, name: j.name, href: `/playreel/${j.id}`, kind: "상품페이지" as const,
label: j.status === "awaiting_review" && j.gate ? `${GATE_META[j.gate].name} 대기` : (F1_STATUS[j.status] ?? j.status), tone: tone(j.status), t: j.created_at })),
];
setRecent(a.sort((x, y) => y.t - x.t).slice(0, 6));
});
}, []);
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", paddingTop: "1.5rem" }}>
<h1 className="page-title"> ?</h1>
<p className="page-subtitle"> . 9:16 .</p>
<div className="entry-stack">
{ENTRIES.map((e) => (
<div key={e.href} className={`card entry${e.hot ? " entry--hot" : ""}`}>
{e.badge && <span className="entry-badge">{e.badge}</span>}
<div className="entry-head">
<div className="entry-icon">{e.icon}</div>
<div>
<h2 className="entry-title">{e.title}</h2>
<p className="entry-desc">{e.desc}</p>
</div>
</div>
<div className="entry-flow">
{e.flow.map((f, i) => (
<span key={f} style={{ display: "contents" }}>
{i > 0 && <em></em>}
<span>{f}</span>
</span>
))}
</div>
<div className="entry-facts">
{e.facts.map(([k, v]) => (
<div key={k}><b>{k}</b><span>{v}</span></div>
))}
</div>
<Link href={e.href} className="btn-cta btn-lg" style={{ textDecoration: "none" }}>{e.cta}</Link>
</div>
))}
</div>
{recent.length > 0 && (
<div style={{ width: "100%", maxWidth: 560, marginTop: "2.5rem" }}>
<p className="field-label" style={{ margin: "0 0 0.75rem" }}> </p>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))", gap: "0.75rem" }}>
{recent.map((j) => (
<Link key={j.href} href={j.href} style={{ textDecoration: "none", color: "inherit" }}>
<div className="card-inner" style={{ padding: "1rem" }}>
<span className="tag" style={{ height: 20, padding: "0 8px", fontSize: "var(--text-xs)" }}>{j.kind}</span>
<p style={{ margin: "0.5rem 0 0", fontSize: "var(--text-sm)", fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{j.name}</p>
<p style={{ margin: "0.35rem 0 0", fontSize: "var(--text-xs)", fontWeight: 600, color: j.tone }}>{j.label}</p>
</div>
</Link>
))}
</div>
</div>
)}
</div>
);
}