o2o-site-AEO/solution/site/src/layouts/paper/Story.tsx
Mina Choi 4831e2d25d [fix] solution/site: 사진 카드를 PhotoCard 하나로 통일 — 템플릿마다 제각각이던 축제·명소 카드
같은 템플릿 안에서도 축제 카드(흰 바탕·글 안쪽)와 명소 카드(바탕 없음·글 아래)가 따로 놀았다.
실측(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>
2026-10-01 14:33:17 +09:00

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>
</>
);
}