도메인별 스키마(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>
134 lines
4.8 KiB
TypeScript
134 lines
4.8 KiB
TypeScript
/**
|
|
* 오늘의 엽서 — 엽서 뒷면 더미.
|
|
*
|
|
* 앞면(사진)은 없다. 쓸 수 있는 사진이 없기도 하고, 이 아이템의 목적이 **손님이 자기 계정에
|
|
* 붙여넣는 것**이라 글이 놓이는 뒷면이 전부다. 우표 자리·소인·가운데 괘선이 그 형식을 만든다.
|
|
*/
|
|
import {useState} from 'react';
|
|
import {Check, Copy} from 'lucide-react';
|
|
import {Rail, SectionBody, SectionFrame} from '../../primitives';
|
|
import type {SectionRenderProps} from '../../types';
|
|
import {parseSectionData, type PostcardItem} 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 Postcard({item}: {item: PostcardItem}) {
|
|
const [copied, setCopied] = useState(false);
|
|
const text = [item.line, (item.hashtags ?? []).join(' ')].filter(Boolean).join(' ');
|
|
|
|
const copy = (event: React.MouseEvent) => {
|
|
event.stopPropagation();
|
|
navigator.clipboard.writeText(text).then(
|
|
() => {
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 1600);
|
|
},
|
|
() => {
|
|
// 클립보드가 막힌 브라우저 — 직접 긁어 갈 수 있게 띄운다.
|
|
window.prompt('아래 문장을 복사하세요', text);
|
|
},
|
|
);
|
|
};
|
|
|
|
return (
|
|
<article
|
|
className="w4-paper w-[304px] shrink-0 snap-center border p-4 shadow-[4px_4px_0_rgba(27,26,21,.13)]"
|
|
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_INK}}
|
|
>
|
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3">
|
|
{/* 가운데 괘선 — 엽서 뒷면을 반으로 가르는 그 선 */}
|
|
<div className="min-w-0 border-r pr-3" style={{borderColor: ITEM_BORDER}}>
|
|
<p className="text-[19px] leading-snug" style={{fontFamily: ITEM_BODY}}>
|
|
“{item.line}”
|
|
</p>
|
|
{item.hashtags && item.hashtags.length > 0 && (
|
|
<p className="mt-2 text-[11px] opacity-60">{item.hashtags.join(' ')}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex w-[62px] flex-col items-center gap-3">
|
|
<span
|
|
className="grid h-[56px] w-[44px] place-items-center border border-dashed text-center text-[9px] leading-tight opacity-60"
|
|
style={{borderColor: ITEM_BORDER}}
|
|
>
|
|
郵票
|
|
<br />
|
|
10원
|
|
</span>
|
|
<span
|
|
className="grid size-[54px] -rotate-6 place-items-center rounded-full border-2 px-1 text-center text-[9px] leading-tight"
|
|
style={{fontFamily: ITEM_HEADING, borderColor: ITEM_ACCENT, color: ITEM_ACCENT}}
|
|
>
|
|
{item.postmark || item.place || '소인'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className="mt-3 flex items-center justify-between gap-2 border-t border-dashed pt-2.5"
|
|
style={{borderColor: ITEM_BORDER}}
|
|
>
|
|
<SourceLine source={item.source} verified={item.verified} />
|
|
<button
|
|
type="button"
|
|
onClick={copy}
|
|
className="flex shrink-0 items-center gap-1 border px-2 py-1 text-[10px] opacity-75 transition-opacity hover:opacity-100"
|
|
style={{borderColor: ITEM_BORDER}}
|
|
>
|
|
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
|
{copied ? '복사됨' : '복사'}
|
|
</button>
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
export function PostcardStack(props: SectionRenderProps) {
|
|
const {section, isSelected, onSelect} = props;
|
|
const parsed = parseSectionData<PostcardItem>(section.type, section.data);
|
|
|
|
return (
|
|
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="tint">
|
|
<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} />
|
|
) : parsed.items.length === 0 ? (
|
|
<PasteHint label="오늘의 엽서" />
|
|
) : (
|
|
<div className="space-y-3">
|
|
<Rail label="엽서">
|
|
{parsed.items.map((item, index) => (
|
|
<Postcard key={`${item.line}-${index}`} item={item} />
|
|
))}
|
|
</Rail>
|
|
<p className="text-[11px] opacity-60">
|
|
[복사] 를 누르면 문장과 해시태그가 함께 복사됩니다 · 총 {parsed.items.length}장
|
|
</p>
|
|
</div>
|
|
)}
|
|
</SectionBody>
|
|
</SectionFrame>
|
|
);
|
|
}
|