import {useEffect, useState, type ReactNode} from 'react'; import {useSite} from '@site/lib/site-context'; import {bookingActionLabel, bookingLinks, channelLabel, primaryChannelLink} from '@site/lib/derive'; import {formatKoreanDate, isoDate} from '@site/lib/format'; // 배럴(@/sections)이 아니라 파일을 직접 가리킨다 — 배럴을 타면 섹션 전부가 딸려 온다. import {MobileTabBar} from '@site/sections/MobileTabBar'; import {GRAIN, useSectionIndex, type IndexEntry} from './SectionHead'; /** * 편집(잡지)형 껍데기. * * ★ 왼쪽 세로 차례가 이 안의 얼굴이다. 잡지의 등(spine)처럼 화면 왼쪽에 붙어 따라오고, * 스크롤에 따라 지금 읽는 항목만 진해진다. **데스크톱에서만** 세운다 — 좁은 화면에서 * 목차는 자리만 먹고, 그 일은 이미 상단 제호 줄과 하단 고정 바가 한다. * ★ 차례를 fixed 로 띄우지 않고 flex 한 칸으로 세웠다. fixed 로 얹으면 어두운 판이 * 밑으로 지나갈 때 목차 글자가 통째로 사라진다 — 칸으로 두면 항상 제 종이 위에 있다. * ★ 제호 줄에 전화·예약을 둔다. 국내 숙박 사이트에서 상단이 답해야 하는 건 그 둘뿐이다. */ export function Shell({children}: {children: ReactNode}) { const payload = useSite(); const {place, site} = payload; const index = useSectionIndex(); const active = useActiveSection(index); const booking = bookingLinks(payload)[0]; // ★ 확정 채널을 전부 늘어놓지 않는다 — 예약 우선으로 딱 하나만 낸다(seo/jsonld.ts 주석 참고). const link = primaryChannelLink(payload); const address = place.roadAddress ?? place.address; return (
{/* 제호(masthead) — 얇은 한 줄, 아래는 굵은 실선. 잡지의 표제부다. */}
{place.name}
{place.phone && ( {place.phone} )} {booking && ( {bookingActionLabel(booking)} )}
{index.length > 0 && ( )}
{children}
{/* 판권장(colophon) — 잡지 맨 뒷장. 상호·주소·연락처·법정 표기가 한자리에 남는다. */}
); } /** * 지금 읽고 있는 섹션. * * ★ 화면 한가운데 얇은 띠(위 45% · 아래 50% 를 잘라낸 나머지)를 지나는 판만 * '읽는 자리'로 본다. 띠를 안 좁히면 긴 섹션 둘이 동시에 걸려 목차가 깜빡인다. * ★ 서버 렌더에서는 아무것도 진해지지 않는다(useEffect 는 브라우저에서만 돈다) — * 초기 상태가 양쪽 다 같아서 하이드레이션이 어긋나지 않는다. */ function useActiveSection(entries: IndexEntry[]): string { // 배열은 렌더마다 새로 만들어진다 — 문자열로 굳혀야 effect 가 매 렌더 다시 돌지 않는다. const anchors = entries.map((entry) => entry.anchor).join(','); const [active, setActive] = useState(''); useEffect(() => { if (typeof IntersectionObserver === 'undefined') return; const targets = anchors .split(',') .map((id) => document.getElementById(id)) .filter((el): el is HTMLElement => el !== null); if (targets.length === 0) return; const observer = new IntersectionObserver( (records) => { const hit = records.find((record) => record.isIntersecting); if (hit) setActive(hit.target.id); }, {rootMargin: '-45% 0px -50% 0px'}, ); targets.forEach((el) => observer.observe(el)); return () => observer.disconnect(); }, [anchors]); return active; }