모달 onClose 가 inline 함수라 부모 리렌더마다 useEffect 가 다시 걸려 scrollY 를 0으로 덮어써 X 버튼으로 닫으면 페이지가 맨 위로 튀었다. - lib/ui/Modal.tsx: onClose 를 ref 로 들고 [open] 에만 의존하도록 수정 (BlogSection·ReviewSection 등 Modal 쓰는 7곳 전부 적용) - sections/EssentialInfoSection.tsx: 체크인·체크아웃 행을 한 줄로 병합 - sections/GallerySection.tsx, UnitsSection.tsx, MobileTabBar.tsx, SiteFooter.tsx, items/*: 표시 폭·간격·라벨 정리 - lib/ui/Carousel.tsx: loop 이음매 간격 재점검 - scripts/prerender.ts: countUniqueContent 가 socialPosts(자체 출력)를 세지 않도록 — 콘텐츠 0건 사이트가 게이트를 우회하던 경로 tsc 통과, vitest 100/102 passed (use-live-weather 실패 2건은 기존·무관) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
249 lines
10 KiB
TypeScript
249 lines
10 KiB
TypeScript
/**
|
|
* 붙여넣기 아이템의 발행본 공통 조각.
|
|
*
|
|
* ★ 색과 서체를 박지 않는다. 전부 템플릿 토큰(`--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 (
|
|
<span
|
|
className="mr-1.5 inline-grid size-[22px] shrink-0 place-items-center rounded-full text-[length:var(--fs-xs)] leading-none 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 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 ? (
|
|
<a
|
|
href={link.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-[length:var(--fs-sm)] font-semibold underline-offset-4 opacity-100 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={inTabPanel ? '' : '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={inTabPanel ? 'sr-only' : 'h2'}>
|
|
{name}
|
|
</h2>
|
|
{linkNode}
|
|
</div>
|
|
{subtitle && (
|
|
<p className="measure mt-3 text-[length:var(--fs-sm)] opacity-100">{subtitle}</p>
|
|
)}
|
|
</header>
|
|
)}
|
|
{children}
|
|
{count && <p className="mt-6 text-[length:var(--fs-xs)] opacity-100">{count}</p>}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 가로로 미는 레일.
|
|
*
|
|
* ★ 예전에는 `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 (
|
|
<Carousel label={label} gap={1} arrows={arrows} onSelect={onSelect} onReady={onReady}>
|
|
{children}
|
|
</Carousel>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 출처 한 줄.
|
|
*
|
|
* ★ '확인 / 확인필요' 배지를 뺐다 (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 (
|
|
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[length:var(--fs-xs)] opacity-100">
|
|
{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>
|
|
)}
|
|
{imageCredit && <span>사진 · {imageCredit}</span>}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* "총 N개" 각주. 단위(곡·장·명)는 아이템마다 다르다.
|
|
*
|
|
* ★ 뒤에 "· 확인 필요 M" 을 붙였었다 — 손님에게 우리 검수 진도를 알릴 이유가 없다.
|
|
* 인자는 그대로 받는다(부르는 쪽 열 곳을 한꺼번에 고치지 않으려고).
|
|
*/
|
|
export function tally(total: number, unverified: number, unit: string): string {
|
|
void unverified;
|
|
return `총 ${total}${unit}`;
|
|
}
|