o2o-site-AEO/solution/site/src/sections/items/VideoSection.tsx
Mina Choi 4260e20a70 [fix] site: 모달 닫기 스크롤 버그 수정 — 이용안내 체크인·체크아웃 병합, UI 다듬기
모달 onClose 가 inline 함수라 부모 리렌더마다 useEffect 가 다시 걸려
scrollY 를 0으로 덮어써 X 버튼으로 닫으면 페이지가 맨 위로 튀었다.

- lib/ui/Modal.tsx: onClose 를 ref 로 들고 [open] 에만 의존하도록 수정
  (BlogSection·ReviewSection 등 Modal 쓰는 7곳 전부 적용)
- sections/EssentialInfoSection.tsx: 체크인·체크아웃 행을 한 줄로 병합
- sections/GallerySection.tsx, UnitsSection.tsx, MobileTabBar.tsx, SiteFooter.tsx,
  items/*: 표시 폭·간격·라벨 정리
- lib/ui/Carousel.tsx: loop 이음매 간격 재점검
- scripts/prerender.ts: countUniqueContent 가 socialPosts(자체 출력)를 세지
  않도록 — 콘텐츠 0건 사이트가 게이트를 우회하던 경로

tsc 통과, vitest 100/102 passed (use-live-weather 실패 2건은 기존·무관)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 17:23:20 +09:00

234 lines
11 KiB
TypeScript

/**
* 영상 — 발행본.
*
* ★ **누르기 전에는 iframe 을 붙이지 않는다.**
* 유튜브 임베드 하나는 첫 요청에만 수백 KB, 실행까지 1MB 가 넘는 남의 스크립트를 끌고 온다.
* 이 사이트는 정적으로 구워 빠른 것이 존재 이유인데, 영상 한 편이 그걸 통째로 무른다.
* 그래서 **표지 사진 + 재생 버튼**만 두고, 손님이 누른 그 영상만 iframe 으로 바꾼다.
* 덤으로 재생 전에는 추적 쿠키가 심기지 않는다(누른 뒤에도 `youtube-nocookie`).
*
* ★ 스크립트가 없어도 영상으로 갈 수 있어야 한다.
* 표지는 `<a href>` 로 감싼다 — 크롤러는 링크를 읽고, JS 가 죽어도 손님은 유튜브로 간다.
* 눌렀을 때만 기본 이동을 막고 그 자리에서 재생한다.
*
* ★ 시작 여백을 없앴다 (2026-09-04, 사장님: "카로셀 시작 패딩 없게")
* 트랙에 px-[7%] 를 두고 카드를 snap-center 로 잡았더니, 390px 에서 첫 영상이 섹션 제목보다
* **24px 더 안쪽**에서 시작했다. 왼쪽 선이 둘이 되면 목록이 섹션에 안 붙은 것처럼 보인다.
* 여백을 걷고 snap-start 로 바꿔 첫 장을 제목과 같은 선에 세운다 — basis 가 86% 라
* 다음 장이 걸쳐 보이는 신호는 그대로다.
* ★ 쇼츠는 9:16 이다. 16:9 틀에 넣으면 좌우가 비고 영상이 손톱만 해진다 —
* 주소가 이미 말해 주는 것(`/shorts/`)을 무시하지 않는다.
*
* ★ 좁은 화면에서는 옆으로 민다. 세로로 쌓으면 영상 두세 편이 화면 몇 개 분량이 되어
* 그 아래 섹션이 안 읽힌다. 넓은 화면에서는 격자 그대로다.
* **폭으로 컴포넌트를 갈아 끼우지 않고 CSS 로만 가른다** — 서버 렌더는 화면 폭을 모르니
* 렌더 중에 matchMedia 를 읽으면 하이드레이션이 어긋난다. 스크립트가 없어도 밀린다.
*/
import {useCallback, useEffect, useRef, useState} from 'react';
import {ChevronLeft, ChevronRight, Play} from 'lucide-react';
import {
isVerticalVideo,
youtubeEmbed,
youtubeId,
youtubePoster,
type VideoItem,
} from '@o2o/shared';
import {AUTOPLAY_MS, scrollRailNext, useRailAutoplay} from '@site/lib/ui/use-rail-autoplay';
import {useSite} from '@site/lib/site-context';
import {sectionItems, sectionName} from '@site/lib/derive';
import {ITEM_BORDER, ITEM_CARD, ItemSection, SourceLine} from './common';
export function VideoSection() {
const payload = useSite();
const parsed = sectionItems<VideoItem>(payload, 'video');
// 지금 재생 중인 한 편. 여러 편을 동시에 띄우지 않는다 — 소리가 겹친다.
const [playing, setPlaying] = useState<number>();
const scroller = useRef<HTMLUListElement>(null);
// 끝에 닿으면 화살표를 지운다 — 눌러도 안 움직이는 버튼은 고장으로 읽힌다.
const [edge, setEdge] = useState({prev: false, next: true});
const syncEdges = useCallback(() => {
const box = scroller.current;
if (!box) return;
const max = box.scrollWidth - box.clientWidth;
// 소수점 폭 때문에 scrollLeft 가 끝에 정확히 닿지 못한다 — 8px 여유를 둔다.
setEdge({prev: box.scrollLeft > 8, next: box.scrollLeft < max - 8});
}, []);
useEffect(() => {
syncEdges();
// 폭이 바뀌면(회전·창 조절) 격자↔슬라이더가 갈리므로 다시 잰다.
window.addEventListener('resize', syncEdges);
return () => window.removeEventListener('resize', syncEdges);
}, [syncEdges]);
const nudge = useCallback((dir: -1 | 1) => {
const box = scroller.current;
if (!box) return;
// 한 화면을 통째로 넘기지 않는다(0.85) — 방금 본 영상이 조금 남아야 이어진 목록으로 읽힌다.
const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
box.scrollBy({left: dir * box.clientWidth * 0.85, behavior: still ? 'auto' : 'smooth'});
}, []);
/*
* 자동 넘김 — 카드 레일과 **같은 정책**이다(`useRailAutoplay`).
* 이 섹션은 embla 를 안 써서 자동 넘김이 없었다(2026-09-09, 사장님: "ado2 카로셀은 auto가 아닌데?").
* 넓은 화면에서는 격자라 넘길 데가 없고, 그때는 `scrollRailNext` 가 곧 false 를 돌려 스스로 멈춘다.
*/
useRailAutoplay({
box: scroller,
interval: AUTOPLAY_MS,
advance: () => scrollRailNext(scroller.current),
});
if (parsed.items.length === 0) return null;
// 한 편뿐이면 밀 곳이 없다 — 슬라이더로 만들지 않는다.
const many = parsed.items.length > 1;
return (
<ItemSection
id="video"
name={parsed.title || sectionName(payload, 'video', '영상으로 보기')}
subtitle={parsed.subtitle}
link={
parsed.linkUrl ? {url: parsed.linkUrl, label: parsed.linkLabel ?? '더 보기'} : undefined
}
>
<div className="relative">
<ul
ref={scroller}
onScroll={syncEdges}
/*
* ★ 데스크톱에서 카드가 **딱 붙어 있었다** (2026-09-09, 사장님: "간격없이 붙는거 다 수정해")
* 클래스가 `gap-5` 와 `gap-3` 두 벌이었고 거기에 `lg:gap-0` 이 얹혀 있었다 —
* 실측(1280px): 3열 격자의 카드 간격이 0px 이라 영상 셋이 한 덩이로 보였다.
* 간격은 카드 레일 한 값(16px)으로 맞췄다 — Carousel 의 `gap` 주석 참조.
*/
className={
many
? 'flex snap-x snap-mandatory gap-4 overflow-x-auto [touch-action:pan-y_pinch-zoom] pb-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
: 'grid grid-cols-1 gap-4 sm:max-w-md sm:mx-auto'
}
>
{parsed.items.map((item, index) => {
const id = youtubeId(item.url);
const vertical = isVerticalVideo(item.url);
const on = playing === index;
return (
<li
key={`${item.url}-${index}`}
/* 다음 편이 걸쳐 보여야 "옆으로 더 있다"가 읽힌다. 넓은 화면은 격자 칸이 폭을 정한다. */
className={many ? 'shrink-0 basis-[46%] snap-start sm:basis-[30%] lg:basis-[200px]' : ''}
>
<figure className="space-y-2">
<div
className="tpl-border relative mx-auto overflow-hidden border"
style={{
borderColor: ITEM_BORDER,
backgroundColor: ITEM_CARD,
aspectRatio: vertical ? '9 / 16' : '16 / 9',
// 세로 영상이 카드 폭을 다 먹으면 한 편이 화면 하나가 된다.
width: vertical ? 'min(100%, 14rem)' : '100%',
}}
>
{on && id ? (
<iframe
src={youtubeEmbed(id)}
title={item.caption || '영상'}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
className="size-full border-0"
/>
) : (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => {
// 유튜브 주소일 때만 그 자리에서 재생한다. 아니면 그냥 링크로 나간다.
if (!id) return;
event.preventDefault();
setPlaying(index);
}}
aria-label={`${item.caption || '영상'} 재생`}
className="group block size-full"
>
{id ? (
<img
src={youtubePoster(id)}
alt=""
loading="lazy"
decoding="async"
className="size-full object-cover"
/>
) : (
<span className="text-muted grid size-full place-items-center px-4 text-center text-[length:var(--fs-xs)]">
유튜브 영상이 아닙니다 — 눌러서 원본으로 갑니다
</span>
)}
<span className="absolute inset-0 grid place-items-center">
<span
className="grid size-14 place-items-center rounded-full backdrop-blur transition-transform group-hover:scale-110"
style={{
backgroundColor:
'color-mix(in srgb, var(--tpl-inverse, #1c1917) 70%, transparent)',
color: 'var(--tpl-bg, #fff)',
}}
>
<Play className="size-6" />
</span>
</span>
</a>
)}
</div>
{item.caption && (
<figcaption className="text-[length:var(--fs-sm)] leading-relaxed opacity-100">
{item.caption}
</figcaption>
)}
<SourceLine source={item.source} verified={item.verified} />
</figure>
</li>
);
})}
</ul>
{/* 손가락으로 밀 수 있어도 화살표가 있어야 "옆에 더 있다"가 보인다. 넓은 화면은 격자라 감춘다. */}
{many && (
<>
<ScrollNav dir="prev" onClick={() => nudge(-1)} disabled={!edge.prev} />
<ScrollNav dir="next" onClick={() => nudge(1)} disabled={!edge.next} />
</>
)}
</div>
</ItemSection>
);
}
function ScrollNav({
dir,
onClick,
disabled,
}: {
dir: 'prev' | 'next';
onClick: () => void;
disabled: boolean;
}) {
const Icon = dir === 'prev' ? ChevronLeft : ChevronRight;
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={dir === 'prev' ? '이전 영상' : '다음 영상'}
className={`tap absolute top-1/2 z-10 flex -translate-y-1/2 items-center justify-center rounded-full bg-black/55 text-white backdrop-blur transition hover:bg-black/75 disabled:pointer-events-none disabled:opacity-0 ${
dir === 'prev' ? 'left-1' : 'right-1'
}`}
>
<Icon className="size-5" />
</button>
);
}