o2o-site-AEO/solution/frontend/src/features/builder/canvas/variants/songs/SongsTurntable.tsx
Mina Choi 23162783f9 [feat] solution/frontend: 붙여넣기 아이템 셋 — 가요 다방·오늘의 한 장·반나절 산책
gunsan_365_story_db.xlsx 365행을 뜯어 보니 고유 주제는 52개이고 한 주제가 7회씩 돈다
(접미사 10개만 회전). 날짜 축으로 카드를 늘어놓으면 이레마다 같은 카드가 돌아온다 —
그래서 묶는 축을 주제로 잡고 날짜는 일력 한 장에만 썼다. 같은 파일 DB_Guide 가 가사·원문
전재를 금지해 가요 스키마에 lyrics 필드를 아예 두지 않았다. 없는 칸은 채울 수 없다.

- canvas/dataSpec.ts: 스키마·예시·프롬프트 단일 표. 레지스트리와 같은 결이라 한 줄을 더하면
  캔버스·[콘텐츠] 탭·프롬프트가 동시에 는다. 파싱은 절대 throw 하지 않는다 — 편집 중인 JSON 은
  늘 깨져 있고 그때 캔버스가 죽으면 고칠 방법이 없다
- shared/types/builder: SectionItem.data 는 파싱본이 아니라 원문 문자열이다. 파싱본만 들면
  JSON 이 깨진 순간 사장님이 쓴 걸 잃는다
- variants/{songs,daily,course}: 턴테이블·일력·승차권. 카드 격자를 쓰지 않고 전부 가로로 넘긴다
- RightTabsPanel: JSON 칸 + 프롬프트 복사/보기·예시 넣기·줄맞춤. 프롬프트에 상호와 주소에서 뽑은
  시·군·구를 박아 내보낸다 — 빈칸을 남기면 못 채우고 그대로 보내고 모델이 엉뚱한 곳을 지어낸다
- dataSpec.locate: JSON.parse 오류가 두 형식이다. position 형만 보면 조각 인용 형에서 위치를
  통째로 잃는다 — 조각을 원문에서 되찾아 센다
- SECTION_DATA_MAX_CHARS: 테마 상한 64KB(site_service._THEME_MAX_BYTES)를 세 섹션이 함께
  넘길 수 있고 거절은 발행 직전에야 드러난다. 화면에서 먼저 끊는다
- industryData: 네 업종에 꺼진 채로 넣는다. 내용 없이 켜져 있으면 발행본에 빈 섹션이 나간다
- index.html: Gugi·Gowun Batang·Nanum Pen Script. 없으면 고딕으로 떨어져 감성이 사라진다

발행 사이트(solution/site)는 아직 variantId 도 data 도 읽지 않는다 — 지금은 빌더 캔버스 전용이다.

tsc·eslint·vite build 통과(frontend·admin), 세 배리에이션 SSR 렌더 확인, 파서 경계 12건 확인
2026-09-01 17:58:58 +09:00

161 lines
6.9 KiB
TypeScript

