도메인별 스키마(company·place·fact·local·site·job)를 걷어내고 public 한 벌로 폈다.
스키마 한정자가 붙은 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다.
- 공용 콘텐츠를 한 테이블로 되돌린다. spots·region_stories 를 따로 파 놓고 보니
같은 성격이 세 곳으로 갈라져 있었다 — `area_contents` 가 처음부터 content_type 으로
종류를 가르는 설계였고 그걸 쓰면 됐다. 관계(거리·숨김)만 `place_area_refs` 로 남긴다.
- migrations/ + scripts/migrate.py: `init.sql` 은 **DB 를 처음 만들 때만** 돈다. 파일에
컬럼을 더해도 이미 데이터가 든 DB 에는 반영되지 않는다 — 실제로 TourAPI 가 주변 정보를
받아 와도 저장할 곳이 없어 축제·맛집이 0건이었고, 화면에는 "그냥 안 나오는 것" 으로만 보였다.
DECISIONS.md 가 예고한 그대로다("운영 DB 가 생기는 순간 다시 필요해진다").
Alembic 을 쓰지 않는 이유는 스키마 정의가 이미 두 곳(ORM·init.sql)이라 세 번째를
더하면 어긋날 자리가 하나 더 생기기 때문이다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
181 lines
8.0 KiB
TypeScript
181 lines
8.0 KiB
TypeScript
/**
|
|
* 가요 다방 — 턴테이블 하나 + 도넛판 캐러셀.
|
|
*
|
|
* 판을 누르면 톤암이 내려오고 판이 돈다. 가사를 못 싣는 제약(DB_Guide: 원문 전재 금지)을
|
|
* 숨기지 않고 라벨 아래 각인으로 드러낸다 — 제약이 곧 이 아이템의 인상이다.
|
|
*/
|
|
import {useState} from 'react';
|
|
import {Rail, SectionBody, SectionFrame} from '../../primitives';
|
|
import type {SectionRenderProps} from '../../types';
|
|
import {parseSectionData, type SongItem} from '@o2o/shared';
|
|
import {
|
|
ITEM_ACCENT,
|
|
ITEM_BODY,
|
|
ITEM_HEADING,
|
|
ITEM_INVERSE,
|
|
ParseError,
|
|
PasteHint,
|
|
SourceLine,
|
|
} from '../items/common';
|
|
import '../items/items.css';
|
|
|
|
/** 곡이 라벨 색을 안 주면 템플릿 강조색이 라벨이 된다 — hex 를 박으면 팔레트를 바꿔도 판만 남는다. */
|
|
const FALLBACK_LABEL = ITEM_ACCENT;
|
|
|
|
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 current = parsed.items[playing] ?? parsed.items[0];
|
|
|
|
return (
|
|
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="dark">
|
|
{/* ★ 어두운 면 위의 글자색을 여기서 한 번만 정한다. 아래는 전부 currentColor·opacity 로 단을 만든다 —
|
|
자식마다 색을 박으면 템플릿을 바꿨을 때 한두 곳이 옛 색으로 남는다. */}
|
|
<SectionBody width="wide" className="text-[color:var(--tpl-bg,#fff)]">
|
|
<div className="space-y-1.5">
|
|
<p className="text-[11px] tracking-[0.28em]" style={{color: ITEM_ACCENT}}>
|
|
33⅓ RPM
|
|
</p>
|
|
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
|
|
{parsed.title || section.name}
|
|
</h2>
|
|
{(parsed.subtitle || section.description) && (
|
|
<p className="text-sm opacity-55" style={{fontFamily: ITEM_BODY}}>
|
|
{parsed.subtitle || section.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{parsed.error ? (
|
|
<ParseError message={parsed.error} />
|
|
) : !current ? (
|
|
<PasteHint label="가요 다방" />
|
|
) : (
|
|
<>
|
|
<div className="grid items-center gap-8 border p-6 sm:p-8 md:grid-cols-[260px_minmax(0,1fr)]"
|
|
style={{
|
|
backgroundColor: ITEM_INVERSE,
|
|
borderColor: 'color-mix(in oklab, currentColor 22%, transparent)',
|
|
}}>
|
|
{/* 턴테이블 */}
|
|
<div className="relative mx-auto size-[240px]">
|
|
{/* 플래터 — 판 아래 깔리는 원반. 어두운 면 위에 글자색을 옅게 얹어 단을 만든다. */}
|
|
<div
|
|
className="absolute inset-0 rounded-full shadow-[0_10px_26px_rgba(0,0,0,.45)]"
|
|
style={{backgroundColor: 'color-mix(in oklab, currentColor 16%, transparent)'}}
|
|
/>
|
|
<div
|
|
className="w4-disc w4-spin absolute inset-2 rounded-full"
|
|
style={{
|
|
['--lbl' as string]: current.labelColor || FALLBACK_LABEL,
|
|
['--w4-vinyl' as string]: ITEM_INVERSE,
|
|
}}
|
|
>
|
|
<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"
|
|
style={{backgroundColor: 'color-mix(in oklab, currentColor 30%, transparent)'}}
|
|
/>
|
|
</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"
|
|
style={{backgroundColor: 'color-mix(in oklab, currentColor 65%, transparent)'}}
|
|
/>
|
|
<div
|
|
className="absolute left-0 -top-1 h-4 w-4 rounded-sm"
|
|
style={{backgroundColor: 'color-mix(in oklab, currentColor 40%, transparent)'}}
|
|
/>
|
|
<div
|
|
className="absolute -top-2 right-0 size-6 rounded-full"
|
|
style={{backgroundColor: 'color-mix(in oklab, currentColor 55%, transparent)'}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 지금 도는 곡 */}
|
|
<div className="min-w-0 space-y-3">
|
|
<p className="text-[10px] tracking-[0.24em]" style={{color: ITEM_ACCENT}}>
|
|
A면 · {playing + 1} / {parsed.items.length}
|
|
</p>
|
|
<h3 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
|
|
{current.title}
|
|
</h3>
|
|
<p className="text-xs opacity-55">
|
|
{[
|
|
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 opacity-80"
|
|
style={{fontFamily: ITEM_BODY}}
|
|
>
|
|
{current.story}
|
|
</p>
|
|
)}
|
|
{current.connection && (
|
|
<p
|
|
className="max-w-[46ch] text-sm leading-relaxed"
|
|
style={{fontFamily: ITEM_BODY, color: template.colors.accent}}
|
|
>
|
|
{current.connection}
|
|
</p>
|
|
)}
|
|
<span className="inline-block border border-dashed px-2 py-1 text-[10px] tracking-[0.1em] opacity-60"
|
|
style={{borderColor: 'color-mix(in oklab, currentColor 35%, transparent)'}}>
|
|
◎ 가사 대신 이야기 — 원문은 싣지 않습니다
|
|
</span>
|
|
<SourceLine source={current.source} verified={current.verified} tone="dark" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* 판 고르기 */}
|
|
<Rail label="곡" tone="dark">
|
|
{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,
|
|
['--w4-vinyl' as string]: ITEM_INVERSE,
|
|
// 고른 판만 강조색 링 — 금색을 박으면 팔레트를 바꿔도 이 링만 남는다.
|
|
boxShadow:
|
|
index === playing
|
|
? `0 0 0 2px ${ITEM_ACCENT}, 0 3px 10px rgba(0,0,0,.4)`
|
|
: undefined,
|
|
}}
|
|
/>
|
|
<span className="mt-2 block truncate text-[10px] leading-tight opacity-55">
|
|
{song.title}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</Rail>
|
|
</>
|
|
)}
|
|
</SectionBody>
|
|
</SectionFrame>
|
|
);
|
|
}
|