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건 확인
161 lines
6.9 KiB
TypeScript
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>
|
|
);
|
|
}
|