같은 템플릿 안에서도 축제 카드(흰 바탕·글 안쪽)와 명소 카드(바탕 없음·글 아래)가 따로 놀았다. 실측(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>
226 lines
7.6 KiB
TypeScript
226 lines
7.6 KiB
TypeScript
import {useState, type ReactNode} from 'react';
|
|
import type {ChronicleItem, PeopleItem, ReadingItem, SongItem} from '@o2o/shared';
|
|
import {useSite} from '@site/lib/site-context';
|
|
import {sectionItems, sectionName} from '@site/lib/derive';
|
|
import {PhotoCard} from '../kit/Card';
|
|
import {Sheet} from './Sheet';
|
|
|
|
const COUNT_WORD: Record<number, string> = {1: '한', 2: '두', 3: '세', 4: '네'};
|
|
|
|
function More({rest, unit, children}: {rest: number; unit: string; children: ReactNode}) {
|
|
if (rest <= 0) return null;
|
|
return (
|
|
<details>
|
|
<summary className="more" style={{display: 'flex', alignItems: 'center', justifyContent: 'center'}}>
|
|
{rest}
|
|
{unit} 더 보기
|
|
</summary>
|
|
{children}
|
|
</details>
|
|
);
|
|
}
|
|
|
|
interface Open {
|
|
title: string;
|
|
meta?: string;
|
|
body?: string;
|
|
image?: string;
|
|
}
|
|
|
|
export function Story() {
|
|
const payload = useSite();
|
|
const region = payload.place.addressLocality?.replace(/시$|군$/, '') ?? '지역';
|
|
const songs = sectionItems<SongItem>(payload, 'songs');
|
|
const people = sectionItems<PeopleItem>(payload, 'people');
|
|
const chronicle = sectionItems<ChronicleItem>(payload, 'chronicle');
|
|
const reading = sectionItems<ReadingItem>(payload, 'reading');
|
|
const [open, setOpen] = useState<Open | null>(null);
|
|
const parts = [
|
|
songs.items.length > 0 && '노래',
|
|
people.items.length > 0 && '인물',
|
|
chronicle.items.length > 0 && '연표',
|
|
reading.items.length > 0 && '읽기',
|
|
].filter(Boolean) as string[];
|
|
if (parts.length === 0) return null;
|
|
|
|
const peopleSorted = [...people.items].sort((a, b) => Number(Boolean(b.imageUrl)) - Number(Boolean(a.imageUrl)));
|
|
const withPhoto = people.items.filter((p) => p.imageUrl).length;
|
|
|
|
const song = (item: SongItem) => (
|
|
<button
|
|
key={item.title + item.artist}
|
|
type="button"
|
|
className="itemrow"
|
|
onClick={() =>
|
|
setOpen({title: item.title, meta: [item.artist, item.year].filter(Boolean).join(' · '), body: item.story})
|
|
}
|
|
>
|
|
<span>
|
|
{item.title} {item.artist && <span className="dim">{item.artist}</span>}
|
|
</span>
|
|
{item.year && <span className="r">{item.year}</span>}
|
|
</button>
|
|
);
|
|
const person = (item: PeopleItem) => {
|
|
const show = () =>
|
|
setOpen({
|
|
title: item.name,
|
|
meta: [item.role, item.years].filter(Boolean).join(' · '),
|
|
body: item.oneLine,
|
|
image: item.imageUrl,
|
|
});
|
|
if (item.imageUrl)
|
|
return (
|
|
<PhotoCard
|
|
key={item.name}
|
|
image={item.imageUrl}
|
|
title={item.name}
|
|
meta={item.role}
|
|
desc={item.oneLine}
|
|
ratio="1 / 1"
|
|
onClick={show}
|
|
/>
|
|
);
|
|
return (
|
|
<button key={item.name} type="button" className="itemcard" onClick={show}>
|
|
<p className="cap">
|
|
<b>{item.name}</b> {item.role && <span className="dim">{item.role}</span>}
|
|
</p>
|
|
{item.oneLine && <p className="cap dim clamp">{item.oneLine}</p>}
|
|
</button>
|
|
);
|
|
};
|
|
const event = (item: ChronicleItem) => (
|
|
<button
|
|
key={`${item.year}-${item.title}`}
|
|
type="button"
|
|
className="itemrow"
|
|
onClick={() =>
|
|
setOpen({
|
|
title: item.title,
|
|
meta: [item.year, item.place].filter(Boolean).join(' · '),
|
|
body: item.summary,
|
|
image: item.imageUrl,
|
|
})
|
|
}
|
|
>
|
|
<span>
|
|
{item.title}
|
|
{item.place && (
|
|
<span className="dim" style={{display: 'block'}}>
|
|
{item.place}
|
|
</span>
|
|
)}
|
|
</span>
|
|
{item.year && <span className="r">{item.year}</span>}
|
|
</button>
|
|
);
|
|
const book = (item: ReadingItem) => (
|
|
<button
|
|
key={item.title}
|
|
type="button"
|
|
className="itemcard"
|
|
onClick={() => setOpen({title: item.title, meta: item.year ? String(item.year) : item.group, body: item.body})}
|
|
style={{padding: '16px 18px', background: 'var(--soft)', marginBottom: 10}}
|
|
>
|
|
<p style={{margin: '0 0 6px', fontSize: '15.5px'}}>
|
|
<b className="mj">{item.title}</b> {item.year && <span className="dim">{item.year}</span>}
|
|
</p>
|
|
<p className="clamp" style={{margin: 0, fontSize: '16.5px', lineHeight: 1.85, color: '#4a453a'}}>
|
|
{item.body}
|
|
</p>
|
|
</button>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<section className="sec" id="story" aria-labelledby="story-heading">
|
|
<p className="eyebrow">STORY</p>
|
|
<h2 id="story-heading" style={{marginBottom: 0}}>
|
|
{sectionName(payload, 'story', `${region} 이야기`)} <span>{parts.join(' · ')}</span>
|
|
</h2>
|
|
<p className="note" style={{margin: '10px 0 0'}}>
|
|
이 도시를 {COUNT_WORD[parts.length] ?? parts.length} 갈래로 봅니다. 하나씩 골라 보세요.
|
|
</p>
|
|
</section>
|
|
|
|
{songs.items.length > 0 && (
|
|
<section className="sec" style={{paddingTop: 18}} id="songs">
|
|
<p className="eyebrow">MUSIC</p>
|
|
<h2>
|
|
{songs.title || '가요 다방'} <span>{songs.items.length}곡</span>
|
|
</h2>
|
|
<p className="note" style={{margin: '-8px 0 14px'}}>
|
|
{region}을 노래한 곡들입니다.
|
|
</p>
|
|
{songs.items.slice(0, 6).map(song)}
|
|
<More rest={songs.items.length - 6} unit="곡">
|
|
{songs.items.slice(6).map(song)}
|
|
</More>
|
|
</section>
|
|
)}
|
|
|
|
{people.items.length > 0 && (
|
|
<section className="sec" id="people">
|
|
<p className="eyebrow">PEOPLE</p>
|
|
<h2>
|
|
{people.title || '인물 열전'} <span>{people.items.length}명</span>
|
|
</h2>
|
|
<p className="note" style={{margin: '-8px 0 14px'}}>
|
|
{region}과 얽힌 사람들입니다.
|
|
</p>
|
|
<div className="grid2 grid3">{peopleSorted.slice(0, 4).map(person)}</div>
|
|
<More rest={people.items.length - 4} unit="명">
|
|
<div className="grid2 grid3" style={{marginTop: 4}}>
|
|
{peopleSorted.slice(4).map(person)}
|
|
</div>
|
|
</More>
|
|
{withPhoto > 0 && withPhoto < people.items.length && (
|
|
<p className="note" style={{margin: '14px 0 0'}}>
|
|
사진이 있는 {withPhoto}명을 먼저 뒀습니다.
|
|
</p>
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
{chronicle.items.length > 0 && (
|
|
<section className="sec" id="chronicle">
|
|
<p className="eyebrow">CHRONICLE</p>
|
|
<h2>{chronicle.title || '시간의 골목'}</h2>
|
|
<p className="note" style={{margin: '-8px 0 14px'}}>
|
|
개항부터 지금까지, 이 도시의 연표입니다.
|
|
</p>
|
|
{chronicle.items.map(event)}
|
|
</section>
|
|
)}
|
|
|
|
{reading.items.length > 0 && (
|
|
<section className="sec" id="reading">
|
|
<p className="eyebrow">READING</p>
|
|
<h2>
|
|
{reading.title || `${region} 읽기`} <span>{reading.items.length}편</span>
|
|
</h2>
|
|
<p className="note" style={{margin: '-8px 0 14px'}}>
|
|
{region}을 배경으로 한 문학입니다.
|
|
</p>
|
|
{reading.items.slice(0, 3).map(book)}
|
|
<More rest={reading.items.length - 3} unit="편">
|
|
{reading.items.slice(3).map(book)}
|
|
</More>
|
|
</section>
|
|
)}
|
|
|
|
<Sheet open={open != null} onClose={() => setOpen(null)} label={open?.title ?? ''}>
|
|
{open && (
|
|
<>
|
|
{open.image && <img src={open.image} alt={open.title} style={{aspectRatio: '1 / 1', maxHeight: '60svh'}} />}
|
|
<h3>{open.title}</h3>
|
|
{open.meta && <p className="meta">{open.meta}</p>}
|
|
{open.body && <p>{open.body}</p>}
|
|
</>
|
|
)}
|
|
</Sheet>
|
|
</>
|
|
);
|
|
}
|