에디터에서 본 화면과 발행된 화면이 달랐다. 렌더러를 두 벌 들고 있었기 때문이다 —
캔버스는 `builder/canvas/variants/*` 25종, 발행본은 `site/src/sections/*`.
Playwright 로 재 보니 아예 다른 물건이었다(2026-09-09, 1024px):
발행본 15섹션 · 에디터 12섹션 · 겹치는 건 4개뿐, 이름도 달랐다
(gallery↔photos · location↔map · guide↔local)
겹치는 4개조차 높이가 달랐다(info 488↔535 · booking 242↔487 · itinerary 881↔383)
소스를 하나로 모은다. 편집·미리보기 둘 다 발행본 렌더러가 그린다.
**데이터도 한 벌** — `GET /v1/place/{id}/site/preview` 가 발행이 굽는 것과 **같은 함수**
(`build_snapshot` → `to_site_payload`)로 payload 를 만든다. DB 도 파일도 건드리지 않는다.
**왜 iframe 인가** — 컴포넌트만 같게 해서는 안 됐다. 미디어 쿼리는 창 폭을 보는데 실제
사이트 폭은 그 안의 프레임이라, 그리드 컬럼 수가 어긋나 섹션이 두 배씩 길어졌다
(festival 2560→6027 · guide 1168→2168). iframe 은 자체 뷰포트를 가져 발행본과 같은 폭을 본다.
폭만이 아니라 **높이도** 준다 — 히어로가 `clamp(24rem, 62vh, 36rem)` 이라 낮은 iframe 에서는
하한에 걸렸다(384 ↔ 발행본 576). 자리에 안 들어가면 transform 으로 줄인다: 크기는 그대로,
그림만 줄여야 미디어 쿼리가 안 흔들린다.
**색·서체도 한 벌** — `themeVars(payload)` · `fontHref(payload)`. 셸에는 발행본 `<head>` 의
폰트 링크가 없어 글자만 기본 산세리프로 떨어졌다(지오메트리는 같은데 픽셀 차이 92%).
**에디터가 저장된 템플릿을 안 읽던 것** — `applyTheme` 이 섹션·색팔레트는 되살리는데
templateId 를 빠뜨렸다. templateId 는 theme JSON 이 아니라 `sites.template_id` **컬럼**이라
저장 경로가 다른데 읽는 쪽이 theme 만 봤다. 사장님이 '옛 항구' 를 골라 발행해도 다시
들어오면 편집 화면만 흰 바탕·고딕이었다.
**고르기는 iframe 안에서** — 같은 오리진이라 안쪽 문서에 직접 리스너를 건다. 어느 섹션인지는
`data-editor-id` 로 안다(화면 id `gallery` ↔ 설정 id `photos`; `display:contents` 라 레이아웃
무영향). 표시는 outline 이다 — 상자 크기를 바꾸지 않아 발행본과 픽셀이 그대로다.
곁들여 정리한 것
- 켤 수 없는 섹션 둘(`pricing`·`planner`)을 뗐다 — 기본표에도 [+섹션 추가]에도 없고 DB 참조 0건.
- 반대로 `event`(소식)는 기본표가 켜서 **발행되는데** 채울 UI 가 없었다. 명세를 넣는다.
이 아이템만 프롬프트가 "찾아라" 가 아니라 **"옮겨 적어라"** 다 — 이 가게에서 지금 하는
일이라 모델이 알 수 없고, 지어내면 손님이 없는 행사를 보고 찾아온다.
- 예약 버튼이 "네이버 예약 예약" 이었다. `{bookingLabel} 예약` 을 13개 파일에서 각자 이어
붙이고 있었다 — `bookingActionLabel()` 하나로 모은다.
- `solution/site` 의 별칭을 `@` → `@site` 로 옮겼다(60파일 195건). 두 앱이 '@' 를 각자 자기
src 로 두면 발행본 컴포넌트를 빌더에서 부를 때 **조용히 다른 파일을 잡는다.**
검증(Playwright, 같은 사업장·1024px):
섹션 15 = 15 · 순서 일치 · **한쪽에만 있는 섹션 0개**
15개 전부 높이·글자 수·제목이 정확히 같다
편집·미리보기·발행본 셋 다 --tpl-bg #e4dac0 · Gugi
`/preview` ↔ 발행본 문서 높이 9029 = 9029, 픽셀 차이 2.88%(축제 카드 지연 로딩 타이밍)
tsc -b 통과 · eslint 통과.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
216 lines
9.7 KiB
TypeScript
216 lines
9.7 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 {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'});
|
|
}, []);
|
|
|
|
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}
|
|
className={`gap-5 ${
|
|
many
|
|
? 'flex snap-x snap-mandatory gap-3 overflow-x-auto pb-2 lg:gap-0 [scrollbar-width:none] lg:grid lg:grid-cols-3 lg:snap-none lg:overflow-visible lg:pb-0 [&::-webkit-scrollbar]:hidden'
|
|
: 'grid grid-cols-1'
|
|
}`}
|
|
>
|
|
{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-[86%] snap-start sm:basis-[56%] lg:basis-auto' : ''}
|
|
>
|
|
<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%, 22rem)' : '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-80">
|
|
{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 lg:hidden ${
|
|
dir === 'prev' ? 'left-1' : 'right-1'
|
|
}`}
|
|
>
|
|
<Icon className="size-5" />
|
|
</button>
|
|
);
|
|
}
|