diff --git a/nginx/site.conf.example b/nginx/site.conf.example index be044e8..3dcf3e8 100644 --- a/nginx/site.conf.example +++ b/nginx/site.conf.example @@ -77,6 +77,17 @@ server { } # 파일명에 해시가 박혀 있다. 내용이 바뀌면 이름이 바뀌므로 영구 캐시가 안전하다. + # 빌더 미리보기 셸. **발행본 번들**을 띄우는 CSR 한 장이다 + # (`solution/site/scripts/prerender.writePreviewShell`). + # ★ 빌더 SPA(`location /`)로 떨어지면 안 된다 — 거기로 가면 미리보기 안에 빌더가 또 뜬다. + # ★ iframe 으로 여는 이유는 뷰포트다. 빌더 안에 직접 그리면 미디어 쿼리가 창 폭을 봐서 + # 그리드 컬럼 수가 발행본과 달라진다(실측 89% 픽셀 차이 — SitePreview 머리주석). + location = /preview { + root /srv/sites; + try_files /preview/index.html =404; + add_header Cache-Control "no-store" always; + } + location ^~ /assets/ { add_header Cache-Control "public, max-age=31536000, immutable"; access_log off; diff --git a/solution/backend/router/v1/site/site.py b/solution/backend/router/v1/site/site.py index b9cb12f..bbf8361 100644 --- a/solution/backend/router/v1/site/site.py +++ b/solution/backend/router/v1/site/site.py @@ -1,5 +1,6 @@ from uuid import UUID +from fastapi.responses import JSONResponse from fastapi import APIRouter, Depends, Query from common.models.gmodel import PageParams, UserInfo @@ -159,6 +160,24 @@ async def start_build( return RemoveNoneResponse(await service.start_build(user_info, str(place_id), req)) +@router.get( + path="/preview", + summary="에디터 미리보기 payload — 발행본과 같은 것", + description="발행이 굽는 것과 **같은 함수**로 만든 SitePayload 를 그대로 준다. " + "미리보기가 이 하나만 먹으면 캔버스와 발행본이 갈릴 자리가 없다. " + "★ DB 도 파일도 건드리지 않는다 — 버전을 만들지 않으므로 눌러도 발행 이력이 안 쌓인다.", +) +async def site_preview( + place_id: UUID, + service: SiteService = Depends(), + user_info: UserInfo = Depends(IsValidAccessToken), +): + payload = await service.preview_payload(user_info, str(place_id)) + if payload is None: + return JSONResponse(status_code=404, content={"detail": "사업장을 찾지 못했습니다"}) + return JSONResponse(content=payload) + + @router.get(path="/version/list", response_model=Res_SiteVersions, summary="빌드 버전 목록") async def list_versions(place_id: UUID, service: SiteService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)): return RemoveNoneResponse(await service.list_versions(user_info, str(place_id))) diff --git a/solution/backend/services/site_service.py b/solution/backend/services/site_service.py index fa6ae7b..788151b 100644 --- a/solution/backend/services/site_service.py +++ b/solution/backend/services/site_service.py @@ -150,6 +150,37 @@ class SiteService: ) return Res_SeoAudit(**report) + async def preview_payload(self, user_info: UserInfo, place_id: str) -> dict | None: + """에디터 미리보기가 쓰는 **발행본과 똑같은 payload**. DB 도 파일도 건드리지 않는다. + + ★ 왜 필요한가 (2026-09-09) + 미리보기와 발행본이 **렌더러를 두 벌** 쓰고 있었다 — 캔버스는 + `frontend/features/builder/canvas/variants/*`, 발행본은 `site/src/sections/*`. + 둘이 공유하는 건 타입과 CSS 토큰뿐이라 같은 데이터로도 다른 그림이 나왔다. + 실측(2026-09-09): 캔버스는 소개 섹션을 **설명 문구를 자리표시로** 그리는데 + 발행본은 데이터가 0자면 섹션째 뺀다 — 사장님은 채워진 화면을 보고 발행해 + 절반이 사라진 페이지를 받는다. `shared/lib/section-data.ts` 가 경고해 둔 + "빌더에서는 보이는데 발행하면 없다"가 파서가 아니라 **렌더러**에서 났다. + + ★ 그래서 미리보기도 이 payload 하나만 먹는다. 발행이 굽는 것과 같은 함수 + (`snapshot.build_snapshot` → `site_payload.to_site_payload`)를 그대로 거치므로, + 여기서 갈릴 자리가 없다. 버전은 아직 없으니 0 으로 넘긴다 — 화면에 안 쓰인다. + """ + from services.build_service import ensure_site, _load_links + from services.site_payload import to_site_payload + from services.snapshot import build_snapshot + + err_type, place = await self._load_place(user_info, place_id) + if err_type != ErrorType.SUCCESS or place is None: + return None + + site = await ensure_site(place_id) + links = await _load_links(place_id) + snapshot = await build_snapshot(place) + # ★ version 은 None 이다. 발행 전이라 버전 행이 없고, payload 의 site.version 은 + # 캐시 무효화 키라 미리보기에서는 뜻이 없다(to_site_payload 가 0 으로 떨어뜨린다). + return to_site_payload(place, snapshot, site, None, links) + # ---- 사이트 주소(네임스페이스) --------------------------------------- # 규칙(정규식·예약어)은 services/site_slug 한 곳에만 있다. 확인과 저장이 그것을 같이 쓴다. diff --git a/solution/frontend/src/features/builder/CanvasView.tsx b/solution/frontend/src/features/builder/CanvasView.tsx index 89fc38f..ee1b058 100644 --- a/solution/frontend/src/features/builder/CanvasView.tsx +++ b/solution/frontend/src/features/builder/CanvasView.tsx @@ -5,7 +5,7 @@ import {publishUrlString, toSlug} from '@o2o/shared'; import {deriveSurfaces} from '@/lib/color'; import {cn} from '@/lib/utils'; import {useBuilderStore, useCurrentTemplate} from '@/stores/builder'; -import {resolveVariant} from './canvas/registry'; +import {SitePreview} from './SitePreview'; import {PUBLISH_HOST} from '@/lib/site'; // ★ 호스트를 상수로 박지 않는다. PublishModal 과 **다른 주소**를 보여주면 사장님은 @@ -45,6 +45,7 @@ export function CanvasView() { const selectedSectionId = useBuilderStore((s) => s.selectedSectionId); const viewport = useBuilderStore((s) => s.viewport); const isPreviewMode = useBuilderStore((s) => s.isPreviewMode); + const placeId = useBuilderStore((s) => s.placeId); const setViewport = useBuilderStore((s) => s.setViewport); const selectSection = useBuilderStore((s) => s.selectSection); const setRightTab = useBuilderStore((s) => s.setRightTab); @@ -61,82 +62,12 @@ export function CanvasView() { // ★ 면 토큰(--tpl-surface / -alt / inverse / border)은 템플릿 색에서 유도한다. // 이걸 안 내려보내면 SectionFrame 이 폴백(고정 stone 색)으로 떨어져서, // 템플릿을 바꿔도 화면 면적의 대부분이 그대로다 — 쇼케이스와 같은 식(lib/color.ts)을 쓴다. - const surfaces = deriveSurfaces(template.colors); - - const look = template.look; - - const themeVars = { - '--tpl-surface': surfaces.surface, - '--tpl-surface-alt': surfaces.surfaceAlt, - '--tpl-inverse': surfaces.inverse, - '--tpl-border': surfaces.border, - '--tpl-primary': template.colors.primary, - '--tpl-secondary': template.colors.secondary, - '--tpl-bg': template.colors.bg, - '--tpl-card': template.colors.card, - '--tpl-text': template.colors.text, - '--tpl-accent': template.colors.accent, - - // 색만 바꾸면 템플릿이 다 같아 보인다 — 생김새를 가르는 건 아래쪽이다. - '--tpl-font-heading': look.fontHeading, - '--tpl-font-body': look.fontBody, - '--tpl-heading-tracking': look.headingTracking, - '--tpl-heading-weight': look.headingWeight, - '--tpl-section-space': look.sectionSpace, - '--tpl-border-width': look.borderWidth, - // 종이 결. 색·서체만으로는 '인쇄물'이 안 된다 — 갱지 면이 있어야 레트로가 레트로로 읽힌다. - // 없는 템플릿에서는 undefined 라 변수 자체가 안 걸리고, `.paper` 가 폴백(none)으로 떨어진다. - '--tpl-texture': look.texture, - - // ★ Tailwind v4 의 테마 변수를 캔버스 안에서만 덮는다. 이러면 변이 파일 40여 개에 흩어진 - // rounded-* · shadow-* 를 하나도 안 고치고 전부 템플릿을 따르게 된다. - // 배수는 Tailwind 기본 비율(0.25/0.375/0.5/0.75/1/1.5rem)을 그대로 옮긴 것이라, - // 기준값이 0.75rem 이면 지금까지와 픽셀 단위로 같고 0 이면 전부 각진다. - '--radius-sm': `calc(${look.radius} * 0.34)`, - '--radius-md': `calc(${look.radius} * 0.5)`, - '--radius-lg': `calc(${look.radius} * 0.67)`, - '--radius-xl': look.radius, - '--radius-2xl': `calc(${look.radius} * 1.34)`, - '--radius-3xl': `calc(${look.radius} * 2)`, - '--shadow-xs': look.shadow, - '--shadow-sm': look.shadow, - '--shadow-md': look.shadow, - } as CSSProperties; - - /** - * 섹션 하나를 그린다. - * - * 어떤 레이아웃으로 그릴지는 레지스트리가 정한다 — 여기에 switch 를 두면 - * 배리에이션을 하나 추가할 때마다 이 파일을 같이 고쳐야 한다. + /* + * ★ 색·서체 토큰을 여기서 만들지 않는다(2026-09-09). + * 캔버스가 빌더 상태로 --tpl-* 를 따로 만들던 동안, 그 값이 발행본과 갈라졌다 — + * 실측: 편집 캔버스 #ffffff·Pretendard ↔ 발행본 #e4dac0·Gugi. + * 이제 미리보기·편집 둘 다 iframe 안 발행본 렌더러가 그리고, 토큰은 payload 하나에서 온다. */ - const renderSection = (section: SectionItem) => { - if (!section.isEnabled) return null; - - const variant = resolveVariant(section, industry); - // 아직 배리에이션이 없는 섹션 타입 — 캔버스를 깨뜨리지 않고 조용히 건너뛴다. - if (!variant) return null; - - const {Component} = variant; - - return ( - { - selectSection(section.id); - setRightTab('content'); - }} - /> - ); - }; return (
@@ -175,10 +106,10 @@ export function CanvasView() { )}
-
+ {/* ★ 이 껍데기는 토큰도 폭도 정하지 않는다(2026-09-09). + 안쪽 iframe 이 **자기 뷰포트**를 만들고 색·서체는 payload 가 준다. + 바깥이 한 번 더 얹으면 두 겹이 돼 바깥 값이 안쪽을 덧칠한다 — 실제로 그랬다. */} +
{viewport !== 'pc' && (
@@ -193,10 +124,22 @@ export function CanvasView() {
)} - {/* 섹션은 좌측 패널이 정한 순서 그대로 그린다. */} -
- {sections.map((section) => renderSection(section))} -
+ {/* ★ 편집 모드도 **발행본 렌더러**가 그린다(2026-09-09). + 캔버스 전용 컴포넌트를 따로 두는 동안 두 화면이 아예 다른 트리였다 — 실측: + 발행본 15섹션 · 에디터 12섹션, 겹치는 건 4개뿐이고 이름도 달랐다 + (gallery↔photos · location↔map · guide↔local). 사장님이 편집한 화면과 + 발행된 화면이 서로 다른 물건이었다. + 고르는 일은 iframe 안 섹션을 눌러서 한다 — SitePreview 가 배선한다. */} + { + selectSection(id); + setRightTab('content'); + }} + />