From b1386dd3ce4b8c9371eb3b09b3b50dc386f3f06e Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 9 Sep 2026 17:09:28 +0900 Subject: [PATCH] =?UTF-8?q?[refactor]=20solution,nginx:=20=EC=97=90?= =?UTF-8?q?=EB=94=94=ED=84=B0=EC=99=80=20=EB=B0=9C=ED=96=89=EB=B3=B8?= =?UTF-8?q?=EC=9D=84=20=ED=95=9C=20=EB=A0=8C=EB=8D=94=EB=9F=AC=EB=A1=9C=20?= =?UTF-8?q?=E2=80=94=20=EB=B3=B4=EB=8A=94=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20?= =?UTF-8?q?=EB=82=98=EA=B0=84=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 에디터에서 본 화면과 발행된 화면이 달랐다. 렌더러를 두 벌 들고 있었기 때문이다 — 캔버스는 `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)`. 셸에는 발행본 `` 의 폰트 링크가 없어 글자만 기본 산세리프로 떨어졌다(지오메트리는 같은데 픽셀 차이 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) --- nginx/site.conf.example | 11 ++ solution/backend/router/v1/site/site.py | 19 ++ solution/backend/services/site_service.py | 31 ++++ .../src/features/builder/CanvasView.tsx | 111 +++--------- .../src/features/builder/SitePreview.tsx | 146 +++++++++++++++ .../src/features/builder/canvas/dataSpec.ts | 71 ++++++++ solution/frontend/src/hooks/usePlaceSync.ts | 8 +- solution/frontend/src/stores/builder.ts | 30 +++- solution/frontend/tsconfig.json | 1 + solution/frontend/vite.config.ts | 6 + solution/site/scripts/prerender.ts | 46 ++++- solution/site/src/App.tsx | 18 +- solution/site/src/entry-client.tsx | 65 +++++++ solution/site/src/layouts/editorial/Hero.tsx | 10 +- solution/site/src/layouts/editorial/Rooms.tsx | 8 +- .../src/layouts/editorial/SectionHead.tsx | 4 +- solution/site/src/layouts/editorial/Shell.tsx | 10 +- solution/site/src/layouts/oasi/Hero.tsx | 8 +- solution/site/src/layouts/oasi/Rooms.tsx | 6 +- solution/site/src/layouts/oasi/Shell.tsx | 11 +- solution/site/src/layouts/pastel/Hero.tsx | 6 +- solution/site/src/layouts/pastel/Rooms.tsx | 6 +- solution/site/src/layouts/pastel/Shell.tsx | 8 +- .../site/src/layouts/reservation/Hero.tsx | 4 +- .../site/src/layouts/reservation/Rooms.tsx | 8 +- .../site/src/layouts/reservation/Shell.tsx | 9 +- solution/site/src/layouts/studio/Hero.tsx | 6 +- solution/site/src/layouts/studio/Rooms.tsx | 4 +- solution/site/src/layouts/studio/Shell.tsx | 12 +- solution/site/src/lib/derive.ts | 15 +- solution/site/src/lib/layout.ts | 2 +- solution/site/src/lib/ui/Section.tsx | 12 +- solution/site/src/pages/HomePage.tsx | 19 +- solution/site/src/sections/AboutSection.tsx | 6 +- solution/site/src/sections/AnswerBlock.tsx | 8 +- solution/site/src/sections/BookingSection.tsx | 6 +- .../src/sections/EssentialInfoSection.tsx | 12 +- .../site/src/sections/ExhibitionSection.tsx | 6 +- solution/site/src/sections/FaqSection.tsx | 6 +- .../site/src/sections/FestivalSection.tsx | 8 +- solution/site/src/sections/GallerySection.tsx | 6 +- solution/site/src/sections/HeroPension.tsx | 6 +- solution/site/src/sections/HeroSection.tsx | 18 +- solution/site/src/sections/HeroSplit.tsx | 6 +- solution/site/src/sections/InquirySection.tsx | 6 +- .../site/src/sections/LocalGuideSection.tsx | 6 +- .../site/src/sections/LocationSection.tsx | 6 +- solution/site/src/sections/MobileTabBar.tsx | 6 +- solution/site/src/sections/PricingSection.tsx | 86 --------- solution/site/src/sections/SiteFooter.tsx | 6 +- solution/site/src/sections/SiteHeader.tsx | 4 +- solution/site/src/sections/SpaceSection.tsx | 6 +- .../site/src/sections/StayBookingDemo.tsx | 4 +- .../site/src/sections/StayBookingSection.tsx | 4 +- solution/site/src/sections/UnitsBands.tsx | 8 +- solution/site/src/sections/UnitsSection.tsx | 18 +- solution/site/src/sections/UnitsTabs.tsx | 8 +- solution/site/src/sections/WeatherSection.tsx | 8 +- solution/site/src/sections/index.ts | 1 - .../src/sections/items/ChronicleSection.tsx | 4 +- .../site/src/sections/items/DailySection.tsx | 4 +- .../site/src/sections/items/EventSection.tsx | 4 +- .../src/sections/items/ItinerarySection.tsx | 4 +- .../site/src/sections/items/PeopleSection.tsx | 4 +- .../src/sections/items/PlannerSection.tsx | 169 ------------------ .../src/sections/items/PostcardSection.tsx | 4 +- .../site/src/sections/items/QuizSection.tsx | 4 +- solution/site/src/sections/items/RouteMap.tsx | 4 +- .../site/src/sections/items/SongsSection.tsx | 6 +- .../site/src/sections/items/StorySection.tsx | 4 +- .../site/src/sections/items/VideoSection.tsx | 4 +- solution/site/src/sections/items/common.tsx | 14 +- solution/site/src/sections/items/index.ts | 3 - .../site/src/sections/stay-booking.test.tsx | 16 +- solution/site/src/seo/head.ts | 43 +++-- solution/site/tsconfig.json | 2 +- solution/site/vite.config.ts | 6 +- 77 files changed, 710 insertions(+), 585 deletions(-) create mode 100644 solution/frontend/src/features/builder/SitePreview.tsx delete mode 100644 solution/site/src/sections/PricingSection.tsx delete mode 100644 solution/site/src/sections/items/PlannerSection.tsx 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'); + }} + />