도메인별 스키마(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>
132 lines
4.5 KiB
TypeScript
132 lines
4.5 KiB
TypeScript
/**
|
|
* 시간의 골목 — 가로 연표.
|
|
*
|
|
* 연도가 큰 활자로 서고 사건이 그 아래 붙는다. 레일 위의 **붉은 점은 도시의 성격이 바뀐 해**다 —
|
|
* 점의 색이 장식이 아니라 정보라서, 범례를 한 줄 달아 그 약속을 밝힌다.
|
|
* ★ 연도가 없는 항목은 지어내 끼우지 않고 레일 끝으로 민다. 연표에서 틀린 순서는 바로 들킨다.
|
|
*/
|
|
import {useMemo} from 'react';
|
|
import {Rail, SectionBody, SectionFrame} from '../../primitives';
|
|
import type {SectionRenderProps} from '../../types';
|
|
import {parseSectionData, type ChronicleItem} from '@o2o/shared';
|
|
import {
|
|
ITEM_ACCENT,
|
|
ITEM_BODY,
|
|
ITEM_BORDER,
|
|
ITEM_CARD,
|
|
ITEM_HEADING,
|
|
ITEM_INK,
|
|
ParseError,
|
|
PasteHint,
|
|
SourceLine,
|
|
} from '../items/common';
|
|
import '../items/items.css';
|
|
|
|
function Milestone({item, isLast}: {item: ChronicleItem; isLast: boolean}) {
|
|
const turning = item.turning === true;
|
|
|
|
return (
|
|
<div className="w-[228px] shrink-0 snap-center">
|
|
<p
|
|
className="leading-none"
|
|
style={{fontFamily: ITEM_HEADING, fontSize: 34, color: turning ? ITEM_ACCENT : ITEM_INK}}
|
|
>
|
|
{item.year ?? '연도 미상'}
|
|
</p>
|
|
|
|
{/* 레일 — 점이 선 위에 놓여야 '흐르는 시간 위의 한 해'로 읽힌다 */}
|
|
<div className="relative my-3 h-3">
|
|
<i
|
|
aria-hidden
|
|
className="absolute top-1/2 left-0 border-t"
|
|
// 마지막 해에서 선이 끊긴다 — 레일이 화면 밖으로 이어지는 것처럼 보이면 뒤가 더 있는 줄 안다.
|
|
style={{borderColor: ITEM_BORDER, right: isLast ? 'calc(100% - 12px)' : 0}}
|
|
/>
|
|
<i
|
|
aria-hidden
|
|
className="absolute top-1/2 left-0 size-3 -translate-y-1/2 rounded-full border-2"
|
|
style={{
|
|
backgroundColor: turning ? ITEM_ACCENT : ITEM_CARD,
|
|
borderColor: turning ? ITEM_ACCENT : ITEM_BORDER,
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1.5 pr-5">
|
|
<h4 className="text-base font-bold" style={{fontFamily: ITEM_BODY}}>
|
|
{item.title}
|
|
</h4>
|
|
{item.summary && (
|
|
<p className="text-[13px] leading-relaxed opacity-75" style={{fontFamily: ITEM_BODY}}>
|
|
{item.summary}
|
|
</p>
|
|
)}
|
|
{item.place && <p className="text-[11px] opacity-60">지금 이 자리 · {item.place}</p>}
|
|
<SourceLine source={item.source} verified={item.verified} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ChronicleRail(props: SectionRenderProps) {
|
|
const {section, isSelected, onSelect} = props;
|
|
const parsed = parseSectionData<ChronicleItem>(section.type, section.data);
|
|
|
|
// 연도가 있는 것부터 오름차순, 없는 것은 뒤로. 붙여넣은 순서를 믿지 않는다.
|
|
const items = useMemo(
|
|
() =>
|
|
[...parsed.items].sort((a, b) => {
|
|
if (a.year == null) return b.year == null ? 0 : 1;
|
|
if (b.year == null) return -1;
|
|
return a.year - b.year;
|
|
}),
|
|
[parsed.items],
|
|
);
|
|
|
|
const turningCount = items.filter((item) => item.turning === true).length;
|
|
|
|
return (
|
|
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
|
|
<SectionBody width="wide">
|
|
<div className="space-y-1.5">
|
|
<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-75" style={{fontFamily: ITEM_BODY}}>
|
|
{parsed.subtitle || section.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{parsed.error ? (
|
|
<ParseError message={parsed.error} />
|
|
) : items.length === 0 ? (
|
|
<PasteHint label="시간의 골목" />
|
|
) : (
|
|
<div className="space-y-3">
|
|
<p className="flex items-center gap-1.5 text-[11px] opacity-60">
|
|
<i
|
|
aria-hidden
|
|
className="size-2.5 rounded-full"
|
|
style={{backgroundColor: ITEM_ACCENT}}
|
|
/>
|
|
도시의 성격이 바뀐 해 {turningCount}개 · 전체 {items.length}개
|
|
</p>
|
|
|
|
<Rail label="연표" gap={0}>
|
|
{items.map((item, index) => (
|
|
<Milestone
|
|
key={`${item.year ?? 'x'}-${item.title}-${index}`}
|
|
item={item}
|
|
isLast={index === items.length - 1}
|
|
/>
|
|
))}
|
|
</Rail>
|
|
</div>
|
|
)}
|
|
</SectionBody>
|
|
</SectionFrame>
|
|
);
|
|
}
|