fix(web): 게이트 ① 구획 선택에 썸네일을 그린다

thumb_url을 백엔드가 내려보내는데 화면은 라벨 칩만 그렸다.
실물은 19조각에 라벨이 겹쳐서("유의사항" 일곱 개) 무엇을 고르는지
알 수 없는 상태였다.

- SectionPicker: 썸네일 그리드(auto-fill 84px, 390px에서 4열).
  번호 배지 · 선택 표시 · 잠금(캐스팅 스케줄) · 높이 툴팁
- 문구를 새 동작에 맞춘다 — 조각을 잘라 붙이는 것이 아니라
  고른 지점부터 상세페이지를 이어서 훑는다
- 목 데이터를 실측 19조각으로 교체. 6개짜리 깔끔한 목을 두었던 탓에
  라벨이 겹치는 실물 모양을 개발 중에 못 봤다

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Haewon Kam 2026-09-09 15:24:58 +09:00
parent 0e53fa294b
commit d50dc52c39
2 changed files with 65 additions and 22 deletions

View File

@ -8,8 +8,8 @@
import { useState } from "react"; import { useState } from "react";
import { import {
GATE_META, type AnalysisReview, type ClipReview, type FetchReview, type FinalReview, GATE_META, type AnalysisReview, type ClipReview, type DetailSection, type FetchReview,
type GateKey, type GateReview, type NarrationReview, type FinalReview, type GateKey, type GateReview, type NarrationReview,
} from "@/lib/playreel"; } from "@/lib/playreel";
// ── 공통 셸 ────────────────────────────────────────────────────────── // ── 공통 셸 ──────────────────────────────────────────────────────────
@ -85,6 +85,46 @@ function Warn({ children }: { children: React.ReactNode }) {
const two = { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: "var(--spacing-page-md)", alignItems: "start" } as const; const two = { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: "var(--spacing-page-md)", alignItems: "start" } as const;
// 상세페이지 조각은 라벨이 겹친다 — "유의사항"만 일곱 개인 페이지가 흔하다.
// 칩으로 그리면 같은 글자가 줄줄이 서서 무엇을 고르는지 알 수 없어 썸네일을 그린다.
function SectionPicker({ sections, onToggle }: {
sections: DetailSection[]; onToggle: (id: string) => void;
}) {
const chosen = sections.filter((s) => s.selected).length;
return (
<>
<p className="field-label" style={{ margin: "1.5rem 0 0.35rem" }}>상세페이지에서 훑기 시작할 지점</p>
<p style={{ margin: "0 0 0.7rem", fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)", lineHeight: 1.5 }}>
고른 지점부터 상세페이지를 이어서 훑습니다. 조각을 잘라 붙이는 것이 아닙니다.
</p>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(84px, 1fr))", gap: 8, maxHeight: 340, overflowY: "auto", paddingRight: 4 }}>
{sections.map((s, i) => (
<button key={s.id} type="button" title={`${s.label} · ${s.height.toLocaleString()}px`}
onClick={s.required ? undefined : () => onToggle(s.id)}
style={{
position: "relative", padding: 0, borderRadius: "var(--radius-md)", overflow: "hidden",
cursor: s.required ? "default" : "pointer", textAlign: "left", background: "transparent",
border: s.selected ? "2px solid var(--color-mint)" : "2px solid var(--color-border-white-10)",
opacity: s.selected ? 1 : 0.5,
}}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={s.thumb_url} alt={s.label}
style={{ width: "100%", aspectRatio: "3 / 4", objectFit: "cover", objectPosition: "top", display: "block", background: "#000" }} />
<span style={{ position: "absolute", top: 4, left: 4, minWidth: 16, padding: "1px 4px", borderRadius: 4, background: "rgba(0,0,0,0.65)", color: "#fff", fontSize: 10, textAlign: "center" }}>{i + 1}</span>
<span style={{ display: "flex", alignItems: "center", gap: 4, padding: "3px 5px", fontSize: 10, lineHeight: 1.3, background: "rgba(0,0,0,0.65)", color: s.selected ? "var(--color-mint)" : "var(--color-text-gray-400)" }}>
{s.required ? <Lock size={10} /> : s.selected ? <Ico d={CHECK} size={10} /> : <Ico d={PLUS} size={10} />}
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{s.label}</span>
</span>
</button>
))}
</div>
<p style={{ margin: "0.6rem 0 0", fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)", display: "flex", alignItems: "center", gap: 6 }}>
<Lock size={11} /> {sections.length}개 중 {chosen}개 선택 · 캐스팅 스케줄은 항상 포함됩니다.
</p>
</>
);
}
// ── ① 수집 확인 ─────────────────────────────────────────────────────── // ── ① 수집 확인 ───────────────────────────────────────────────────────
export function FetchGate({ data, onChange }: { data: FetchReview; onChange: (edits: { sections: string[]; meta: FetchReview["meta"] }) => void }) { export function FetchGate({ data, onChange }: { data: FetchReview; onChange: (edits: { sections: string[]; meta: FetchReview["meta"] }) => void }) {
const [sections, setSections] = useState(data.sections); const [sections, setSections] = useState(data.sections);
@ -106,18 +146,11 @@ export function FetchGate({ data, onChange }: { data: FetchReview; onChange: (ed
캐스트 · {meta.cast.join(", ")} 캐스트 · {meta.cast.join(", ")}
</p> </p>
<p className="field-label" style={{ margin: "1.5rem 0 0.5rem" }}>영상에 넣을 상세페이지 부분</p> <SectionPicker sections={sections}
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}> onToggle={(id) => {
{sections.map((s) => ( const n = sections.map((x) => x.id === id ? { ...x, selected: !x.selected } : x);
<Chip key={s.id} on={s.selected} locked={s.required} setSections(n); emit(n);
onClick={() => { const n = sections.map((x) => x.id === s.id ? { ...x, selected: !x.selected } : x); setSections(n); emit(n); }}> }} />
{s.label}
</Chip>
))}
</div>
<p style={{ margin: "0.6rem 0 0", fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)", display: "flex", alignItems: "center", gap: 6 }}>
<Lock size={11} /> 캐스팅 스케줄은 항상 포함됩니다.
</p>
{data.poster_width <= 800 && ( {data.poster_width <= 800 && (
<Warn>포스터가 {data.poster_width}px로 작습니다. 다음 단계에서 화질을 2배 보정합니다(2크레딧). 원본 파일이 있으면 무빙포스터 경로에서 직접 올리는 편이 더 선명합니다.</Warn> <Warn>포스터가 {data.poster_width}px로 작습니다. 다음 단계에서 화질을 2배 보정합니다(2크레딧). 원본 파일이 있으면 무빙포스터 경로에서 직접 올리는 편이 더 선명합니다.</Warn>
)} )}

View File

@ -158,6 +158,23 @@ const PH = (w: number, h: number, text: string) =>
`<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}"><rect width="100%" height="100%" fill="#003538"/><text x="50%" y="50%" fill="#a6ffea" font-size="20" font-family="sans-serif" text-anchor="middle" dominant-baseline="middle">${text}</text></svg>`, `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}"><rect width="100%" height="100%" fill="#003538"/><text x="50%" y="50%" fill="#a6ffea" font-size="20" font-family="sans-serif" text-anchor="middle" dominant-baseline="middle">${text}</text></svg>`,
)}`; )}`;
// 실물이 오는 모양. 심규선 콘서트 상세페이지 실측 19조각이다 — 라벨이 겹치고 대부분 공지다.
// 이 자리에 6개짜리 깔끔한 목을 두었다가 게이트 ①이 같은 글자 열아홉 개만 뱉는 걸 놓쳤다.
const MOCK_SECTIONS: DetailSection[] = ([
["keyvisual", "키비주얼", 1108], ["synopsis", "작품 소개", 313], ["fragment", "조각", 244],
["info", "관람 정보", 270], ["notice", "유의사항", 608], ["info", "관람 정보", 346],
["fragment", "조각", 270], ["notice", "유의사항", 887], ["schedule", "캐스팅 스케줄", 582],
["notice", "유의사항", 1149], ["notice", "유의사항", 968], ["info", "관람 정보", 383],
["discount", "할인 안내", 473], ["fragment", "조각", 285], ["notice", "유의사항", 875],
["notice", "유의사항", 1063], ["fragment", "조각", 152], ["notice", "유의사항", 1068],
["info", "관람 정보", 371],
] as const).map(([tag, label, height], i) => ({
id: `detail_01_s${String(i + 1).padStart(2, "0")}`,
tag, label, height, thumb_url: PH(180, 240, String(i + 1)),
required: tag === "schedule" || undefined,
selected: tag !== "fragment",
}));
export function mockJob(gate: GateKey | "running" | "done" | "failed"): PlayreelJob { export function mockJob(gate: GateKey | "running" | "done" | "failed"): PlayreelJob {
const base: PlayreelJob = { const base: PlayreelJob = {
id: "mock", kind: "playreel", name: "뮤지컬 〈겨울왕국〉", status: "awaiting_review", stage: null, id: "mock", kind: "playreel", name: "뮤지컬 〈겨울왕국〉", status: "awaiting_review", stage: null,
@ -178,14 +195,7 @@ export function mockJob(gate: GateKey | "running" | "done" | "failed"): Playreel
return { ...base, gate, stages: stages(2), review: { gate, data: { return { ...base, gate, stages: stages(2), review: { gate, data: {
poster_url: PH(300, 420, "포스터 750px"), poster_width: 750, poster_url: PH(300, 420, "포스터 750px"), poster_width: 750,
meta: { title: "뮤지컬 〈겨울왕국〉", date_text: "2026.11.25 ~ 2027.03.01", place: "샤롯데씨어터", cast: ["박혜나", "정선아", "이지혜"], genre: "뮤지컬" }, meta: { title: "뮤지컬 〈겨울왕국〉", date_text: "2026.11.25 ~ 2027.03.01", place: "샤롯데씨어터", cast: ["박혜나", "정선아", "이지혜"], genre: "뮤지컬" },
sections: [ sections: MOCK_SECTIONS,
{ id: "s1", tag: "story", label: "작품 소개", thumb_url: PH(160, 90, "소개"), height: 1800, selected: true },
{ id: "s2", tag: "awards", label: "수상·세계관", thumb_url: PH(160, 90, "수상"), height: 900, selected: true },
{ id: "s3", tag: "cast", label: "캐스트", thumb_url: PH(160, 90, "캐스트"), height: 1400, selected: true },
{ id: "s4", tag: "schedule", label: "캐스팅 스케줄", thumb_url: PH(160, 90, "스케줄"), height: 2200, required: true, selected: true },
{ id: "s5", tag: "discount", label: "할인 안내", thumb_url: PH(160, 90, "할인"), height: 700, selected: true },
{ id: "s6", tag: "notice", label: "유의사항", thumb_url: PH(160, 90, "유의"), height: 1200, selected: false },
],
} } }; } } };
case "analysis_confirm": case "analysis_confirm":
return { ...base, gate, stages: stages(4), credits_used: 2, review: { gate, data: { return { ...base, gate, stages: stages(4), credits_used: 2, review: { gate, data: {