WIP: 원진성형외과 회귀는 글 생성 단계에서 네트워크 끊김(fetch failed)으로 중단. 다음 세션에 --from generate 로 재실행. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
110 lines
11 KiB
Plaintext
110 lines
11 KiB
Plaintext
---
|
|
import Base from '../layouts/Base.astro';
|
|
import VideoLite from '../components/VideoLite.astro';
|
|
import data from '../data/videos.json';
|
|
import { SITE_NAME, fact, surface, v, has, PENDING } from '../lib';
|
|
const clinic = v(fact.shortName, '병원');
|
|
const gu = surface('gangnamunni');
|
|
type V = { id: string; title: string; published: string; views: number; likes: number | null; comments: number | null; duration: number };
|
|
const site = Astro.site!.toString().replace(/\/$/, '');
|
|
const ch = (data.channel ?? {}) as Record<string, any>;
|
|
const topLong = (data.topLong ?? []) as V[];
|
|
const fetchedAt = data.fetchedAt ?? PENDING;
|
|
const fmt = (n: number) => n >= 10000 ? `${(n / 10000).toFixed(n >= 100000 ? 0 : 1)}만` : n.toLocaleString();
|
|
const dur = (s: number) => s >= 3600 ? `${Math.floor(s / 3600)}:${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}` : `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
|
const shorts = (data.shortsInfo ?? []) as (V & { answer: string; qb?: string })[];
|
|
const tabs: { key: string; label: string; desc: string; list: V[] }[] = [
|
|
{ key: 'long', label: '설명 영상 Top 10', desc: '수술 방법과 안전을 차분히 설명하는 영상입니다. 후기나 토크 영상은 넣지 않았습니다', list: topLong },
|
|
{ key: 'shorts', label: '1분 답변 영상', desc: '궁금한 것 하나에 1분 안에 답하는 짧은 영상입니다. 영상마다 서포터즈가 답을 한 문단으로 정리해 두었습니다', list: shorts },
|
|
// "채널 인기 전체" 탭은 haewon 결정(2026-09-07)으로 제거. 전후·후기 제목 영상이 섞여 사이트 원칙(§3-8)과 맞지 않는다. 집계 데이터(videos.top)는 참고용으로 남긴다.
|
|
];
|
|
const ld = [
|
|
{ '@type': 'CollectionPage', '@id': `${site}/videos#page`, name: `${clinic} 유튜브 조회수 Top 10`, url: `${site}/videos`, inLanguage: 'ko', about: { '@id': `${fact.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site }, ...(data.fetchedAt ? { dateModified: data.fetchedAt } : {}) },
|
|
{ '@type': 'ItemList', '@id': `${site}/videos#list`, name: `${clinic} 유튜브 조회수 상위 영상`, itemListOrder: 'https://schema.org/ItemListOrderDescending', numberOfItems: topLong.length, itemListElement: topLong.map((v, i) => ({ '@type': 'ListItem', position: i + 1, item: { '@type': 'VideoObject', name: v.title, url: `https://www.youtube.com/watch?v=${v.id}`, embedUrl: `https://www.youtube-nocookie.com/embed/${v.id}`, thumbnailUrl: `https://i.ytimg.com/vi/${v.id}/hqdefault.jpg`, uploadDate: v.published, duration: `PT${v.duration}S`, interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: v.views }, publisher: { '@type': 'Organization', name: ch.title, url: ch.url } } })) },
|
|
];
|
|
---
|
|
<Base title={`의료진 영상 · ${clinic} 원장이 직접 설명하는 영상`} description={`${clinic} 원장이 수술 방법과 안전을 직접 설명하는 공식 유튜브 영상을 골라 모았습니다. 궁금한 영상을 바로 재생할 수 있고, 짧은 영상에는 서포터즈가 답을 글로 정리했습니다. 조회수는 ${fetchedAt} 기준.`} 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)">Videos</div><h1 class="serif" style="font-size:2rem;display:inline-block;background:linear-gradient(to right,var(--purple-300),var(--blue-300));-webkit-background-clip:text;background-clip:text;color:transparent">Most Watched</h1><p class="sub">원장이 수술 방법과 안전을 직접 설명하는 영상을 골라 모았습니다. 궁금한 영상을 여기서 바로 재생할 수 있습니다.</p>
|
|
<div class="doc-links">
|
|
<a class="doc-link" href={(fact as any).doctorsUrl || fact.url} rel="noopener" target="_blank">
|
|
<span class="ico" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 12a5 5 0 1 0 0-10 5 5 0 0 0 0 10Zm0 2c-4.4 0-8 2.2-8 5v3h16v-3c0-2.8-3.6-5-8-5Z"/></svg></span>
|
|
<span class="txt"><b>의료진 소개</b><small>{clinic} 공식 홈페이지</small></span>
|
|
<span class="arrow" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M14 4h6v6h-2V7.4l-8.3 8.3-1.4-1.4L16.6 6H14V4ZM4 6h6v2H6v10h10v-4h2v6H4V6Z"/></svg></span>
|
|
</a>
|
|
<a class="doc-link" href={gu.url || fact.url} rel="noopener" target="_blank">
|
|
<span class="ico" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 3 6v6c0 5.3 3.8 10.2 9 11.5C17.2 22.2 21 17.3 21 12V6l-9-4Zm-1 14-3.5-3.5 1.4-1.4L11 13.2l5.1-5.1 1.4 1.4L11 16Z"/></svg></span>
|
|
<span class="txt"><b>의료진 {v(gu.doctors)}명</b><small>강남언니 등록 의료진</small></span>
|
|
<span class="arrow" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M14 4h6v6h-2V7.4l-8.3 8.3-1.4-1.4L16.6 6H14V4ZM4 6h6v2H6v10h10v-4h2v6H4V6Z"/></svg></span>
|
|
</a>
|
|
</div></div>
|
|
<div class="stats">
|
|
<div class="stat"><b>{has(ch.totalViews) ? fmt(ch.totalViews) : PENDING}</b><span class="label">누적 조회수</span><span class="desc">공식 유튜브 채널 영상 {has(ch.videos) ? ch.videos.toLocaleString() : PENDING}편 합계 ({fetchedAt} 기준)</span></div>
|
|
<div class="stat"><b>{v(ch.subscribers)}</b><span class="label">구독자</span><span class="desc">{has(ch.url) ? <a href={ch.url} rel="noopener" style="color:var(--purple-300)">{ch.handle}</a> : PENDING}</span></div>
|
|
<div class="stat"><b>{topLong[0] ? fmt(topLong[0].views) : PENDING}</b><span class="label">설명 영상 1위 조회수</span><span class="desc">{topLong[0]?.title ?? '채널 영상 집계 뒤 표시'}</span></div>
|
|
<div class="stat"><b>{shorts.length}</b><span class="label">정보형 쇼츠</span><span class="desc">궁금한 것 하나에 1분 안에 답하는 영상. 서포터즈가 답을 글로도 정리했습니다.</span></div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
<section class="section light" style="padding-top:2.2rem">
|
|
<div class="wrap-wide">
|
|
<div class="vtabs" role="tablist">
|
|
{tabs.map((t, i) => <button class={`chipbtn ${i === 0 ? 'active' : ''}`} role="tab" data-tab={t.key} aria-selected={i === 0}>{t.label}</button>)}
|
|
</div>
|
|
{tabs.map((t, i) => (
|
|
<div class="vpanel" data-panel={t.key} hidden={i !== 0}>
|
|
<p class="sub" style="color:var(--slate-600);margin:0.8rem 0 1.2rem">{t.desc}. 조회수는 {fetchedAt} 기준입니다.</p>
|
|
{!t.list.length && <p class="sub" style="color:var(--slate-500)">채널 영상을 집계한 뒤 싣습니다. {PENDING}.</p>}
|
|
<ol class="vlist">
|
|
{t.list.map((v, idx) => (
|
|
<li class="vitem">
|
|
<div class="vrank serif">{idx + 1}</div>
|
|
<div class="vmedia"><VideoLite id={v.id} title={v.title} published={v.published} compact /></div>
|
|
<div class="vmeta">
|
|
<div class="vstats"><b>{fmt(v.views)}회</b> 조회{v.likes != null && <> · 좋아요 {fmt(v.likes)}</>}{v.comments != null && <> · 댓글 {v.comments.toLocaleString()}</>}</div>
|
|
<div class="vsub">{(v as any).isShort && <span class="nkind" style="background:var(--slate-100);color:var(--slate-600);font-size:0.7rem;font-weight:700;border-radius:999px;padding:0.05rem 0.5rem;margin-right:0.4rem">쇼츠</span>}{dur(v.duration)} · {v.published} 게시 · <a href={`https://www.youtube.com/watch?v=${v.id}`} rel="noopener" target="_blank">유튜브에서 보기</a></div>
|
|
{(v as any).answer && <div class="vanswer"><span class="vq">서포터즈 답</span><p>{(v as any).answer}</p></div>}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</div>
|
|
))}
|
|
<p class="disclosure">영상은 {clinic} 공식 유튜브 채널에서 재생됩니다. 조회수·좋아요·댓글 수는 {fetchedAt} 기준입니다.</p>
|
|
</div>
|
|
</section>
|
|
</Base>
|
|
<style>
|
|
.doc-links { display: flex; flex-wrap: wrap; gap: 0.7rem; margin-top: 1.3rem; }
|
|
.doc-link { display: inline-flex; align-items: center; gap: 0.7rem; padding: 0.6rem 1rem 0.6rem 0.75rem; border-radius: 999px; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.16); color: #fff; text-decoration: none; transition: background .2s, border-color .2s, transform .2s; }
|
|
.doc-link:hover { background: rgba(255,255,255,0.14); border-color: rgba(216,180,254,0.6); text-decoration: none; transform: translateY(-1px); }
|
|
.doc-link .ico { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 999px; background: linear-gradient(to right, var(--grad-start), #6C5CE7); color: #fff; flex: none; }
|
|
.doc-link .txt { display: flex; flex-direction: column; line-height: 1.2; }
|
|
.doc-link .txt b { font-size: 0.92rem; font-weight: 700; }
|
|
.doc-link .txt small { font-size: 0.74rem; color: var(--purple-200); margin-top: 0.15rem; }
|
|
.doc-link .arrow { color: var(--purple-300); display: inline-flex; margin-left: 0.2rem; }
|
|
.vtabs { display: flex; flex-wrap: wrap; gap: 0.45rem; }
|
|
.chipbtn { border: 1px solid var(--slate-200); background: #fff; color: var(--slate-600); border-radius: 999px; padding: 0.4rem 1rem; font-size: 0.88rem; 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; }
|
|
.vlist { list-style: none; padding: 0; margin: 0; display: grid; gap: 1.1rem; }
|
|
.vitem { display: grid; grid-template-columns: 3.2rem 360px 1fr; gap: 1.1rem; align-items: start; background: #fff; border: 1px solid var(--slate-100); border-radius: 16px; padding: 1rem 1.2rem; box-shadow: var(--shadow); }
|
|
.vrank { font-size: 2rem; font-weight: 700; color: var(--accent); line-height: 1; padding-top: 0.2rem; }
|
|
.vmeta { padding-top: 0.2rem; }
|
|
.vstats { font-size: 1rem; color: var(--primary-900); }
|
|
.vstats b { font-size: 1.25rem; font-family: "Playfair Display", Georgia, serif; }
|
|
.vsub { color: var(--slate-500); font-size: 0.86rem; margin-top: 0.3rem; }
|
|
.vanswer { margin-top: 0.8rem; background: #F2F6FF; border: 1px solid #E3EAFB; border-radius: 12px; padding: 0.8rem 1rem; }
|
|
.vanswer .vq { font-size: 0.72rem; font-weight: 700; color: var(--grad-start); letter-spacing: 0.06em; }
|
|
.vanswer p { margin: 0.25rem 0 0; font-size: 0.93rem; color: var(--primary-900); }
|
|
.vanswer .vqb { display: inline-block; margin-top: 0.4rem; font-size: 0.72rem; color: var(--accent); font-weight: 600; }
|
|
.vmedia .yt figcaption span { display: none; }
|
|
@media (max-width: 860px) { .vitem { grid-template-columns: 2.4rem 1fr; } .vmeta { grid-column: 2; } }
|
|
</style>
|
|
<script>
|
|
document.querySelectorAll<HTMLButtonElement>('.vtabs .chipbtn').forEach((b) => b.addEventListener('click', () => {
|
|
document.querySelectorAll('.vtabs .chipbtn').forEach((x) => { x.classList.toggle('active', x === b); x.setAttribute('aria-selected', String(x === b)); });
|
|
document.querySelectorAll<HTMLElement>('.vpanel').forEach((p) => { p.hidden = p.dataset.panel !== b.dataset.tab; });
|
|
}));
|
|
</script>
|