o2o-infinith-demo/templates/supporters-astro/src/pages/newsroom.astro
Haewon Kam 622441e6aa feat(supporters): 템플릿화 — 병원 고유값을 데이터로 분리하고 templates/supporters-astro 내보내기
- src/data/site.json 신설(매체 이름·로고·홈 큐레이션), factSheet에 kind·areaLabel·transitShort·transitNote·reservationUrl·doctorsUrl·youtube.title 추가
- lib.ts: PENDING('확인 대기')·v()·has()·surface()·editor() 도입, 스키마는 빈 값을 빼고 생성
- 페이지·컴포넌트 15개에서 뷰성형외과 하드코딩 제거, 빈 데이터 empty-state (글 0편·영상 0건·뉴스 0건·이미지 없음)
- 검증: 28페이지 표시 텍스트·구조 지문 이전 빌드와 동일(홈 title 마침표 1건만 정정), JSON-LD 차이는 빈 sameAs 제거뿐
- scripts/export_template.mjs + scripts/template/data(빈 데이터 세트) → templates/supporters-astro (빈 데이터로 10페이지 0오류 빌드, 게이트 통과)
- 게이트 픽스처를 자체 authors로 분리해 템플릿에서도 테스트 통과, 구조 기준선의 글 상세 페이지는 사전순 첫 글로 동적 선택

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 14:07:14 +09:00

125 lines
9.5 KiB
Plaintext

