같은 템플릿 안에서도 축제 카드(흰 바탕·글 안쪽)와 명소 카드(바탕 없음·글 아래)가 따로 놀았다. 실측(9종 전수): 제목 굵기 400/500/700/900, 사진 모서리 0/12/18/22px 가 한 화면에 섞여 있었다. - kit/Card.tsx·card.css: 사진 3:2 + 제목 1줄 + 거리 1줄 + 설명 2줄. 템플릿 차이는 --kc-* 변수만 - 9종 Around·Festival, 고택 인물: 사진 카드를 PhotoCard 로 교체. 맛집·홈 목록 행은 그대로 - 템플릿 CSS: 모서리·글꼴·강조색 변수와 그리드 간격. 일러스트는 상자형, 빅타이포는 선 격자 - 모바일 캐러셀 화살표가 카드를 가려 1024px 미만에서 숨김 - docs/TEMPLATE_DESIGN.md: 사진 카드 규칙 tsc·eslint 통과 · vitest 123 passed · 12개 사이트 굽기 · 카드 모양 템플릿당 1종 확인 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
75 lines
2.6 KiB
TypeScript
75 lines
2.6 KiB
TypeScript
import {useState} from 'react';
|
|
import type {FestivalEntry} from '@o2o/shared';
|
|
import {useSite} from '@site/lib/site-context';
|
|
import {naverSearchUrl} from '@site/lib/format';
|
|
import {PhotoCard} from '../kit/Card';
|
|
import {Sheet} from '../paper/Sheet';
|
|
|
|
const SEASONS = ['봄', '여름', '가을', '겨울'];
|
|
|
|
export function Festival() {
|
|
const payload = useSite();
|
|
const festivals = payload.local.festivals;
|
|
const [season, setSeason] = useState('');
|
|
const [open, setOpen] = useState<FestivalEntry | null>(null);
|
|
if (festivals.length === 0) return null;
|
|
const tabs = SEASONS.filter((s) => festivals.some((f) => f.season === s));
|
|
const shown = festivals.filter((f) => !season || f.season === season);
|
|
|
|
return (
|
|
<>
|
|
<section className="wrap" id="festival" aria-labelledby="festival-heading">
|
|
<p className="label">SEASON</p>
|
|
<h2 id="festival-heading">
|
|
계절별 축제 <span className="cnt">{festivals.length}</span>
|
|
</h2>
|
|
<p className="lead">{payload.place.addressLocality ?? '이 지역'}의 축제와 행사를 계절로 묶었습니다.</p>
|
|
{tabs.length > 1 && (
|
|
<div className="chips" role="tablist">
|
|
{['', ...tabs].map((s) => (
|
|
<button
|
|
key={s || 'all'}
|
|
type="button"
|
|
role="tab"
|
|
aria-current={s === season}
|
|
onClick={() => setSeason(s)}
|
|
>
|
|
{s || '전체'} {festivals.filter((f) => !s || f.season === s).length}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="rail">
|
|
{shown.map((f) => (
|
|
<PhotoCard
|
|
key={f.name}
|
|
image={f.imageUrl}
|
|
title={f.name}
|
|
meta={[f.month, f.location].filter(Boolean).join(' · ')}
|
|
onClick={() => setOpen(f)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
<Sheet open={open != null} onClose={() => setOpen(null)} label={open?.name ?? ''}>
|
|
{open && (
|
|
<>
|
|
{open.imageUrl && <img src={open.imageUrl} alt={open.name} />}
|
|
<h3>{open.name}</h3>
|
|
<p className="meta">{[open.period ?? open.month, open.location].filter(Boolean).join(' · ')}</p>
|
|
{open.description && <p>{open.description}</p>}
|
|
<a
|
|
className="out"
|
|
href={open.officialUrl ?? naverSearchUrl(open.searchQuery)}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
자세히 보기
|
|
</a>
|
|
</>
|
|
)}
|
|
</Sheet>
|
|
</>
|
|
);
|
|
}
|