/** * 영상 — 발행본. * * ★ **누르기 전에는 iframe 을 붙이지 않는다.** * 유튜브 임베드 하나는 첫 요청에만 수백 KB, 실행까지 1MB 가 넘는 남의 스크립트를 끌고 온다. * 이 사이트는 정적으로 구워 빠른 것이 존재 이유인데, 영상 한 편이 그걸 통째로 무른다. * 그래서 **표지 사진 + 재생 버튼**만 두고, 손님이 누른 그 영상만 iframe 으로 바꾼다. * 덤으로 재생 전에는 추적 쿠키가 심기지 않는다(누른 뒤에도 `youtube-nocookie`). * * ★ 스크립트가 없어도 영상으로 갈 수 있어야 한다. * 표지는 `` 로 감싼다 — 크롤러는 링크를 읽고, 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 {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(payload, 'video'); // 지금 재생 중인 한 편. 여러 편을 동시에 띄우지 않는다 — 소리가 겹친다. const [playing, setPlaying] = useState(); const scroller = useRef(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'}); }, []); if (parsed.items.length === 0) return null; // 한 편뿐이면 밀 곳이 없다 — 슬라이더로 만들지 않는다. const many = parsed.items.length > 1; return (
    {parsed.items.map((item, index) => { const id = youtubeId(item.url); const vertical = isVerticalVideo(item.url); const on = playing === index; return (
  • {on && id ? (