---
import Base from '../layouts/Base.astro';
import news from '../data/news.json';
import { SITE_NAME, fact, site as S, v, PENDING } from '../lib';
const clinic = v(fact.shortName, '병원');
const items = news.items as { date: string; title: string; outlet: string; url: string; kind: 'release' | 'column' | 'mention'; origin: string; topic: string }[];
const site = Astro.site!.toString().replace(/\/$/, '');
const years = [...new Set(items.map((i) => i.date.slice(0, 4)))].sort().reverse();
const topics = [...new Set(items.map((i) => i.topic))];
const topicCount = Object.fromEntries(topics.map((t) => [t, items.filter((i) => i.topic === t).length]));
topics.sort((a, b) => topicCount[b] - topicCount[a]);
const KIND: Record<string, string> = { release: '보도자료', column: '원장 기고·인터뷰', mention: '언론 언급' };
const counts = { release: items.filter((i) => i.kind === 'release').length, column: items.filter((i) => i.kind === 'column').length, mention: items.filter((i) => i.kind === 'mention').length };
const outlets = new Set(items.map((i) => i.outlet)).size;
const ld = [
{ '@type': 'CollectionPage', '@id': `${site}/newsroom#page`, name: `${clinic} 뉴스룸`, url: `${site}/newsroom`, inLanguage: 'ko', about: { '@id': `${fact.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site }, ...(news.generatedAt ? { dateModified: news.generatedAt } : {}) },
{ '@type': 'ItemList', '@id': `${site}/newsroom#list`, name: `${clinic} 언론 보도 목록`, numberOfItems: items.length, itemListOrder: 'https://schema.org/ItemListOrderDescending', itemListElement: items.slice(0, 200).map((i, idx) => ({ '@type': 'ListItem', position: idx + 1, url: i.url, name: i.title })) },
];
---
<Base title={`뉴스룸 · ${clinic} 언론 보도 모음`} description={items.length ? `${clinic} 보도자료와 원장 기고·인터뷰, 언론 보도 ${items.length}건을 연도와 주제별로 모았습니다. ${years[years.length - 1]}년부터 ${years[0]}년까지, 매체 ${outlets}곳. 제목을 누르면 기사 원문으로 이동합니다.` : `${clinic} 보도자료와 원장 기고·인터뷰, 언론 보도를 연도와 주제별로 모읍니다. 기사 수집 ${PENDING}.`} jsonLd={ld}>
<section class="section dark" style="padding:3.5rem 0 3rem">
<div class="wrap-wide">
<div class="sec-head"><div class="eyebrow" style="color:var(--purple-300)">Newsroom</div><h1 class="serif" style="font-size:2rem;color:#fff;display:inline-block;background:linear-gradient(to right,var(--purple-300),var(--blue-300));-webkit-background-clip:text;background-clip:text;color:transparent">In The News</h1><p class="sub">{clinic} 보도자료와 원장 기고·인터뷰, 언론 보도를 연도와 주제별로 모았습니다. 제목을 누르면 기사 원문으로 이동합니다.</p></div>
<div class="stats">
<div class="stat"><b>{items.length.toLocaleString()}</b><span class="label">기사</span><span class="desc">{years.length ? `${years[years.length - 1]}년부터 ${years[0]}년까지` : PENDING}</span></div>
<div class="stat"><b>{counts.release.toLocaleString()}</b><span class="label">보도자료</span><span class="desc">병원이 직접 알린 소식. 수상, 장비 도입, 행사, 협약</span></div>
<div class="stat"><b>{counts.column.toLocaleString()}</b><span class="label">원장 기고·인터뷰</span><span class="desc">원장이 언론에 직접 설명한 수술 이야기</span></div>
<div class="stat"><b>{outlets}</b><span class="label">매체</span><span class="desc">{v(S.newsOutletsExample, PENDING)}</span></div>
</div>
</div>
</section>
<section class="section light" style="padding-top:2.5rem">
<div class="wrap-wide">
<div class="filters" id="filters">
<div class="fgroup"><span class="flabel">종류</span>
<button class="chipbtn active" data-kind="all">전체</button>
<button class="chipbtn" data-kind="release">보도자료 {counts.release}</button>
<button class="chipbtn" data-kind="column">기고·인터뷰 {counts.column}</button>
<button class="chipbtn" data-kind="mention">언론 언급 {counts.mention}</button>
</div>
<div class="fgroup"><span class="flabel">주제</span>
<button class="chipbtn active" data-topic="all">전체</button>
{topics.map((t) => <button class="chipbtn" data-topic={t}>{t} {topicCount[t]}</button>)}
</div>
<div class="fgroup"><span class="flabel">연도</span>
<div class="yearnav">{years.map((y) => <a href={`#y${y}`}>{y}</a>)}</div>
</div>
</div>
{!items.length && <p class="sub" style="color:var(--slate-500)">병원 게시판과 뉴스 검색에서 기사를 모은 뒤 싣습니다. {PENDING}.</p>}
{years.map((y) => {
const list = items.filter((i) => i.date.startsWith(y));
return (
<section class="year" id={`y${y}`} data-year={y}>
<div class="yearhead"><h2 class="serif" style="margin-top:0">{y}</h2><span class="ycount">{list.length}건</span></div>
<ol class="newslist">
{list.map((i) => (
<li class="newsitem" data-kind={i.kind} data-topic={i.topic}>
<span class="ndate">{i.date}</span>
<span class={`nkind ${i.kind}`}>{KIND[i.kind]}</span>
<a class="ntitle" href={i.url} rel="noopener nofollow" target="_blank">{i.title}</a>
<span class="noutlet">{i.outlet}</span>
<span class="ntopic">{i.topic}</span>
</li>
))}
</ol>
</section>
);
})}
<p class="disclosure">{v(S.newsNote, `${clinic} 홈페이지 게시판과 뉴스 검색에서 모은 기사입니다. 병원이 알리거나 원장이 설명한 기사만 모았고, 그 밖의 보도는 포함하지 않습니다.`)}</p>
</div>
</section>
</Base>
<style>
.filters { display: grid; gap: 0.7rem; margin-bottom: 2rem; background: var(--primary-50); border-radius: 16px; padding: 1.1rem 1.25rem; }
.fgroup { display: flex; flex-wrap: wrap; align-items: center; gap: 0.45rem; }
.flabel { font-size: 0.78rem; font-weight: 700; color: var(--slate-500); letter-spacing: 0.06em; margin-right: 0.3rem; min-width: 2.4em; }
.chipbtn { border: 1px solid var(--slate-200); background: #fff; color: var(--slate-600); border-radius: 999px; padding: 0.3rem 0.8rem; font-size: 0.82rem; font-weight: 600; cursor: pointer; font-family: inherit; }
.chipbtn.active { background: linear-gradient(to right, var(--grad-start), var(--grad-end)); color: #fff; border-color: transparent; }
.yearnav { display: flex; flex-wrap: wrap; gap: 0.35rem; }
.yearnav a { font-size: 0.82rem; font-weight: 600; color: var(--accent); padding: 0.2rem 0.5rem; border-radius: 6px; }
.yearnav a:hover { background: var(--st-good-bg); text-decoration: none; }
.year { margin: 0 0 2.2rem; scroll-margin-top: 90px; }
.year.hidden { display: none; }
.yearhead { display: flex; align-items: baseline; gap: 0.8rem; border-bottom: 2px solid var(--primary-900); padding-bottom: 0.4rem; margin-bottom: 0.6rem; }
.yearhead h2 { margin: 0; font-size: 1.8rem; }
.ycount { color: var(--slate-500); font-size: 0.85rem; font-weight: 600; }
.newslist { list-style: none; padding: 0; margin: 0; }
.newsitem { display: grid; grid-template-columns: 6.2em 6.4em 1fr auto auto; gap: 0.6rem 0.9rem; align-items: baseline; padding: 0.55rem 0.4rem; border-bottom: 1px solid var(--slate-100); font-size: 0.93rem; }
.newsitem.hidden { display: none; }
.newsitem:hover { background: var(--primary-50); }
.ndate { color: var(--slate-500); font-variant-numeric: tabular-nums; font-size: 0.85rem; }
.nkind { font-size: 0.72rem; font-weight: 700; border-radius: 999px; padding: 0.1rem 0.55rem; text-align: center; border: 1px solid transparent; white-space: nowrap; }
.nkind.release { background: var(--st-good-bg); color: var(--st-good-text); border-color: var(--st-good-border); }
.nkind.column { background: var(--st-info-bg); color: var(--st-info-text); border-color: var(--st-info-border); }
.nkind.mention { background: var(--slate-100); color: var(--slate-600); }
.ntitle { color: var(--primary-900); font-weight: 500; }
.noutlet { color: var(--slate-500); font-size: 0.83rem; white-space: nowrap; }
.ntopic { color: var(--accent); font-size: 0.75rem; font-weight: 600; white-space: nowrap; }
@media (max-width: 760px) { .newsitem { grid-template-columns: 1fr; gap: 0.15rem; } .noutlet, .ntopic { display: inline; margin-right: 0.6rem; } }
</style>
<script>
const state = { kind: 'all', topic: 'all' };
const items = Array.from(document.querySelectorAll<HTMLElement>('.newsitem'));
const years = Array.from(document.querySelectorAll<HTMLElement>('.year'));
function apply() {
for (const el of items) {
const ok = (state.kind === 'all' || el.dataset.kind === state.kind) && (state.topic === 'all' || el.dataset.topic === state.topic);
el.classList.toggle('hidden', !ok);
}
for (const y of years) {
const visible = y.querySelectorAll('.newsitem:not(.hidden)').length;
y.classList.toggle('hidden', visible === 0);
const c = y.querySelector('.ycount'); if (c) c.textContent = `${visible}건`;
}
}
document.querySelectorAll<HTMLButtonElement>('.chipbtn').forEach((b) => b.addEventListener('click', () => {
const key = b.dataset.kind ? 'kind' : 'topic'; const val = (b.dataset.kind ?? b.dataset.topic)!;
(state as any)[key] = val;
b.parentElement!.querySelectorAll('.chipbtn').forEach((x) => x.classList.toggle('active', x === b));
apply();
}));
</script>