/** * 붙여넣기 아이템의 발행본 공통 조각. * * ★ 색과 서체를 박지 않는다. 전부 템플릿 토큰(`--tpl-*`, `seo/head.ts` 가 심는다)을 읽는다 — * 한때 이 파일이 갱지색·주(朱)잉크·간판체를 hex 와 폰트명으로 들고 있었고, 그 바람에 * 사장님이 템플릿을 바꿔도 아이템 섹션만 레트로로 남았다. 아이템은 레트로 전용 부품이 아니다. * ★ 인상은 빌더 캔버스와 같게 그린다. 다만 **인터랙션은 옮기지 않는다** — * 캔버스의 턴테이블은 '지금 한 곡'만 펴는데 그러면 나머지 곡의 문장이 HTML 에 없다. * 이 사이트의 존재 이유가 AI·검색의 인용이라, 발행본은 전 항목을 펴고 가로로만 민다. */ import {createContext, useContext} from 'react'; import type {ReactNode} from 'react'; import type {DataSource, DataVerified} from '@o2o/shared'; import {Carousel} from '@site/lib/ui'; import {useLayout} from '@site/lib/layout'; export const TabPanelContext = createContext(false); // 안별 제목은 `lib/ui/Section` 과 **같은 파일**을 직접 가리킨다. // `@/sections` 배럴로 돌아가면 이 파일이 그 배럴 안에 있어 순환이다. import {SectionHead as ReservationHead} from '@site/layouts/reservation/SectionHead'; import {SectionHead as OasiHead} from '@site/layouts/oasi/SectionHead'; import {SectionHead as StudioHead} from '@site/layouts/studio/SectionHead'; import {SectionHead as PastelHead} from '@site/layouts/pastel/SectionHead'; import {SectionHead as EditorialHead} from '@site/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) 55%, 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 ( {n} ); } /** * 아이템 섹션 껍데기. * * 바탕은 `--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}` 를 받고 `

` 을 직접 그린다). * ★ 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 inTabPanel = useContext(TabPanelContext); const Head = layout === 'reservation' ? ReservationHead : layout === 'oasi' ? OasiHead : layout === 'studio' ? StudioHead : layout === 'pastel' ? PastelHead : layout === 'editorial' ? EditorialHead : null; // 링크 마크업은 한 벌이다 — 안에 따라 서는 자리만 달라진다(1안은 제목 오른쪽, 나머지는 aside). const linkNode = link ? ( {link.label} → ) : undefined; return (
{Head ? ( ) : (
{/* 제목과 바깥 링크를 한 줄에. 좁은 화면에서는 링크가 아래로 떨어진다. */}

{name}

{linkNode}
{subtitle && (

{subtitle}

)}
)} {children} {count &&

{count}

}
); } /** * 가로로 미는 레일. * * ★ 예전에는 `overflow-x: auto` 상자였고 주석에 "화살표는 두지 않는다 — 손가락·트랙패드로 * 민다"고 적혀 있었다. 그 문장이 틀렸다. **휠 마우스에는 옆으로 미는 방법이 없다** — * 데스크톱 손님은 첫 두세 장만 보고 지나갔다. 이제 드래그·화살표·점·키보드가 다 붙는다. * ★ 슬라이드는 여전히 전부 HTML 에 있다(Carousel 참조). 인용이 이 사이트의 목적이다. */ export function Rail({ children, label, arrows = 'header', onSelect, onReady, }: { children: ReactNode; label: string; /** * 액자(필름·책장) 안에 든 레일은 'overlay' 다 — 화살표를 액자 안에 한 줄로 두면 * 퍼포레이션과 프레임 사이에 버튼 띠가 생겨 필름이 아니라 위젯이 된다. */ arrows?: 'header' | 'overlay'; /** 지금 몇 번째 장인지 알아야 하는 레일에만 준다(추천 일정의 코스 줄). Carousel 주석 참조. */ onSelect?: (index: number) => void; onReady?: (api: {scrollTo: (index: number) => void}) => void; }) { return ( {children} ); } /** * 출처 한 줄. * * ★ '확인 / 확인필요' 배지를 뺐다 (2026-09-03, 사장님 지적) * 검수 상태는 **우리 살림이지 손님에게 할 말이 아니다.** 손님 화면에 '확인필요'가 붙으면 * 그 문장만 못 믿는 게 아니라 사이트 전체가 덜 만든 것으로 읽힌다. * 검수는 에디터(빌더 캔버스)에서 계속 보인다 — 고칠 사람은 거기서 본다. * ★ 출처는 남긴다. 인용될 때 근거를 대는 건 이 사이트의 존재 이유다. */ export function SourceLine({ source, verified, imageCredit, }: { source?: DataSource; verified?: DataVerified; /** * 사진의 출처 표시. **글의 출처와 다른 것**이다 — 사진은 위키미디어(CC BY·BY-SA)나 * 공공누리 제1유형에서 오고, 둘 다 출처 표시가 **라이선스 조건**이다. 생략하면 침해다. * 그래서 글 출처가 없어도 사진 출처만으로 이 줄이 선다. */ imageCredit?: string; }) { // verified 는 계약에 남겨 둔다(에디터·게이트가 읽는다). 화면에만 안 낸다. void verified; if (!source?.name && !imageCredit) return null; return (

{source?.name && ( 출처 ·{' '} {source.url ? ( {source.name} ) : ( source.name )} )} {imageCredit && 사진 · {imageCredit}}

); } /** * "총 N개" 각주. 단위(곡·장·명)는 아이템마다 다르다. * * ★ 뒤에 "· 확인 필요 M" 을 붙였었다 — 손님에게 우리 검수 진도를 알릴 이유가 없다. * 인자는 그대로 받는다(부르는 쪽 열 곳을 한꺼번에 고치지 않으려고). */ export function tally(total: number, unverified: number, unit: string): string { void unverified; return `총 ${total}${unit}`; }