/**
* 가요 다방 — 턴테이블 하나 + 도넛판 캐러셀.
*
* 판을 누르면 톤암이 내려오고 판이 돈다. 가사를 못 싣는 제약(DB_Guide: 원문 전재 금지)을
* 숨기지 않고 라벨 아래 각인으로 드러낸다 — 제약이 곧 이 아이템의 인상이다.
*/
import {useState} from 'react';
import {SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {parseSectionData, type SongItem} from '../../dataSpec';
import {
CarouselNav,
ParseError,
PasteHint,
RETRO_BODY,
RETRO_SIGN,
SourceLine,
useCarousel,
} from '../retro/common';
import '../retro/retro.css';
const FALLBACK_LABEL = '#d4551f';
export function SongsTurntable(props: SectionRenderProps) {
const {section, isSelected, onSelect, template} = props;
const parsed = parseSectionData<SongItem>(section.type, section.data);
const [playing, setPlaying] = useState(0);
const {ref, scrollBy} = useCarousel<HTMLDivElement>();
// 항목이 줄어 인덱스가 범위를 벗어나도 첫 곡으로 떨어진다 — 빈 화면을 만들지 않는다.
const current = parsed.items[playing] ?? parsed.items[0];
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="dark">
<SectionBody width="wide">
<div className="space-y-1.5">
<p className="text-[11px] tracking-[0.28em] text-amber-500/90">33 RPM</p>
<h2 className="text-2xl text-stone-50 sm:text-3xl" style={{fontFamily: RETRO_SIGN}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm text-stone-400" style={{fontFamily: RETRO_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : !current ? (
<PasteHint label="가요 다방" />
) : (
<>
<div className="grid items-center gap-8 border border-stone-700/60 bg-linear-160 from-stone-800 to-stone-900 p-6 sm:p-8 md:grid-cols-[260px_minmax(0,1fr)]">
{/* 턴테이블 */}
<div className="relative mx-auto size-[240px]">
<div className="absolute inset-0 rounded-full bg-radial from-stone-700 from-32% to-stone-800 shadow-[0_10px_26px_rgba(0,0,0,.5)]" />
<div
className="w4-disc w4-spin absolute inset-2 rounded-full"
style={{['--lbl' as string]: current.labelColor || FALLBACK_LABEL}}
>
<div className="w4-disc-sheen absolute inset-0 rounded-full" />
<div className="absolute left-1/2 top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-stone-700" />
</div>
{/* 톤암 — 곡이 얹혀 있으니 항상 내려와 있다. */}
<div className="absolute -right-1 top-3 h-2 w-[120px] origin-right rotate-6">
<div className="absolute inset-y-[3px] right-4 left-0 rounded-sm bg-linear-to-b from-stone-300 to-stone-500" />
<div className="absolute left-0 -top-1 h-4 w-4 rounded-sm bg-stone-700" />
<div className="absolute -top-2 right-0 size-6 rounded-full bg-radial from-stone-300 to-stone-600" />
</div>
</div>
{/* 지금 도는 곡 */}
<div className="min-w-0 space-y-3">
<p className="text-[10px] tracking-[0.24em] text-amber-500/90">
A면 · {playing + 1} / {parsed.items.length}
</p>
<h3 className="text-2xl text-stone-50 sm:text-3xl" style={{fontFamily: RETRO_SIGN}}>
{current.title}
</h3>
<p className="text-xs text-stone-400">
{[
current.artist,
current.year ? String(current.year) : undefined,
current.lyricist || current.composer
? `작사 ${current.lyricist ?? '미상'} / 작곡 ${current.composer ?? '미상'}`
: undefined,
current.label,
]
.filter(Boolean)
.join(' · ')}
</p>
{current.story && (
<p
className="max-w-[46ch] text-sm leading-relaxed text-stone-300"
style={{fontFamily: RETRO_BODY}}
>
{current.story}
</p>
)}
{current.connection && (
<p
className="max-w-[46ch] text-sm leading-relaxed"
style={{fontFamily: RETRO_BODY, color: template.colors.accent}}
>
{current.connection}
</p>
)}
<span className="inline-block border border-dashed border-stone-600 px-2 py-1 text-[10px] tracking-[0.1em] text-stone-400">
</span>
<SourceLine source={current.source} verified={current.verified} tone="dark" />
</div>
</div>
{/* 판 고르기 */}
<div className="space-y-2">
<div ref={ref} className="w4-scroll flex snap-x snap-mandatory gap-4 overflow-x-auto pb-2">
{parsed.items.map((song, index) => (
<button
key={`${song.title}-${index}`}
type="button"
onClick={(event) => {
event.stopPropagation();
setPlaying(index);
}}
aria-current={index === playing}
className="w-24 shrink-0 snap-center text-center"
>
<span
className="w4-disc-mini mx-auto block size-[84px] rounded-full transition-transform hover:scale-105"
style={{
['--lbl' as string]: song.labelColor || FALLBACK_LABEL,
boxShadow:
index === playing
? '0 0 0 2px #c9930f, 0 3px 10px rgba(0,0,0,.45)'
: undefined,
}}
/>
<span className="mt-2 block truncate text-[10px] leading-tight text-stone-400">
{song.title}
</span>
</button>
))}
</div>
{parsed.items.length > 3 && (
<CarouselNav
onPrev={() => scrollBy(-1)}
onNext={() => scrollBy(1)}
tone="dark"
label="곡"
/>
)}
</div>
</>
)}
</SectionBody>
</SectionFrame>
);
}