o2o-site-AEO/solution/site/src/sections/items/common.tsx
Mina Choi 64ce467f21 [refactor] postgres-init,solution: DB 구조 재편 — 스키마 해체 · 공용 콘텐츠 한 벌 · 마이그레이션 체계
도메인별 스키마(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>
2026-09-09 17:08:02 +09:00

226 lines
9.1 KiB
TypeScript

/**
* 붙여넣기 아이템의 발행본 공통 조각.
*
* ★ 색과 서체를 박지 않는다. 전부 템플릿 토큰(`--tpl-*`, `seo/head.ts` 가 심는다)을 읽는다 —
* 한때 이 파일이 갱지색·주(朱)잉크·간판체를 hex 와 폰트명으로 들고 있었고, 그 바람에
* 사장님이 템플릿을 바꿔도 아이템 섹션만 레트로로 남았다. 아이템은 레트로 전용 부품이 아니다.
* ★ 인상은 빌더 캔버스와 같게 그린다. 다만 **인터랙션은 옮기지 않는다** —
* 캔버스의 턴테이블은 '지금 한 곡'만 펴는데 그러면 나머지 곡의 문장이 HTML 에 없다.
* 이 사이트의 존재 이유가 AI·검색의 인용이라, 발행본은 전 항목을 펴고 가로로만 민다.
*/
import type {ReactNode} from 'react';
import type {DataSource, DataVerified} from '@o2o/shared';
import {Carousel} from '@/lib/ui';
import {useLayout} from '@/lib/layout';
// 안별 제목은 `lib/ui/Section` 과 **같은 파일**을 직접 가리킨다.
// `@/sections` 배럴로 돌아가면 이 파일이 그 배럴 안에 있어 순환이다.
import {SectionHead as ReservationHead} from '@/layouts/reservation/SectionHead';
import {SectionHead as OasiHead} from '@/layouts/oasi/SectionHead';
import {SectionHead as StudioHead} from '@/layouts/studio/SectionHead';
import {SectionHead as PastelHead} from '@/layouts/pastel/SectionHead';
import {SectionHead as EditorialHead} from '@/layouts/editorial/SectionHead';
import './items.css';
/** 카드·종이 면. 섹션 바탕보다 한 단계 앞이다. */
export const ITEM_CARD = 'var(--tpl-card, #fafafa)';
/** 선·테두리. */
export const ITEM_BORDER = 'var(--tpl-border, #d6d3d1)';
/**
* 강조 — 연표의 전환점, 일력의 날짜, 소인 도장.
*
* ★ 강조색을 그대로 쓰지 않고 둘레 글자색을 섞는다(빌더 캔버스와 같은 규칙). 팔레트에 따라
* accent 가 바탕과 같은 밝기면 큰 숫자와 배지가 화면에서 사라진다.
*/
export const ITEM_ACCENT = 'color-mix(in oklab, var(--tpl-accent, #2563eb) 70%, currentColor)';
/** 어두운 면(필름·플립보드). */
export const ITEM_INVERSE = 'var(--tpl-inverse, #1c1917)';
/** 어두운 면 위의 글자색 — 흰색을 박지 않고 템플릿의 밝은 면 색을 쓴다. */
export const ITEM_INVERSE_INK = 'var(--tpl-bg, #ffffff)';
/** 글자색. */
export const ITEM_INK = 'var(--tpl-text, #09090b)';
/**
* 시간표 칸 번호 — 승차권 카드(추천 일정 · 계절별 추천 하루)가 함께 쓴다.
* **지도 핀과 같은 번호**다. 둘을 잇는 끈이 이 숫자뿐이라 두 화면이 어긋나면 안 된다.
*/
export function StopNo({n}: {n: number}) {
return (
<span
className="mr-1.5 inline-grid size-[17px] translate-y-px place-items-center rounded-full text-[10px] tabular-nums"
style={{backgroundColor: ITEM_ACCENT, color: ITEM_INVERSE_INK}}
>
{n}
</span>
);
}
/**
* 아이템 섹션 껍데기.
*
* 바탕은 `--tpl-surface`(섹션 바탕) 또는 `--tpl-inverse`(어두운 아이템)다.
* 다른 섹션들과 **같은 여백·같은 제목**을 쓴다(index.css 의 --section-space) —
* 아이템만 다른 리듬으로 들어가면 페이지가 두 사이트를 이어 붙인 것처럼 보인다.
*
* ★ 제목은 안(layout)에 맡긴다 (2026-09-04, 사장님이 3안 화면을 보고 지적)
* 여기서 `.h2` 를 직접 그렸더니, 소제목이 대괄호 안 작은 활자인 3안(oasi)에서
* '영상으로 보기'·'추천 일정' 만 큰 명조로 튀었다. 공용 `lib/ui/Section` 과 **같은 방식**으로
* 안별 `SectionHead` 에 넘긴다 — subtitle 은 lead 로, 바깥 링크는 aside 로 간다
* (다섯 안 모두 `{id, title, lead, aside}` 를 받고 `<h2 id="${id}-heading">` 을 직접 그린다).
* ★ 1안(default)만 아래 폴백으로 남긴다. 공용 `DefaultHead` 는 제목 앞에 강조선이 있고
* 여백(mb-6)·lead 들여쓰기가 달라서, 그걸 쓰면 **이미 발행된 1안의 생김새가 바뀐다.**
*/
export function ItemSection({
id,
name,
subtitle,
count,
link,
children,
dark,
}: {
id: string;
name: string;
subtitle?: string;
/** 섹션 머리에 거는 바깥 링크 — 이 섹션을 만든 서비스로 보낸다. 없으면 안 그린다. */
link?: {url: string; label: string};
/** 각주 한 줄("총 8곡 · 확인 필요 2"). 없으면 각주를 그리지 않는다. */
count?: string;
children: ReactNode;
dark?: boolean;
}) {
const layout = useLayout();
const Head =
layout === 'reservation'
? ReservationHead
: layout === 'oasi'
? OasiHead
: layout === 'studio'
? StudioHead
: layout === 'pastel'
? PastelHead
: layout === 'editorial'
? EditorialHead
: null;
// 링크 마크업은 한 벌이다 — 안에 따라 서는 자리만 달라진다(1안은 제목 오른쪽, 나머지는 aside).
const linkNode = link ? (
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-[length:var(--fs-sm)] font-semibold underline-offset-4 opacity-75 transition-opacity hover:opacity-100 hover:underline"
>
{link.label} →
</a>
) : undefined;
return (
<section
id={id}
aria-labelledby={`${id}-heading`}
className="border-line paper w-full border-b"
style={{
backgroundColor: dark ? ITEM_INVERSE : 'var(--tpl-surface, #fafafa)',
color: dark ? ITEM_INVERSE_INK : ITEM_INK,
paddingBlock: 'var(--section-space)',
}}
>
<div className="shell">
{Head ? (
<Head id={id} title={name} lead={subtitle} aside={linkNode} />
) : (
<header className="mb-8 sm:mb-10">
{/* 제목과 바깥 링크를 한 줄에. 좁은 화면에서는 링크가 아래로 떨어진다. */}
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2">
<h2 id={`${id}-heading`} className="h2">
{name}
</h2>
{linkNode}
</div>
{subtitle && (
<p className="measure mt-3 text-[length:var(--fs-sm)] opacity-70">{subtitle}</p>
)}
</header>
)}
{children}
{count && <p className="mt-6 text-[length:var(--fs-xs)] opacity-60">{count}</p>}
</div>
</section>
);
}
/**
* 가로로 미는 레일.
*
* ★ 예전에는 `overflow-x: auto` 상자였고 주석에 "화살표는 두지 않는다 — 손가락·트랙패드로
* 민다"고 적혀 있었다. 그 문장이 틀렸다. **휠 마우스에는 옆으로 미는 방법이 없다** —
* 데스크톱 손님은 첫 두세 장만 보고 지나갔다. 이제 드래그·화살표·점·키보드가 다 붙는다.
* ★ 슬라이드는 여전히 전부 HTML 에 있다(Carousel 참조). 인용이 이 사이트의 목적이다.
*/
export function Rail({
children,
label,
arrows = 'header',
}: {
children: ReactNode;
label: string;
/**
* 액자(필름·책장) 안에 든 레일은 'overlay' 다 — 화살표를 액자 안에 한 줄로 두면
* 퍼포레이션과 프레임 사이에 버튼 띠가 생겨 필름이 아니라 위젯이 된다.
*/
arrows?: 'header' | 'overlay';
}) {
return (
<Carousel label={label} gap={1} arrows={arrows}>
{children}
</Carousel>
);
}
/**
* 출처 한 줄.
*
* ★ '확인 / 확인필요' 배지를 뺐다 (2026-09-03, 사장님 지적)
* 검수 상태는 **우리 살림이지 손님에게 할 말이 아니다.** 손님 화면에 '확인필요'가 붙으면
* 그 문장만 못 믿는 게 아니라 사이트 전체가 덜 만든 것으로 읽힌다.
* 검수는 에디터(빌더 캔버스)에서 계속 보인다 — 고칠 사람은 거기서 본다.
* ★ 출처는 남긴다. 인용될 때 근거를 대는 건 이 사이트의 존재 이유다.
*/
export function SourceLine({source, verified}: {source?: DataSource; verified?: DataVerified}) {
// verified 는 계약에 남겨 둔다(에디터·게이트가 읽는다). 화면에만 안 낸다.
void verified;
if (!source?.name) return null;
return (
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px] opacity-70">
{source?.name && (
<span>
출처 ·{' '}
{source.url ? (
<a
href={source.url}
target="_blank"
rel="noopener noreferrer nofollow"
className="underline underline-offset-2"
>
{source.name}
</a>
) : (
source.name
)}
</span>
)}
</p>
);
}
/**
* "총 N개" 각주. 단위(곡·장·명)는 아이템마다 다르다.
*
* ★ 뒤에 "· 확인 필요 M" 을 붙였었다 — 손님에게 우리 검수 진도를 알릴 이유가 없다.
* 인자는 그대로 받는다(부르는 쪽 열 곳을 한꺼번에 고치지 않으려고).
*/
export function tally(total: number, unverified: number, unit: string): string {
void unverified;
return `총 ${total}${unit}`;
}